ир
This commit is contained in:
@@ -4,3 +4,9 @@ CHANNEL_MESSAGE_ID=
|
|||||||
ADMIN_IDS=
|
ADMIN_IDS=
|
||||||
CONFIG_PATH=config/actors.json
|
CONFIG_PATH=config/actors.json
|
||||||
STATE_PATH=data/state.json
|
STATE_PATH=data/state.json
|
||||||
|
HIDDEN_LINK_URL=
|
||||||
|
HIDDEN_LINK_CHAR=​
|
||||||
|
TELETHON_API_ID=
|
||||||
|
TELETHON_API_HASH=
|
||||||
|
TELETHON_SESSION_STRING=
|
||||||
|
TELETHON_CHANNEL=
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ requires-python = ">=3.11"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aiogram>=3.21.0,<4.0.0",
|
"aiogram>=3.21.0,<4.0.0",
|
||||||
"python-dotenv>=1.0.1,<2.0.0",
|
"python-dotenv>=1.0.1,<2.0.0",
|
||||||
|
"telethon>=1.42.0,<2.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
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.html_entities import html_to_text_entities
|
||||||
from session_bot.render import build_channel_text
|
from session_bot.render import build_channel_text
|
||||||
from session_bot.storage import JsonStateStorage
|
from session_bot.storage import JsonStateStorage
|
||||||
|
from session_bot.telethon_publisher import TelethonPublisher
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
STATUS_CHOICES = {
|
STATUS_CHOICES = {
|
||||||
"open": "Открыть",
|
"open": "Открыть",
|
||||||
"backstage": "Закулисье",
|
"backstage": "Закулисье",
|
||||||
"delay": "Задержка",
|
"delay": "Задержка",
|
||||||
"rest": "Антракт",
|
"rest": "Антракт",
|
||||||
}
|
}
|
||||||
|
|
||||||
HELP_TEXT = (
|
HELP_TEXT = (
|
||||||
"Команды\n"
|
"Команды\n"
|
||||||
"/start или /panel - открыть панель\n"
|
"/start или /panel - открыть панель\n"
|
||||||
"/help - показать справку\n"
|
"/help - показать справку\n"
|
||||||
"/refresh - перерисовать пост в канале\n"
|
"/refresh - перерисовать пост в канале\n"
|
||||||
"/post_test - отправить текущий пост себе в личку для проверки\n"
|
"/post_test - отправить текущий пост себе в личку для проверки\n"
|
||||||
"/cancel - сбросить текущий ввод\n"
|
"/cancel - сбросить текущий ввод\n"
|
||||||
"/post - сохранить шаблон поста\n"
|
"/post - сохранить шаблон поста\n"
|
||||||
"/template_dump - показать HTML из сообщения-реплая\n\n"
|
"/template_dump - показать HTML из сообщения-реплая\n\n"
|
||||||
"Шаблон поста\n"
|
"Шаблон поста\n"
|
||||||
"Можно использовать один общий {{actors}} для всего блока актеров.\n"
|
"Можно использовать один общий {{actors}} для всего блока актеров.\n"
|
||||||
"Или точечные плейсхолдеры: {{actor:liebe}}, {{actor:mari}}.\n"
|
"Рли точечные плейсхолдеры: {{actor:liebe}}, {{actor:mari}}.\n"
|
||||||
"Можно добавить {{hidden_link}} в начало, либо задать HIDDEN_LINK_URL в .env.\n"
|
"Можно добавить {{hidden_link}} в начало, либо задать HIDDEN_LINK_URL в .env.\n"
|
||||||
"Если нужна готовая Telegram-разметка, используйте /post ответом на уже оформленное сообщение."
|
"Если нужна готовая Telegram-разметка, используйте /post ответом на уже оформленное сообщение."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -82,21 +83,21 @@ def build_status_keyboard(actor_key: str) -> InlineKeyboardBuilder:
|
|||||||
keyboard = InlineKeyboardBuilder()
|
keyboard = InlineKeyboardBuilder()
|
||||||
for status_key, title in STATUS_CHOICES.items():
|
for status_key, title in STATUS_CHOICES.items():
|
||||||
keyboard.button(text=title, callback_data=f"status:{actor_key}:{status_key}")
|
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=f"custom:{actor_key}")
|
||||||
keyboard.button(text="⬅️ Назад", callback_data="nav:panel")
|
keyboard.button(text="⬅️ Назад", callback_data="nav:panel")
|
||||||
keyboard.adjust(2)
|
keyboard.adjust(2)
|
||||||
return keyboard
|
return keyboard
|
||||||
|
|
||||||
|
|
||||||
def build_back_to_actor_keyboard(actor_key: str) -> InlineKeyboardBuilder:
|
def build_back_to_actor_keyboard(actor_key: str) -> InlineKeyboardBuilder:
|
||||||
keyboard = InlineKeyboardBuilder()
|
keyboard = InlineKeyboardBuilder()
|
||||||
keyboard.button(text="⬅️ Назад", callback_data=f"actor:{actor_key}")
|
keyboard.button(text="⬅️ Назад", callback_data=f"actor:{actor_key}")
|
||||||
return keyboard
|
return keyboard
|
||||||
|
|
||||||
|
|
||||||
def build_back_to_panel_keyboard() -> InlineKeyboardBuilder:
|
def build_back_to_panel_keyboard() -> InlineKeyboardBuilder:
|
||||||
keyboard = InlineKeyboardBuilder()
|
keyboard = InlineKeyboardBuilder()
|
||||||
keyboard.button(text="К панели", callback_data="nav:panel")
|
keyboard.button(text="К панели", callback_data="nav:panel")
|
||||||
return keyboard
|
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))
|
specific_count = len(re.findall(r"\{\{actor:[a-z0-9_\-]+\}\}", normalized, flags=re.IGNORECASE))
|
||||||
|
|
||||||
if common_count == 0 and specific_count == 0:
|
if common_count == 0 and specific_count == 0:
|
||||||
return "Шаблон сохранен, но в нем нет {{actors}} или {{actor:key}}."
|
return "Шаблон сохранен, но в нем нет {{actors}} или {{actor:key}}."
|
||||||
if common_count > 1:
|
if common_count > 1:
|
||||||
return "Шаблон сохранен, но в нем несколько {{actors}}. Нужен только один общий {{actors}}."
|
return "Шаблон сохранен, но в нем несколько {{actors}}. Нужен только один общий {{actors}}."
|
||||||
return None
|
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:
|
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)
|
keyboard = build_actor_keyboard(app_config, user_id, settings.admin_ids)
|
||||||
text = "Выберите персонажа для смены статуса."
|
text = "Выберите персонажа для смены статуса."
|
||||||
|
|
||||||
if isinstance(target, CallbackQuery):
|
if isinstance(target, CallbackQuery):
|
||||||
await safe_edit_message(target, text, keyboard.as_markup())
|
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, ""))
|
current_phrase = runtime_state.get("phrase", actor.get("phrases", {}).get(current_status, ""))
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f"Выбран {actor['display_name']}.",
|
f"Выбран {actor['display_name']}.",
|
||||||
f"Текущий статус: {STATUS_CHOICES.get(current_status, current_status)}.",
|
f"Текущий статус: {STATUS_CHOICES.get(current_status, current_status)}.",
|
||||||
]
|
]
|
||||||
if current_phrase:
|
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())
|
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()
|
state = state_storage.load()
|
||||||
if "template" in state and "text" in state["template"]:
|
if "template" in state and "text" in state["template"]:
|
||||||
normalized_template = normalize_template_placeholders(state["template"]["text"])
|
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["template"]["text"] = normalized_template
|
||||||
state_storage.save(state)
|
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
|
link_preview_options = None
|
||||||
if app_config.get("hidden_link_url", "").strip():
|
if app_config.get("hidden_link_url", "").strip():
|
||||||
link_preview_options = LinkPreviewOptions(
|
link_preview_options = LinkPreviewOptions(
|
||||||
@@ -263,6 +274,7 @@ async def apply_status_update(
|
|||||||
app_config: dict,
|
app_config: dict,
|
||||||
state_storage: JsonStateStorage,
|
state_storage: JsonStateStorage,
|
||||||
settings,
|
settings,
|
||||||
|
mtproto_publisher: TelethonPublisher | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
payload = state_storage.load()
|
payload = state_storage.load()
|
||||||
payload.setdefault("actors", {})
|
payload.setdefault("actors", {})
|
||||||
@@ -272,7 +284,7 @@ async def apply_status_update(
|
|||||||
"updated_by": user_id,
|
"updated_by": user_id,
|
||||||
}
|
}
|
||||||
state_storage.save(payload)
|
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:
|
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()
|
await state.clear()
|
||||||
user_id = message.from_user.id
|
user_id = message.from_user.id
|
||||||
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
||||||
await message.answer("У вас нет доступа к этой панели.")
|
await message.answer("У вас нет доступа к этой панели.")
|
||||||
return
|
return
|
||||||
await show_panel(message, user_id, app_config, settings)
|
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()
|
await state.clear()
|
||||||
user_id = message.from_user.id
|
user_id = message.from_user.id
|
||||||
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
||||||
await message.answer("Состояние очищено.")
|
await message.answer("Состояние очищено.")
|
||||||
return
|
return
|
||||||
await show_panel(message, user_id, app_config, settings)
|
await show_panel(message, user_id, app_config, settings)
|
||||||
|
|
||||||
@@ -339,14 +351,15 @@ async def refresh_handler(
|
|||||||
state_storage: JsonStateStorage,
|
state_storage: JsonStateStorage,
|
||||||
actor_lookup: dict,
|
actor_lookup: dict,
|
||||||
settings,
|
settings,
|
||||||
|
mtproto_publisher: TelethonPublisher | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
user_id = message.from_user.id
|
user_id = message.from_user.id
|
||||||
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
||||||
await message.answer("У вас нет доступа к обновлению.")
|
await message.answer("У вас нет доступа к обновлению.")
|
||||||
return
|
return
|
||||||
|
|
||||||
await update_channel_post(bot, app_config, state_storage, settings)
|
await update_channel_post(bot, app_config, state_storage, settings, mtproto_publisher)
|
||||||
await message.answer("Сообщение канала обновлено.")
|
await message.answer("Сообщение канала обновлено.")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("post_test"))
|
@router.message(Command("post_test"))
|
||||||
@@ -360,32 +373,32 @@ async def post_test_handler(
|
|||||||
) -> None:
|
) -> None:
|
||||||
user_id = message.from_user.id
|
user_id = message.from_user.id
|
||||||
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
||||||
await message.answer("У вас нет доступа к тестовой отправке.")
|
await message.answer("У вас нет доступа к тестовой отправке.")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await send_test_post(bot, message.chat.id, app_config, state_storage)
|
await send_test_post(bot, message.chat.id, app_config, state_storage)
|
||||||
except TelegramBadRequest as exc:
|
except TelegramBadRequest as exc:
|
||||||
await message.answer(f"Тестовая отправка не удалась:\n{exc}")
|
await message.answer(f"Тестовая отправка не удалась:\n{exc}")
|
||||||
return
|
return
|
||||||
|
|
||||||
await message.answer("Тестовый пост отправлен.")
|
await message.answer("Тестовый пост отправлен.")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("template_dump"))
|
@router.message(Command("template_dump"))
|
||||||
async def template_dump_handler(message: Message, actor_lookup: dict, settings) -> None:
|
async def template_dump_handler(message: Message, actor_lookup: dict, settings) -> None:
|
||||||
user_id = message.from_user.id
|
user_id = message.from_user.id
|
||||||
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
||||||
await message.answer("У вас нет доступа к шаблонам поста.")
|
await message.answer("У вас нет доступа к шаблонам поста.")
|
||||||
return
|
return
|
||||||
if message.reply_to_message is None:
|
if message.reply_to_message is None:
|
||||||
await message.answer("Используйте /template_dump ответом на оформленное сообщение.")
|
await message.answer("Рспользуйте /template_dump ответом РЅР° оформленное сообщение.")
|
||||||
return
|
return
|
||||||
source = message.reply_to_message
|
source = message.reply_to_message
|
||||||
if source.html_text:
|
if source.html_text:
|
||||||
await message.answer(source.html_text)
|
await message.answer(source.html_text)
|
||||||
return
|
return
|
||||||
await message.answer("У сообщения нет HTML-представления, которое можно извлечь.")
|
await message.answer("У сообщения нет HTML-представления, которое можно извлечь.")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("post"))
|
@router.message(Command("post"))
|
||||||
@@ -398,7 +411,7 @@ async def post_handler(
|
|||||||
) -> None:
|
) -> None:
|
||||||
user_id = message.from_user.id
|
user_id = message.from_user.id
|
||||||
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
||||||
await message.answer("У вас нет доступа к шаблонам поста.")
|
await message.answer("У вас нет доступа к шаблонам поста.")
|
||||||
return
|
return
|
||||||
|
|
||||||
template = extract_template_text(message)
|
template = extract_template_text(message)
|
||||||
@@ -414,19 +427,19 @@ async def post_handler(
|
|||||||
|
|
||||||
if message.text and message.text.startswith("/post "):
|
if message.text and message.text.startswith("/post "):
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"Шаблон сохранен.\n"
|
"Шаблон сохранен.\n"
|
||||||
"Но если нужна готовая разметка, ссылки и premium emoji, лучше использовать /post ответом на уже оформленное сообщение."
|
"Но если нужна готовая разметка, ссылки и premium emoji, лучше использовать /post ответом на уже оформленное сообщение."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await message.answer("Шаблон поста сохранен.")
|
await message.answer("Шаблон поста сохранен.")
|
||||||
return
|
return
|
||||||
|
|
||||||
await state.set_state(SessionForm.waiting_for_post_template)
|
await state.set_state(SessionForm.waiting_for_post_template)
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"Перешлите или отправьте шаблон одним сообщением.\n"
|
"Перешлите или отправьте шаблон одним сообщением.\n"
|
||||||
"Используйте {{actors}} для общего блока или {{actor:key}} для конкретного актера.\n"
|
"Рспользуйте {{actors}} для общего блока или {{actor:key}} для конкретного актера.\n"
|
||||||
"Для скрытой ссылки можно использовать {{hidden_link}}."
|
"Для скрытой ссылки можно использовать {{hidden_link}}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -435,7 +448,7 @@ async def panel_callback(callback: CallbackQuery, state: FSMContext, app_config:
|
|||||||
await state.clear()
|
await state.clear()
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
||||||
await callback.answer("Нет доступа.", show_alert=True)
|
await callback.answer("Нет доступа.", show_alert=True)
|
||||||
return
|
return
|
||||||
await show_panel(callback, user_id, app_config, settings)
|
await show_panel(callback, user_id, app_config, settings)
|
||||||
|
|
||||||
@@ -452,17 +465,17 @@ async def actor_handler(
|
|||||||
await state.clear()
|
await state.clear()
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
||||||
await callback.answer("Нет доступа.", show_alert=True)
|
await callback.answer("Нет доступа.", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
actor_key = callback.data.split(":", maxsplit=1)[1]
|
actor_key = callback.data.split(":", maxsplit=1)[1]
|
||||||
actor = find_actor(app_config, actor_key)
|
actor = find_actor(app_config, actor_key)
|
||||||
if actor is None:
|
if actor is None:
|
||||||
await callback.answer("Персонаж не найден.", show_alert=True)
|
await callback.answer("Персонаж не найден.", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
if user_id not in settings.admin_ids and user_id not in actor_operator_ids(actor):
|
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
|
return
|
||||||
|
|
||||||
await show_actor_status_menu(callback, actor, state_storage)
|
await show_actor_status_menu(callback, actor, state_storage)
|
||||||
@@ -477,20 +490,21 @@ async def status_handler(
|
|||||||
actor_lookup: dict,
|
actor_lookup: dict,
|
||||||
app_config: dict,
|
app_config: dict,
|
||||||
state_storage: JsonStateStorage,
|
state_storage: JsonStateStorage,
|
||||||
|
mtproto_publisher: TelethonPublisher | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
||||||
await callback.answer("Нет доступа.", show_alert=True)
|
await callback.answer("Нет доступа.", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
_, actor_key, status_key = callback.data.split(":")
|
_, actor_key, status_key = callback.data.split(":")
|
||||||
actor = find_actor(app_config, actor_key)
|
actor = find_actor(app_config, actor_key)
|
||||||
if actor is None:
|
if actor is None:
|
||||||
await callback.answer("Персонаж не найден.", show_alert=True)
|
await callback.answer("Персонаж не найден.", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
if user_id not in settings.admin_ids and user_id not in actor_operator_ids(actor):
|
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
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -503,23 +517,24 @@ async def status_handler(
|
|||||||
app_config=app_config,
|
app_config=app_config,
|
||||||
state_storage=state_storage,
|
state_storage=state_storage,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
mtproto_publisher=mtproto_publisher,
|
||||||
)
|
)
|
||||||
except TelegramBadRequest as exc:
|
except Exception as exc:
|
||||||
logging.exception("Failed to edit channel message")
|
logging.exception("Failed to edit channel message")
|
||||||
await callback.answer("Не удалось обновить сообщение канала.", show_alert=True)
|
await callback.answer("Не удалось обновить сообщение канала.", show_alert=True)
|
||||||
await safe_edit_message(
|
await safe_edit_message(
|
||||||
callback,
|
callback,
|
||||||
f"Статус сохранен, но сообщение канала не обновилось:\n{exc}",
|
f"Статус сохранен, но сообщение канала не обновилось:\n{exc}",
|
||||||
build_back_to_panel_keyboard().as_markup(),
|
build_back_to_panel_keyboard().as_markup(),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await safe_edit_message(
|
await safe_edit_message(
|
||||||
callback,
|
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(),
|
build_back_to_panel_keyboard().as_markup(),
|
||||||
)
|
)
|
||||||
await callback.answer("Готово")
|
await callback.answer("Готово")
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("custom:"))
|
@router.callback_query(F.data.startswith("custom:"))
|
||||||
@@ -533,17 +548,17 @@ async def custom_phrase_handler(
|
|||||||
) -> None:
|
) -> None:
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
|
||||||
await callback.answer("Нет доступа.", show_alert=True)
|
await callback.answer("Нет доступа.", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
_, actor_key = callback.data.split(":")
|
_, actor_key = callback.data.split(":")
|
||||||
actor = find_actor(app_config, actor_key)
|
actor = find_actor(app_config, actor_key)
|
||||||
if actor is None:
|
if actor is None:
|
||||||
await callback.answer("Персонаж не найден.", show_alert=True)
|
await callback.answer("Персонаж не найден.", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
if user_id not in settings.admin_ids and user_id not in actor_operator_ids(actor):
|
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
|
return
|
||||||
|
|
||||||
runtime_state = get_actor_runtime_state(actor, state_storage)
|
runtime_state = get_actor_runtime_state(actor, state_storage)
|
||||||
@@ -554,8 +569,8 @@ async def custom_phrase_handler(
|
|||||||
await safe_edit_message(
|
await safe_edit_message(
|
||||||
callback,
|
callback,
|
||||||
(
|
(
|
||||||
f"Введите свою фразу для {actor['display_name']}.\n"
|
f"Введите свою фразу для {actor['display_name']}.\n"
|
||||||
f"Текущий статус останется: {STATUS_CHOICES.get(status_key, status_key)}."
|
f"Текущий статус останется: {STATUS_CHOICES.get(status_key, status_key)}."
|
||||||
),
|
),
|
||||||
build_back_to_actor_keyboard(actor_key).as_markup(),
|
build_back_to_actor_keyboard(actor_key).as_markup(),
|
||||||
)
|
)
|
||||||
@@ -570,9 +585,10 @@ async def phrase_handler(
|
|||||||
app_config: dict,
|
app_config: dict,
|
||||||
state_storage: JsonStateStorage,
|
state_storage: JsonStateStorage,
|
||||||
settings,
|
settings,
|
||||||
|
mtproto_publisher: TelethonPublisher | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if message.text is None:
|
if message.text is None:
|
||||||
await message.answer("Нужен текст сообщения.")
|
await message.answer("Нужен текст сообщения.")
|
||||||
return
|
return
|
||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
@@ -581,12 +597,12 @@ async def phrase_handler(
|
|||||||
actor = find_actor(app_config, actor_key)
|
actor = find_actor(app_config, actor_key)
|
||||||
if actor is None:
|
if actor is None:
|
||||||
await state.clear()
|
await state.clear()
|
||||||
await message.answer("Персонаж не найден.")
|
await message.answer("Персонаж не найден.")
|
||||||
return
|
return
|
||||||
|
|
||||||
phrase = message.text.strip()
|
phrase = message.text.strip()
|
||||||
if not phrase:
|
if not phrase:
|
||||||
await message.answer("Фраза не должна быть пустой.")
|
await message.answer("Фраза не должна быть пустой.")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -599,16 +615,17 @@ async def phrase_handler(
|
|||||||
app_config=app_config,
|
app_config=app_config,
|
||||||
state_storage=state_storage,
|
state_storage=state_storage,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
mtproto_publisher=mtproto_publisher,
|
||||||
)
|
)
|
||||||
except TelegramBadRequest as exc:
|
except Exception as exc:
|
||||||
logging.exception("Failed to edit channel message")
|
logging.exception("Failed to edit channel message")
|
||||||
await message.answer(f"Статус сохранен, но сообщение канала не обновилось:\n{exc}")
|
await message.answer(f"Статус сохранен, но сообщение канала не обновилось:\n{exc}")
|
||||||
await state.clear()
|
await state.clear()
|
||||||
return
|
return
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
await message.answer(
|
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(),
|
reply_markup=build_back_to_panel_keyboard().as_markup(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -621,7 +638,7 @@ async def post_template_handler(
|
|||||||
) -> None:
|
) -> None:
|
||||||
template = extract_template_text(message)
|
template = extract_template_text(message)
|
||||||
if template is None:
|
if template is None:
|
||||||
await message.answer("Нужен текстовый шаблон. Перешлите текстовый пост или отправьте текст.")
|
await message.answer("Нужен текстовый шаблон. Перешлите текстовый пост или отправьте текст.")
|
||||||
return
|
return
|
||||||
|
|
||||||
normalized = normalize_template_placeholders(template)
|
normalized = normalize_template_placeholders(template)
|
||||||
@@ -633,15 +650,21 @@ async def post_template_handler(
|
|||||||
await message.answer(validation_error)
|
await message.answer(validation_error)
|
||||||
return
|
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 = Dispatcher(storage=MemoryStorage())
|
||||||
dispatcher["app_config"] = app_config
|
dispatcher["app_config"] = app_config
|
||||||
dispatcher["actor_lookup"] = build_actor_lookup(app_config)
|
dispatcher["actor_lookup"] = build_actor_lookup(app_config)
|
||||||
dispatcher["settings"] = settings
|
dispatcher["settings"] = settings
|
||||||
dispatcher["state_storage"] = state_storage
|
dispatcher["state_storage"] = state_storage
|
||||||
|
dispatcher["mtproto_publisher"] = mtproto_publisher
|
||||||
dispatcher.include_router(router)
|
dispatcher.include_router(router)
|
||||||
return dispatcher
|
return dispatcher
|
||||||
|
|
||||||
@@ -655,10 +678,17 @@ async def main() -> None:
|
|||||||
if settings.hidden_link_char:
|
if settings.hidden_link_char:
|
||||||
app_config["hidden_link_char"] = settings.hidden_link_char
|
app_config["hidden_link_char"] = settings.hidden_link_char
|
||||||
state_storage = JsonStateStorage(settings.state_path)
|
state_storage = JsonStateStorage(settings.state_path)
|
||||||
|
mtproto_publisher = TelethonPublisher(settings) if settings.telethon_enabled else None
|
||||||
|
|
||||||
bot = Bot(token=settings.bot_token)
|
bot = Bot(token=settings.bot_token)
|
||||||
dispatcher = build_dispatcher(app_config, settings, state_storage)
|
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)
|
await dispatcher.start_polling(bot)
|
||||||
|
finally:
|
||||||
|
if mtproto_publisher is not None:
|
||||||
|
await mtproto_publisher.close()
|
||||||
|
|
||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ class BotSettings:
|
|||||||
admin_ids: set[int]
|
admin_ids: set[int]
|
||||||
hidden_link_url: str
|
hidden_link_url: str
|
||||||
hidden_link_char: str
|
hidden_link_char: str
|
||||||
|
telethon_api_id: int | None
|
||||||
|
telethon_api_hash: str
|
||||||
|
telethon_session_string: str
|
||||||
|
telethon_channel: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telethon_enabled(self) -> bool:
|
||||||
|
return bool(self.telethon_api_id and self.telethon_api_hash and self.telethon_session_string)
|
||||||
|
|
||||||
|
|
||||||
def _parse_admin_ids(value: str) -> set[int]:
|
def _parse_admin_ids(value: str) -> set[int]:
|
||||||
@@ -32,6 +40,8 @@ def _parse_admin_ids(value: str) -> set[int]:
|
|||||||
def load_settings() -> BotSettings:
|
def load_settings() -> BotSettings:
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
telethon_api_id = os.environ.get("TELETHON_API_ID", "").strip()
|
||||||
|
|
||||||
return BotSettings(
|
return BotSettings(
|
||||||
bot_token=os.environ["BOT_TOKEN"],
|
bot_token=os.environ["BOT_TOKEN"],
|
||||||
channel_id=int(os.environ["CHANNEL_ID"]),
|
channel_id=int(os.environ["CHANNEL_ID"]),
|
||||||
@@ -41,6 +51,10 @@ def load_settings() -> BotSettings:
|
|||||||
admin_ids=_parse_admin_ids(os.environ.get("ADMIN_IDS", "")),
|
admin_ids=_parse_admin_ids(os.environ.get("ADMIN_IDS", "")),
|
||||||
hidden_link_url=os.environ.get("HIDDEN_LINK_URL", ""),
|
hidden_link_url=os.environ.get("HIDDEN_LINK_URL", ""),
|
||||||
hidden_link_char=os.environ.get("HIDDEN_LINK_CHAR", "​"),
|
hidden_link_char=os.environ.get("HIDDEN_LINK_CHAR", "​"),
|
||||||
|
telethon_api_id=int(telethon_api_id) if telethon_api_id else None,
|
||||||
|
telethon_api_hash=os.environ.get("TELETHON_API_HASH", "").strip(),
|
||||||
|
telethon_session_string=os.environ.get("TELETHON_SESSION_STRING", "").strip(),
|
||||||
|
telethon_channel=os.environ.get("TELETHON_CHANNEL", "").strip(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ PLAIN_LINK_RE = re.compile(r"(?P<label>[^\n<>()]+?) \((?P<url>https?://[^\s)]+)\
|
|||||||
|
|
||||||
|
|
||||||
def build_hidden_link(config: dict) -> str:
|
def build_hidden_link(config: dict) -> str:
|
||||||
|
url = config.get("hidden_link_url", "").strip()
|
||||||
|
if not url:
|
||||||
return ""
|
return ""
|
||||||
|
char = config.get("hidden_link_char", "​") or "​"
|
||||||
|
return f'<a href="{escape(url, quote=True)}">{char}</a>'
|
||||||
|
|
||||||
|
|
||||||
def convert_plain_links_to_html(template: str) -> str:
|
def convert_plain_links_to_html(template: str) -> str:
|
||||||
@@ -101,13 +105,16 @@ def build_default_template(config: dict) -> str:
|
|||||||
return "\n\n".join(blocks)
|
return "\n\n".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
def build_channel_text(config: dict, state: dict) -> str:
|
def build_channel_text(config: dict, state: dict, *, include_hidden_link: bool = False) -> str:
|
||||||
template = state.get("template", {}).get("text") or config.get("template_text") or build_default_template(config)
|
template = state.get("template", {}).get("text") or config.get("template_text") or build_default_template(config)
|
||||||
template = convert_plain_links_to_html(template)
|
template = convert_plain_links_to_html(template)
|
||||||
template, used_keys = replace_actor_placeholders(template, config, state)
|
template, used_keys = replace_actor_placeholders(template, config, state)
|
||||||
actors_block = build_actor_lines(config, state, skip_keys=used_keys)
|
actors_block = build_actor_lines(config, state, skip_keys=used_keys)
|
||||||
|
|
||||||
text = template.replace("{{actors}}", actors_block)
|
text = template.replace("{{actors}}", actors_block)
|
||||||
|
hidden_link = build_hidden_link(config) if include_hidden_link else ""
|
||||||
text = text.replace("{{hidden_link}}", "")
|
text = text.replace("{{hidden_link}}", "")
|
||||||
|
if include_hidden_link and hidden_link:
|
||||||
|
text = f"{hidden_link}{text}"
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|||||||
159
session_bot/telethon_publisher.py
Normal file
159
session_bot/telethon_publisher.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from aiogram.types import MessageEntity
|
||||||
|
|
||||||
|
from session_bot.html_entities import html_to_text_entities
|
||||||
|
from session_bot.render import build_channel_text
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from session_bot.config import BotSettings
|
||||||
|
from session_bot.storage import JsonStateStorage
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TelethonPublisher:
|
||||||
|
settings: "BotSettings"
|
||||||
|
_client: Any = None
|
||||||
|
_peer: Any = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self.settings.telethon_enabled
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if not self.enabled or self._client is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from telethon import TelegramClient
|
||||||
|
from telethon.sessions import StringSession
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Telethon is not installed. Add it to the runtime environment to use channel editing via MTProto."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
self._client = TelegramClient(
|
||||||
|
StringSession(self.settings.telethon_session_string),
|
||||||
|
self.settings.telethon_api_id,
|
||||||
|
self.settings.telethon_api_hash,
|
||||||
|
)
|
||||||
|
await self._client.connect()
|
||||||
|
if not await self._client.is_user_authorized():
|
||||||
|
raise RuntimeError("Telethon session is not authorized. Generate a valid StringSession first.")
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._client is None:
|
||||||
|
return
|
||||||
|
await self._client.disconnect()
|
||||||
|
self._client = None
|
||||||
|
self._peer = None
|
||||||
|
|
||||||
|
async def edit_channel_post(self, app_config: dict, state_storage: "JsonStateStorage", message_id: int) -> None:
|
||||||
|
await self.start()
|
||||||
|
html_text = build_channel_text(app_config, state_storage.load(), include_hidden_link=True)
|
||||||
|
text, bot_entities = html_to_text_entities(html_text)
|
||||||
|
entities = self._convert_entities(bot_entities)
|
||||||
|
peer = await self._resolve_peer()
|
||||||
|
await self._client.edit_message(
|
||||||
|
peer,
|
||||||
|
message_id,
|
||||||
|
text=text,
|
||||||
|
formatting_entities=entities,
|
||||||
|
link_preview=bool(app_config.get("hidden_link_url", "").strip()),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _resolve_peer(self) -> Any:
|
||||||
|
if self._peer is not None:
|
||||||
|
return self._peer
|
||||||
|
if self._client is None:
|
||||||
|
raise RuntimeError("Telethon client is not started.")
|
||||||
|
|
||||||
|
candidates: list[Any] = []
|
||||||
|
channel_ref = self.settings.telethon_channel.strip()
|
||||||
|
if channel_ref:
|
||||||
|
candidates.extend(self._candidate_refs(channel_ref))
|
||||||
|
else:
|
||||||
|
candidates.extend(self._candidate_refs(self.settings.channel_id))
|
||||||
|
|
||||||
|
last_error: Exception | None = None
|
||||||
|
seen: set[str] = set()
|
||||||
|
for candidate in candidates:
|
||||||
|
key = repr(candidate)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
try:
|
||||||
|
self._peer = await self._client.get_entity(candidate)
|
||||||
|
return self._peer
|
||||||
|
except Exception as exc: # pragma: no cover - depends on runtime Telegram session
|
||||||
|
last_error = exc
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
"Unable to resolve TELETHON_CHANNEL. Set TELETHON_CHANNEL to @username, t.me link, or numeric channel id."
|
||||||
|
) from last_error
|
||||||
|
|
||||||
|
def _candidate_refs(self, value: str | int) -> list[Any]:
|
||||||
|
candidates: list[Any] = [value]
|
||||||
|
raw = str(value).strip()
|
||||||
|
if not raw:
|
||||||
|
return candidates
|
||||||
|
if raw.lstrip("-").isdigit():
|
||||||
|
numeric = int(raw)
|
||||||
|
candidates.append(numeric)
|
||||||
|
if raw.startswith("-100"):
|
||||||
|
candidates.append(int(raw[4:]))
|
||||||
|
try:
|
||||||
|
from telethon import utils
|
||||||
|
|
||||||
|
resolved_id, _ = utils.resolve_id(numeric)
|
||||||
|
candidates.append(resolved_id)
|
||||||
|
except Exception: # pragma: no cover - best effort normalization
|
||||||
|
pass
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _convert_entities(self, entities: list[MessageEntity]) -> list[Any]:
|
||||||
|
try:
|
||||||
|
from telethon.tl import types
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Telethon is not installed. Add it to the runtime environment to use channel editing via MTProto."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
converted: list[Any] = []
|
||||||
|
for entity in entities:
|
||||||
|
item = self._convert_entity(entity, types)
|
||||||
|
if item is not None:
|
||||||
|
converted.append(item)
|
||||||
|
return converted
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_entity(entity: MessageEntity, types: Any) -> Any | None:
|
||||||
|
kwargs = {"offset": entity.offset, "length": entity.length}
|
||||||
|
|
||||||
|
if entity.type == "bold":
|
||||||
|
return types.MessageEntityBold(**kwargs)
|
||||||
|
if entity.type == "italic":
|
||||||
|
return types.MessageEntityItalic(**kwargs)
|
||||||
|
if entity.type == "underline":
|
||||||
|
return types.MessageEntityUnderline(**kwargs)
|
||||||
|
if entity.type == "strikethrough":
|
||||||
|
return types.MessageEntityStrike(**kwargs)
|
||||||
|
if entity.type == "code":
|
||||||
|
return types.MessageEntityCode(**kwargs)
|
||||||
|
if entity.type == "pre":
|
||||||
|
return types.MessageEntityPre(language=entity.language or "", **kwargs)
|
||||||
|
if entity.type == "text_link":
|
||||||
|
return types.MessageEntityTextUrl(url=entity.url or "", **kwargs)
|
||||||
|
if entity.type == "spoiler":
|
||||||
|
return types.MessageEntitySpoiler(**kwargs)
|
||||||
|
if entity.type == "blockquote" and hasattr(types, "MessageEntityBlockquote"):
|
||||||
|
try:
|
||||||
|
return types.MessageEntityBlockquote(collapsed=False, **kwargs)
|
||||||
|
except TypeError:
|
||||||
|
return types.MessageEntityBlockquote(**kwargs)
|
||||||
|
if entity.type == "custom_emoji" and entity.custom_emoji_id:
|
||||||
|
return types.MessageEntityCustomEmoji(document_id=int(entity.custom_emoji_id), **kwargs)
|
||||||
|
return None
|
||||||
@@ -29,6 +29,19 @@ def test_build_channel_text_includes_phrase() -> None:
|
|||||||
assert "готов к игре" in text
|
assert "готов к игре" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_channel_text_can_prefix_hidden_link() -> None:
|
||||||
|
config = {
|
||||||
|
"template_text": "header\n\n{{actors}}",
|
||||||
|
"hidden_link_url": "https://example.com/image.png",
|
||||||
|
"hidden_link_char": "​",
|
||||||
|
"actors": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
text = build_channel_text(config, {"actors": {}}, include_hidden_link=True)
|
||||||
|
|
||||||
|
assert text.startswith('<a href="https://example.com/image.png">​</a>')
|
||||||
|
|
||||||
|
|
||||||
def test_build_channel_text_supports_per_actor_placeholders() -> None:
|
def test_build_channel_text_supports_per_actor_placeholders() -> None:
|
||||||
config = {
|
config = {
|
||||||
"template_text": "HEAD\n\n{{actor:astat}}\n\nMID\n\n{{actor:mari}}\n\nTAIL",
|
"template_text": "HEAD\n\n{{actor:astat}}\n\nMID\n\n{{actor:mari}}\n\nTAIL",
|
||||||
|
|||||||
Reference in New Issue
Block a user