21
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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", "​"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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'<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", {})}
|
||||
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'<a href="{escape(actor["link"], quote=True)}">{display_name}</a>'
|
||||
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'<a href="{escape(actor["link"], quote=True)}">{display_name}</a>'
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user