21
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 14s

This commit is contained in:
2026-04-02 21:32:26 +07:00
parent e7bd488551
commit 8fb6da84ae
4 changed files with 160 additions and 86 deletions

View File

@@ -34,17 +34,13 @@ HELP_TEXT = (
"/help - показать справку\n"
"/refresh - перерисовать пост в канале\n"
"/cancel - сбросить текущий ввод\n"
"/post - сохранить шаблон поста с плейсхолдером {{actors}}\n\n"
"Как пользоваться\n"
"1. Откройте панель.\n"
"2. Выберите своего актера.\n"
"3. Нажмите один из статусов. Он применится сразу с шаблонной фразой.\n"
"4. Если нужен свой текст, нажмите 'Своя фраза' и отправьте его.\n\n"
"/post - сохранить шаблон поста\n"
"/template_dump - показать HTML из сообщения-реплая\n\n"
"Шаблон поста\n"
"В шаблоне должен быть {{actors}} - туда бот подставляет актерский блок.\n"
"Можно добавить {{hidden_link}} в начало, либо указать hidden_link_url в config/actors.json.\n"
"Шаблон лучше отправлять обычным текстом или реплаем на текстовый пост.\n"
"Точное восстановление исходного MarkdownV2 из пересланного оформленного поста Telegram не гарантируется."
"Можно использовать один общий {{actors}} для всего блока актеров.\n"
"Или точечные плейсхолдеры: {{actor:liebe}}, {{actor:mari}}.\n"
"Можно добавить {{hidden_link}} в начало, либо задать HIDDEN_LINK_URL в .env.\n"
"Если нужна готовая Telegram-разметка, используйте /post ответом на уже оформленное сообщение."
)
@@ -90,6 +86,12 @@ def build_status_keyboard(actor_key: str) -> InlineKeyboardBuilder:
return keyboard
def build_back_to_actor_keyboard(actor_key: str) -> InlineKeyboardBuilder:
keyboard = InlineKeyboardBuilder()
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")
@@ -122,15 +124,34 @@ def normalize_template_placeholders(template: str) -> str:
normalized = re.sub(r"\\\{\\\{\s*actors\s*\\\}\\\}", "{{actors}}", normalized, flags=re.IGNORECASE)
normalized = re.sub(r"\{\{\s*actors\s*\}\}", "{{actors}}", normalized, flags=re.IGNORECASE)
normalized = re.sub(
r"\\\{\\\{\s*hidden_link\s*\\\}\\\}",
"{{hidden_link}}",
r"\\\{\\\{\s*actor\s*:\s*([a-z0-9_\-]+)\s*\\\}\\\}",
lambda match: f"{{{{actor:{match.group(1).lower()}}}}}",
normalized,
flags=re.IGNORECASE,
)
normalized = re.sub(
r"\{\{\s*actor\s*:\s*([a-z0-9_\-]+)\s*\}\}",
lambda match: f"{{{{actor:{match.group(1).lower()}}}}}",
normalized,
flags=re.IGNORECASE,
)
normalized = re.sub(r"\\\{\\\{\s*hidden_link\s*\\\}\\\}", "{{hidden_link}}", normalized, flags=re.IGNORECASE)
normalized = re.sub(r"\{\{\s*hidden_link\s*\}\}", "{{hidden_link}}", normalized, flags=re.IGNORECASE)
return normalized
def validate_template_structure(template: str) -> str | None:
normalized = normalize_template_placeholders(template)
common_count = normalized.count("{{actors}}")
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}}."
if common_count > 1:
return "Шаблон сохранен, но в нем несколько {{actors}}. Нужен только один общий {{actors}}."
return None
async def safe_edit_message(callback: CallbackQuery, text: str, reply_markup=None) -> None:
try:
await callback.message.edit_text(
@@ -155,11 +176,7 @@ 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],
state_storage: JsonStateStorage,
) -> None:
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, ""))
@@ -213,26 +230,20 @@ def save_post_template(state_storage: JsonStateStorage, template: str) -> None:
state_storage.save(payload)
def count_actor_placeholders(template: str) -> int:
return normalize_template_placeholders(template).count("{{actors}}")
@router.message(CommandStart())
@router.message(Command("panel"))
async def start_handler(message: Message, state: FSMContext, app_config: dict, actor_lookup: dict, settings) -> None:
await state.clear()
user_id = message.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await message.answer("У вас нет доступа к этой панели.")
return
await show_panel(message, user_id, app_config, settings)
@router.message(Command("help"))
async def help_handler(message: Message) -> None:
await message.answer(HELP_TEXT, disable_web_page_preview=True)
await message.answer(HELP_TEXT)
@router.message(Command("cancel"))
@@ -263,6 +274,22 @@ async def refresh_handler(
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("У вас нет доступа к шаблонам поста.")
return
if message.reply_to_message is None:
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-представления, которое можно извлечь.")
@router.message(Command("post"))
async def post_handler(
message: Message,
@@ -281,32 +308,26 @@ async def post_handler(
normalized = normalize_template_placeholders(template)
save_post_template(state_storage, normalized)
await state.clear()
placeholder_count = count_actor_placeholders(normalized)
if placeholder_count == 0:
await message.answer(
"Шаблон сохранен, но в нем нет {{actors}}.\n"
"Плейсхолдер должен быть именно {{actors}} в любом регистре."
)
return
if placeholder_count > 1:
await message.answer(
"Шаблон сохранен, но в нем несколько {{actors}}.\n"
"Нужен только один общий {{actors}} на месте всего блока актеров, иначе список будет дублироваться."
)
validation_error = validate_template_structure(normalized)
if validation_error:
await message.answer(validation_error)
return
if message.text and message.text.startswith("/post "):
await message.answer(
"Шаблон сохранен.\n"
"Но если нужна разметка, ссылки и premium emoji, лучше использовать /post ответом на уже оформленное сообщение, а не вставлять шаблон текстом после команды."
"Но если нужна готовая разметка, ссылки и premium emoji, лучше использовать /post ответом на уже оформленное сообщение."
)
return
await message.answer("Шаблон поста сохранен.")
return
await state.set_state(SessionForm.waiting_for_post_template)
await message.answer(
"Перешлите или отправьте текст шаблона одним сообщением.\n"
"Внутри должен быть {{actors}}.\n"
"Перешлите или отправьте шаблон одним сообщением.\n"
"Используйте {{actors}} для общего блока или {{actor:key}} для конкретного актера.\n"
"Для скрытой ссылки можно использовать {{hidden_link}}."
)
@@ -436,10 +457,9 @@ async def custom_phrase_handler(
callback,
(
f"Введите свою фразу для {actor['display_name']}.\n"
f"Текущий статус останется: {STATUS_CHOICES.get(status_key, status_key)}.\n"
"Если хотите сначала сменить статус, вернитесь назад."
f"Текущий статус останется: {STATUS_CHOICES.get(status_key, status_key)}."
),
build_status_keyboard(actor_key).as_markup(),
build_back_to_actor_keyboard(actor_key).as_markup(),
)
await callback.answer()
@@ -510,18 +530,9 @@ async def post_template_handler(
save_post_template(state_storage, normalized)
await state.clear()
placeholder_count = count_actor_placeholders(normalized)
if placeholder_count == 0:
await message.answer(
"Шаблон сохранен, но в нем нет {{actors}}.\n"
"Плейсхолдер должен быть именно {{actors}} в любом регистре."
)
return
if placeholder_count > 1:
await message.answer(
"Шаблон сохранен, но в нем несколько {{actors}}.\n"
"Нужен только один общий {{actors}} на месте всего блока актеров, иначе список будет дублироваться."
)
validation_error = validate_template_structure(normalized)
if validation_error:
await message.answer(validation_error)
return
await message.answer("Шаблон поста сохранен.")
@@ -541,6 +552,10 @@ async def main() -> None:
logging.basicConfig(level=logging.INFO)
settings = load_settings()
app_config = load_actor_config(settings.config_path)
if settings.hidden_link_url:
app_config["hidden_link_url"] = settings.hidden_link_url
if settings.hidden_link_char:
app_config["hidden_link_char"] = settings.hidden_link_char
state_storage = JsonStateStorage(settings.state_path)
bot = Bot(token=settings.bot_token)

View File

@@ -16,6 +16,8 @@ class BotSettings:
config_path: Path
state_path: Path
admin_ids: set[int]
hidden_link_url: str
hidden_link_char: str
def _parse_admin_ids(value: str) -> set[int]:
@@ -30,20 +32,15 @@ def _parse_admin_ids(value: str) -> set[int]:
def load_settings() -> BotSettings:
load_dotenv()
bot_token = os.environ["BOT_TOKEN"]
channel_id = int(os.environ["CHANNEL_ID"])
channel_message_id = int(os.environ["CHANNEL_MESSAGE_ID"])
config_path = Path(os.environ.get("CONFIG_PATH", "config/actors.json"))
state_path = Path(os.environ.get("STATE_PATH", "data/state.json"))
admin_ids = _parse_admin_ids(os.environ.get("ADMIN_IDS", ""))
return BotSettings(
bot_token=bot_token,
channel_id=channel_id,
channel_message_id=channel_message_id,
config_path=config_path,
state_path=state_path,
admin_ids=admin_ids,
bot_token=os.environ["BOT_TOKEN"],
channel_id=int(os.environ["CHANNEL_ID"]),
channel_message_id=int(os.environ["CHANNEL_MESSAGE_ID"]),
config_path=Path(os.environ.get("CONFIG_PATH", "config/actors.json")),
state_path=Path(os.environ.get("STATE_PATH", "data/state.json")),
admin_ids=_parse_admin_ids(os.environ.get("ADMIN_IDS", "")),
hidden_link_url=os.environ.get("HIDDEN_LINK_URL", ""),
hidden_link_char=os.environ.get("HIDDEN_LINK_CHAR", "​"),
)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import re
from html import escape
@@ -10,6 +11,8 @@ DEFAULT_STATUS_LABELS = {
"rest": "антракт",
}
ACTOR_PLACEHOLDER_RE = re.compile(r"\{\{\s*actor\s*:\s*([a-z0-9_\-]+)\s*\}\}", re.IGNORECASE)
def build_hidden_link(config: dict) -> str:
url = config.get("hidden_link_url", "").strip()
@@ -19,12 +22,9 @@ def build_hidden_link(config: dict) -> str:
return f'<a href="{escape(url, quote=True)}">{invisible}</a>'
def build_actor_lines(config: dict, state: dict) -> str:
actor_lines: list[str] = []
def build_actor_line(actor: dict, state: dict, config: dict) -> str:
actor_state = state.get("actors", {})
status_labels = {**DEFAULT_STATUS_LABELS, **config.get("status_labels", {})}
for actor in config["actors"]:
current = actor_state.get(actor["key"], {})
status = current.get("status", actor.get("default_status", "backstage"))
phrase = current.get("phrase", actor.get("phrases", {}).get(status, ""))
@@ -40,12 +40,37 @@ def build_actor_lines(config: dict, state: dict) -> str:
)
if phrase:
line = f"{line}\n {escape(phrase)}"
actor_lines.append(line)
return line
def build_actor_lines(config: dict, state: dict, skip_keys: set[str] | None = None) -> str:
actor_lines: list[str] = []
skip_keys = skip_keys or set()
for actor in config["actors"]:
if actor["key"] in skip_keys:
continue
actor_lines.append(build_actor_line(actor, state, config))
actor_lines.extend(config.get("static_actor_lines_html", []))
return "\n".join(actor_lines)
def replace_actor_placeholders(template: str, config: dict, state: dict) -> tuple[str, set[str]]:
used_keys: set[str] = set()
actors_by_key = {actor["key"].lower(): actor for actor in config["actors"]}
def repl(match: re.Match[str]) -> str:
actor_key = match.group(1).lower()
actor = actors_by_key.get(actor_key)
if actor is None:
return match.group(0)
used_keys.add(actor["key"])
return build_actor_line(actor, state, config)
return ACTOR_PLACEHOLDER_RE.sub(repl, template), used_keys
def build_default_template(config: dict) -> str:
blocks = []
hidden = build_hidden_link(config)
@@ -69,7 +94,8 @@ def build_default_template(config: dict) -> str:
def build_channel_text(config: dict, state: dict) -> str:
template = state.get("template", {}).get("text") or config.get("template_text") or build_default_template(config)
actors_block = build_actor_lines(config, state)
template, used_keys = replace_actor_placeholders(template, config, state)
actors_block = build_actor_lines(config, state, skip_keys=used_keys)
hidden_link = build_hidden_link(config)
text = template.replace("{{actors}}", actors_block)

View File

@@ -28,3 +28,39 @@ def test_build_channel_text_includes_phrase_and_status() -> None:
assert '<a href="https://t.me/example"><b>ASTAT</b></a>' in text
assert "исполняет роль" in text
assert "готов к игре" in text
def test_build_channel_text_supports_per_actor_placeholders() -> None:
config = {
"template_text": "HEAD\n\n{{actor:astat}}\n\nMID\n\n{{actor:mari}}\n\nTAIL",
"actors": [
{
"key": "astat",
"display_name": "ASTAT",
"display_html": "<b>ASTAT</b>",
"link": "https://t.me/astat",
"pronouns": "he/him",
"meta_html": " he/him ",
"emoji": "🌟",
"default_status": "backstage",
"phrases": {"backstage": "в закулисье."},
},
{
"key": "mari",
"display_name": "MARI",
"display_html": "<b>MARI</b>",
"link": "https://t.me/mari",
"pronouns": "she/her",
"meta_html": " she/her ",
"emoji": "🌟",
"default_status": "open",
"phrases": {"open": "исполняет роль."},
},
],
}
state = {"actors": {}}
text = build_channel_text(config, state)
assert text.count("<b>ASTAT</b>") == 1
assert text.count("<b>MARI</b>") == 1