post message
Some checks failed
CI / Lint (ruff + mypy) (push) Failing after 32s
CI / Run tests (push) Has been skipped
CI / Docker build test (push) Successful in 10s

This commit is contained in:
2026-04-02 20:59:04 +07:00
parent c286039df7
commit d8231c13a4
5 changed files with 266 additions and 241 deletions

View File

@@ -28,26 +28,28 @@ STATUS_CHOICES = {
}
HELP_TEXT = (
"<b>Команды</b>\n"
"Команды\n"
"/start или /panel - открыть панель\n"
"/help - показать эту справку\n"
"/refresh - принудительно обновить сообщение канала\n"
"/cancel - сбросить текущий ввод и вернуться к панели\n\n"
"<b>Как пользоваться</b>\n"
"/help - показать справку\n"
"/refresh - перерисовать пост в канале\n"
"/cancel - сбросить текущий ввод\n"
"/post - сохранить шаблон поста с плейсхолдером {{actors}}\n\n"
"Как пользоваться\n"
"1. Откройте панель.\n"
"2. Выберите своего актера.\n"
"3. Выберите статус.\n"
"4. Выберите вариант фразы кнопкой: шаблон, без фразы или своя.\n"
"5. Если выбрана своя фраза, отправьте текст одним сообщением.\n\n"
"<b>Настройка</b>\n"
"ENV: BOT_TOKEN, CHANNEL_ID, CHANNEL_MESSAGE_ID, ADMIN_IDS.\n"
"Актеры и шаблоны лежат в config/actors.json.\n"
"Текущие статусы сохраняются в data/state.json."
"3. Нажмите один из статусов. Он применится сразу с шаблонной фразой.\n"
"4. Если нужен свой текст, нажмите 'Своя фраза' и отправьте его.\n\n"
"Шаблон поста\n"
"В шаблоне должен быть {{actors}} - туда бот подставляет актерский блок.\n"
"Можно добавить {{hidden_link}} в начало, либо указать hidden_link_url в config/actors.json.\n"
"Шаблон лучше отправлять обычным текстом или реплаем на текстовый пост.\n"
"Точное восстановление исходного MarkdownV2 из пересланного оформленного поста Telegram не гарантируется."
)
class SessionForm(StatesGroup):
waiting_for_custom_phrase = State()
waiting_for_post_template = State()
def actor_operator_ids(actor: dict[str, Any]) -> set[int]:
@@ -81,34 +83,12 @@ 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.adjust(2)
return keyboard
def build_phrase_keyboard(actor_key: str, status_key: str, default_phrase: str) -> InlineKeyboardBuilder:
keyboard = InlineKeyboardBuilder()
if default_phrase:
keyboard.button(
text="Шаблон",
callback_data=f"apply:template:{actor_key}:{status_key}",
)
keyboard.button(
text="Без фразы",
callback_data=f"apply:empty:{actor_key}:{status_key}",
)
keyboard.button(
text="Своя фраза",
callback_data=f"custom:{actor_key}:{status_key}",
)
keyboard.button(
text="⬅️ Назад",
callback_data=f"nav:actor:{actor_key}",
)
keyboard.adjust(2)
return keyboard
def build_back_to_panel_keyboard() -> InlineKeyboardBuilder:
keyboard = InlineKeyboardBuilder()
keyboard.button(text="К панели", callback_data="nav:panel")
@@ -119,12 +99,23 @@ def find_actor(app_config: dict, actor_key: str) -> dict[str, Any] | None:
return next((item for item in app_config["actors"] if item["key"] == actor_key), None)
def get_actor_runtime_state(actor: dict[str, Any], state_storage: JsonStateStorage) -> dict[str, Any]:
payload = state_storage.load()
return payload.get("actors", {}).get(actor["key"], {})
def extract_template_text(message: Message) -> str | None:
source = message.reply_to_message or message
if source.text:
return source.md_text
return None
async def safe_edit_message(callback: CallbackQuery, text: str, reply_markup=None) -> None:
try:
await callback.message.edit_text(
text,
reply_markup=reply_markup,
parse_mode=ParseMode.HTML,
disable_web_page_preview=True,
)
except TelegramBadRequest as exc:
@@ -144,13 +135,23 @@ async def show_panel(target: Message | CallbackQuery, user_id: int, app_config:
await target.answer(text, reply_markup=keyboard.as_markup())
async def show_actor_status_menu(callback: CallbackQuery, actor: dict[str, Any]) -> None:
keyboard = build_status_keyboard(actor["key"])
await safe_edit_message(
callback,
f"Выбран <b>{actor['display_name']}</b>.\nКакой статус поставить?",
keyboard.as_markup(),
)
async def show_actor_status_menu(
callback: CallbackQuery,
actor: dict[str, Any],
state_storage: JsonStateStorage,
) -> None:
runtime_state = get_actor_runtime_state(actor, state_storage)
current_status = runtime_state.get("status", actor.get("default_status", "backstage"))
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)}.",
]
if 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:
@@ -160,11 +161,38 @@ async def update_channel_post(bot: Bot, app_config: dict, state_storage: JsonSta
chat_id=settings.channel_id,
message_id=settings.channel_message_id,
text=text,
parse_mode=ParseMode.HTML,
disable_web_page_preview=True,
parse_mode=ParseMode.MARKDOWN_V2,
disable_web_page_preview=False,
)
async def apply_status_update(
bot: Bot,
actor_key: str,
status_key: str,
phrase: str,
user_id: int,
app_config: dict,
state_storage: JsonStateStorage,
settings,
) -> None:
payload = state_storage.load()
payload.setdefault("actors", {})
payload["actors"][actor_key] = {
"status": status_key,
"phrase": phrase,
"updated_by": user_id,
}
state_storage.save(payload)
await update_channel_post(bot, app_config, state_storage, settings)
def save_post_template(state_storage: JsonStateStorage, template: str) -> None:
payload = state_storage.load()
payload["template"] = {"text": template}
state_storage.save(payload)
@router.message(CommandStart())
@router.message(Command("panel"))
async def start_handler(message: Message, state: FSMContext, app_config: dict, actor_lookup: dict, settings) -> None:
@@ -180,7 +208,7 @@ async def start_handler(message: Message, state: FSMContext, app_config: dict, a
@router.message(Command("help"))
async def help_handler(message: Message) -> None:
await message.answer(HELP_TEXT, parse_mode=ParseMode.HTML, disable_web_page_preview=True)
await message.answer(HELP_TEXT, disable_web_page_preview=True)
@router.message(Command("cancel"))
@@ -211,6 +239,37 @@ async def refresh_handler(
await message.answer("Сообщение канала обновлено.")
@router.message(Command("post"))
async def post_handler(
message: Message,
state: FSMContext,
state_storage: JsonStateStorage,
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("У вас нет доступа к шаблонам поста.")
return
template = extract_template_text(message)
if template is not None:
save_post_template(state_storage, template)
await state.clear()
if "{{actors}}" not in template:
await message.answer("Шаблон сохранен, но в нем нет {{actors}}.")
return
await message.answer("Шаблон поста сохранен.")
return
await state.set_state(SessionForm.waiting_for_post_template)
await message.answer(
"Перешлите или отправьте текст шаблона одним сообщением.\n"
"Внутри должен быть {{actors}}.\n"
"Для скрытой ссылки можно использовать {{hidden_link}}."
)
@router.callback_query(F.data == "nav:panel")
async def panel_callback(callback: CallbackQuery, state: FSMContext, app_config: dict, actor_lookup: dict, settings) -> None:
await state.clear()
@@ -222,7 +281,14 @@ async def panel_callback(callback: CallbackQuery, state: FSMContext, app_config:
@router.callback_query(F.data.startswith("actor:"))
async def actor_handler(callback: CallbackQuery, state: FSMContext, settings, actor_lookup: dict, app_config: dict) -> None:
async def actor_handler(
callback: CallbackQuery,
state: FSMContext,
settings,
actor_lookup: dict,
app_config: dict,
state_storage: JsonStateStorage,
) -> None:
await state.clear()
user_id = callback.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
@@ -239,35 +305,18 @@ async def actor_handler(callback: CallbackQuery, state: FSMContext, settings, ac
await callback.answer("Можно менять только свой статус.", show_alert=True)
return
await show_actor_status_menu(callback, actor)
await callback.answer()
@router.callback_query(F.data.startswith("nav:actor:"))
async def actor_back_handler(callback: CallbackQuery, state: FSMContext, settings, actor_lookup: dict, app_config: dict) -> None:
await state.clear()
actor_key = callback.data.split(":", maxsplit=2)[2]
user_id = callback.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await callback.answer("Нет доступа.", show_alert=True)
return
actor = find_actor(app_config, actor_key)
if actor is None:
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)
return
await show_actor_status_menu(callback, actor)
await show_actor_status_menu(callback, actor, state_storage)
await callback.answer()
@router.callback_query(F.data.startswith("status:"))
async def status_handler(
callback: CallbackQuery,
bot: Bot,
settings,
actor_lookup: dict,
app_config: dict,
state_storage: JsonStateStorage,
) -> None:
user_id = callback.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
@@ -284,80 +333,12 @@ async def status_handler(
await callback.answer("Можно менять только свой статус.", show_alert=True)
return
default_phrase = actor.get("phrases", {}).get(status_key, "")
prompt_parts = [
f"Статус для <b>{actor['display_name']}</b>: <b>{STATUS_CHOICES[status_key]}</b>.",
"Выберите, как оформить подпись.",
]
if default_phrase:
prompt_parts.append(f"Шаблон: <code>{default_phrase}</code>")
else:
prompt_parts.append("Шаблон не задан.")
await safe_edit_message(
callback,
"\n".join(prompt_parts),
build_phrase_keyboard(actor_key, status_key, default_phrase).as_markup(),
)
await callback.answer()
async def apply_status_update(
bot: Bot,
actor_key: str,
status_key: str,
phrase: str,
user_id: int,
app_config: dict,
state_storage: JsonStateStorage,
settings,
) -> None:
payload = state_storage.load()
payload.setdefault("actors", {})
payload["actors"][actor_key] = {
"status": status_key,
"phrase": phrase,
"updated_by": user_id,
}
state_storage.save(payload)
await update_channel_post(bot, app_config, state_storage, settings)
@router.callback_query(F.data.startswith("apply:"))
async def apply_phrase_handler(
callback: CallbackQuery,
state: FSMContext,
bot: Bot,
app_config: dict,
state_storage: JsonStateStorage,
settings,
actor_lookup: dict,
) -> None:
user_id = callback.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await callback.answer("Нет доступа.", show_alert=True)
return
_, mode, actor_key, status_key = callback.data.split(":")
actor = find_actor(app_config, actor_key)
if actor is None:
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)
return
phrase = ""
if mode == "template":
phrase = actor.get("phrases", {}).get(status_key, "")
try:
await apply_status_update(
bot=bot,
actor_key=actor_key,
status_key=status_key,
phrase=phrase,
phrase=actor.get("phrases", {}).get(status_key, ""),
user_id=user_id,
app_config=app_config,
state_storage=state_storage,
@@ -368,16 +349,14 @@ async def apply_phrase_handler(
await callback.answer("Не удалось обновить сообщение канала.", show_alert=True)
await safe_edit_message(
callback,
f"Статус сохранен, но сообщение канала не обновилось:\n<code>{exc}</code>",
f"Статус сохранен, но сообщение канала не обновилось:\n{exc}",
build_back_to_panel_keyboard().as_markup(),
)
await state.clear()
return
await state.clear()
await safe_edit_message(
callback,
f"Обновлено: <b>{actor['display_name']}</b> -> <b>{STATUS_CHOICES[status_key].lower()}</b>.",
f"Готово.\n{actor['display_name']} -> {STATUS_CHOICES[status_key]}",
build_back_to_panel_keyboard().as_markup(),
)
await callback.answer("Готово")
@@ -390,13 +369,14 @@ async def custom_phrase_handler(
settings,
actor_lookup: dict,
app_config: dict,
state_storage: JsonStateStorage,
) -> None:
user_id = callback.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await callback.answer("Нет доступа.", show_alert=True)
return
_, actor_key, status_key = callback.data.split(":")
_, actor_key = callback.data.split(":")
actor = find_actor(app_config, actor_key)
if actor is None:
await callback.answer("Персонаж не найден.", show_alert=True)
@@ -406,16 +386,19 @@ async def custom_phrase_handler(
await callback.answer("Можно менять только свой статус.", show_alert=True)
return
runtime_state = get_actor_runtime_state(actor, state_storage)
status_key = runtime_state.get("status", actor.get("default_status", "backstage"))
await state.set_state(SessionForm.waiting_for_custom_phrase)
await state.update_data(actor_key=actor_key, status_key=status_key)
await safe_edit_message(
callback,
(
f"Введите свою фразу для <b>{actor['display_name']}</b>.\n"
"Она будет добавлена под строкой статуса.\n"
"Если передумали, нажмите кнопку ниже."
f"Введите свою фразу для {actor['display_name']}.\n"
f"Текущий статус останется: {STATUS_CHOICES.get(status_key, status_key)}.\n"
"Если хотите сначала сменить статус, вернитесь назад."
),
build_phrase_keyboard(actor_key, status_key, actor.get("phrases", {}).get(status_key, "")).as_markup(),
build_status_keyboard(actor_key).as_markup(),
)
await callback.answer()
@@ -460,21 +443,38 @@ async def phrase_handler(
)
except TelegramBadRequest as exc:
logging.exception("Failed to edit channel message")
await message.answer(
f"Статус сохранен, но сообщение канала не обновилось:\n<code>{exc}</code>",
parse_mode=ParseMode.HTML,
)
await message.answer(f"Статус сохранен, но сообщение канала не обновилось:\n{exc}")
await state.clear()
return
await state.clear()
await message.answer(
f"Обновлено: <b>{actor['display_name']}</b> -> <b>{STATUS_CHOICES[status_key].lower()}</b>.",
parse_mode=ParseMode.HTML,
f"Обновлено: {actor['display_name']} -> {STATUS_CHOICES[status_key].lower()}.",
reply_markup=build_back_to_panel_keyboard().as_markup(),
)
@router.message(SessionForm.waiting_for_post_template)
async def post_template_handler(
message: Message,
state: FSMContext,
state_storage: JsonStateStorage,
) -> None:
template = extract_template_text(message)
if template is None:
await message.answer("Нужен текстовый шаблон. Перешлите текстовый пост или отправьте текст.")
return
save_post_template(state_storage, template)
await state.clear()
if "{{actors}}" not in template:
await message.answer("Шаблон сохранен, но в нем нет {{actors}}.")
return
await message.answer("Шаблон поста сохранен.")
def build_dispatcher(app_config: dict, settings, state_storage: JsonStateStorage) -> Dispatcher:
dispatcher = Dispatcher(storage=MemoryStorage())
dispatcher["app_config"] = app_config