diff --git a/session_bot/bot.py b/session_bot/bot.py
index efc7d69..d84944c 100644
--- a/session_bot/bot.py
+++ b/session_bot/bot.py
@@ -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)
diff --git a/session_bot/config.py b/session_bot/config.py
index 1202198..a24c65e 100644
--- a/session_bot/config.py
+++ b/session_bot/config.py
@@ -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", ""),
)
diff --git a/session_bot/render.py b/session_bot/render.py
index 33e6790..b357b76 100644
--- a/session_bot/render.py
+++ b/session_bot/render.py
@@ -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,33 +22,55 @@ def build_hidden_link(config: dict) -> str:
return f'{invisible}'
-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", {})}
+ current = actor_state.get(actor["key"], {})
+ status = current.get("status", actor.get("default_status", "backstage"))
+ phrase = current.get("phrase", actor.get("phrases", {}).get(status, ""))
+ label = status_labels.get(status, status)
+ display_name = actor.get("display_html", escape(actor["display_name"]))
+ meta = actor.get("meta_html", escape(actor["pronouns"]))
+ emoji = actor.get("emoji_html", escape(actor.get("emoji", "")))
+
+ line = (
+ f'{emoji} '
+ f'{display_name}'
+ f"{meta}{escape(label)}."
+ )
+ if phrase:
+ line = f"{line}\n {escape(phrase)}"
+ 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"]:
- current = actor_state.get(actor["key"], {})
- status = current.get("status", actor.get("default_status", "backstage"))
- phrase = current.get("phrase", actor.get("phrases", {}).get(status, ""))
- label = status_labels.get(status, status)
- display_name = actor.get("display_html", escape(actor["display_name"]))
- meta = actor.get("meta_html", escape(actor["pronouns"]))
- emoji = actor.get("emoji_html", escape(actor.get("emoji", "")))
-
- line = (
- f'{emoji} '
- f'{display_name}'
- f"{meta}{escape(label)}."
- )
- if phrase:
- line = f"{line}\n {escape(phrase)}"
- actor_lines.append(line)
+ 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)
diff --git a/tests/test_render.py b/tests/test_render.py
index 920cd12..57694bf 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -28,3 +28,39 @@ def test_build_channel_text_includes_phrase_and_status() -> None:
assert 'ASTAT' 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": "ASTAT",
+ "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": "MARI",
+ "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("ASTAT") == 1
+ assert text.count("MARI") == 1