214
This commit is contained in:
@@ -205,6 +205,7 @@ async def show_actor_status_menu(callback: CallbackQuery, actor: dict[str, Any],
|
|||||||
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) -> None:
|
||||||
state = state_storage.load()
|
state = state_storage.load()
|
||||||
text = build_channel_text(app_config, state)
|
text = build_channel_text(app_config, state)
|
||||||
|
try:
|
||||||
await bot.edit_message_text(
|
await bot.edit_message_text(
|
||||||
chat_id=settings.channel_id,
|
chat_id=settings.channel_id,
|
||||||
message_id=settings.channel_message_id,
|
message_id=settings.channel_message_id,
|
||||||
@@ -212,6 +213,24 @@ async def update_channel_post(bot: Bot, app_config: dict, state_storage: JsonSta
|
|||||||
parse_mode=ParseMode.HTML,
|
parse_mode=ParseMode.HTML,
|
||||||
disable_web_page_preview=False,
|
disable_web_page_preview=False,
|
||||||
)
|
)
|
||||||
|
except TelegramBadRequest as exc:
|
||||||
|
if "Invalid custom emoji identifier specified" not in str(exc):
|
||||||
|
raise
|
||||||
|
|
||||||
|
template = state.get("template", {}).get("text", "")
|
||||||
|
if not template:
|
||||||
|
raise
|
||||||
|
|
||||||
|
state["template"]["text"] = sanitize_template_html(template)
|
||||||
|
state_storage.save(state)
|
||||||
|
fallback_text = build_channel_text(app_config, state)
|
||||||
|
await bot.edit_message_text(
|
||||||
|
chat_id=settings.channel_id,
|
||||||
|
message_id=settings.channel_message_id,
|
||||||
|
text=fallback_text,
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def apply_status_update(
|
async def apply_status_update(
|
||||||
|
|||||||
@@ -4,14 +4,8 @@ import re
|
|||||||
from html import escape
|
from html import escape
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_STATUS_LABELS = {
|
|
||||||
"open": "исполняет роль",
|
|
||||||
"backstage": "в закулисье",
|
|
||||||
"delay": "задержки",
|
|
||||||
"rest": "антракт",
|
|
||||||
}
|
|
||||||
|
|
||||||
ACTOR_PLACEHOLDER_RE = re.compile(r"\{\{\s*actor\s*:\s*([a-z0-9_\-]+)\s*\}\}", re.IGNORECASE)
|
ACTOR_PLACEHOLDER_RE = re.compile(r"\{\{\s*actor\s*:\s*([a-z0-9_\-]+)\s*\}\}", re.IGNORECASE)
|
||||||
|
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:
|
||||||
@@ -22,39 +16,38 @@ def build_hidden_link(config: dict) -> str:
|
|||||||
return f'<a href="{escape(url, quote=True)}">{invisible}</a>'
|
return f'<a href="{escape(url, quote=True)}">{invisible}</a>'
|
||||||
|
|
||||||
|
|
||||||
def build_actor_line(actor: dict, state: dict, config: dict) -> str:
|
def convert_plain_links_to_html(template: str) -> str:
|
||||||
|
def repl(match: re.Match[str]) -> str:
|
||||||
|
label = match.group("label").rstrip()
|
||||||
|
url = match.group("url")
|
||||||
|
if "<a " in label:
|
||||||
|
return match.group(0)
|
||||||
|
return f'<a href="{escape(url, quote=True)}">{escape(label)}</a>'
|
||||||
|
|
||||||
|
return PLAIN_LINK_RE.sub(repl, template)
|
||||||
|
|
||||||
|
|
||||||
|
def build_actor_phrase(actor: dict, state: dict) -> str:
|
||||||
actor_state = state.get("actors", {})
|
actor_state = state.get("actors", {})
|
||||||
status_labels = {**DEFAULT_STATUS_LABELS, **config.get("status_labels", {})}
|
|
||||||
current = actor_state.get(actor["key"], {})
|
current = actor_state.get(actor["key"], {})
|
||||||
status = current.get("status", actor.get("default_status", "backstage"))
|
status = current.get("status", actor.get("default_status", "backstage"))
|
||||||
phrase = current.get("phrase", actor.get("phrases", {}).get(status, ""))
|
return current.get("phrase", actor.get("phrases", {}).get(status, ""))
|
||||||
label = status_labels.get(status, status)
|
|
||||||
|
|
||||||
|
def build_actor_line(actor: dict, state: dict) -> str:
|
||||||
|
phrase = build_actor_phrase(actor, state)
|
||||||
display_name = actor.get("display_html", escape(actor["display_name"]))
|
display_name = actor.get("display_html", escape(actor["display_name"]))
|
||||||
meta = actor.get("meta_html", escape(actor["pronouns"]))
|
meta = actor.get("meta_html", escape(actor["pronouns"]))
|
||||||
emoji = actor.get("emoji_html", escape(actor.get("emoji", "")))
|
emoji = actor.get("emoji_html", escape(actor.get("emoji", "")))
|
||||||
|
return (
|
||||||
line = (
|
|
||||||
f'{emoji} '
|
f'{emoji} '
|
||||||
f'<a href="{escape(actor["link"], quote=True)}">{display_name}</a>'
|
f'<a href="{escape(actor["link"], quote=True)}">{display_name}</a>'
|
||||||
f"{meta}{escape(label)}."
|
f"{meta}{escape(phrase)}"
|
||||||
)
|
)
|
||||||
if phrase:
|
|
||||||
line = f"{line}\n {escape(phrase)}"
|
|
||||||
return line
|
|
||||||
|
|
||||||
|
|
||||||
def build_actor_fragment(actor: dict, state: dict, config: dict) -> str:
|
def build_actor_fragment(actor: dict, state: dict) -> str:
|
||||||
actor_state = state.get("actors", {})
|
return escape(build_actor_phrase(actor, state))
|
||||||
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)
|
|
||||||
|
|
||||||
fragment = f"{escape(label)}."
|
|
||||||
if phrase:
|
|
||||||
fragment = f"{fragment}\n {escape(phrase)}"
|
|
||||||
return fragment
|
|
||||||
|
|
||||||
|
|
||||||
def build_actor_lines(config: dict, state: dict, skip_keys: set[str] | None = None) -> str:
|
def build_actor_lines(config: dict, state: dict, skip_keys: set[str] | None = None) -> str:
|
||||||
@@ -64,7 +57,7 @@ def build_actor_lines(config: dict, state: dict, skip_keys: set[str] | None = No
|
|||||||
for actor in config["actors"]:
|
for actor in config["actors"]:
|
||||||
if actor["key"] in skip_keys:
|
if actor["key"] in skip_keys:
|
||||||
continue
|
continue
|
||||||
actor_lines.append(build_actor_line(actor, state, config))
|
actor_lines.append(build_actor_line(actor, state))
|
||||||
|
|
||||||
actor_lines.extend(config.get("static_actor_lines_html", []))
|
actor_lines.extend(config.get("static_actor_lines_html", []))
|
||||||
return "\n".join(actor_lines)
|
return "\n".join(actor_lines)
|
||||||
@@ -88,19 +81,15 @@ def replace_actor_placeholders(template: str, config: dict, state: dict) -> tupl
|
|||||||
line_text = template[line_start:line_end]
|
line_text = template[line_start:line_end]
|
||||||
|
|
||||||
if line_text.strip().lower() == match.group(0).strip().lower():
|
if line_text.strip().lower() == match.group(0).strip().lower():
|
||||||
return build_actor_line(actor, state, config)
|
return build_actor_line(actor, state)
|
||||||
|
|
||||||
return build_actor_fragment(actor, state, config)
|
return build_actor_fragment(actor, state)
|
||||||
|
|
||||||
return ACTOR_PLACEHOLDER_RE.sub(repl, template), used_keys
|
return ACTOR_PLACEHOLDER_RE.sub(repl, template), used_keys
|
||||||
|
|
||||||
|
|
||||||
def build_default_template(config: dict) -> str:
|
def build_default_template(config: dict) -> str:
|
||||||
blocks = []
|
blocks = []
|
||||||
hidden = build_hidden_link(config)
|
|
||||||
if hidden:
|
|
||||||
blocks.append(hidden)
|
|
||||||
|
|
||||||
for key in ("header_html", "intro_links_html", "projects_block_html", "actors_title_html"):
|
for key in ("header_html", "intro_links_html", "projects_block_html", "actors_title_html"):
|
||||||
value = config.get(key, "").strip()
|
value = config.get(key, "").strip()
|
||||||
if value:
|
if value:
|
||||||
@@ -118,14 +107,14 @@ def build_default_template(config: dict) -> str:
|
|||||||
|
|
||||||
def build_channel_text(config: dict, state: 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)
|
template = state.get("template", {}).get("text") or config.get("template_text") or build_default_template(config)
|
||||||
|
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)
|
||||||
hidden_link = build_hidden_link(config)
|
hidden_link = build_hidden_link(config)
|
||||||
|
|
||||||
text = template.replace("{{actors}}", actors_block)
|
text = template.replace("{{actors}}", actors_block)
|
||||||
text = text.replace("{{hidden_link}}", hidden_link)
|
text = text.replace("{{hidden_link}}", "")
|
||||||
|
if hidden_link:
|
||||||
if "{{hidden_link}}" not in template and hidden_link:
|
text = f"{hidden_link}{text}"
|
||||||
text = f"{hidden_link}\n{text}"
|
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from session_bot.render import build_channel_text
|
from session_bot.render import build_channel_text
|
||||||
|
|
||||||
|
|
||||||
def test_build_channel_text_includes_phrase_and_status() -> None:
|
def test_build_channel_text_includes_phrase() -> None:
|
||||||
config = {
|
config = {
|
||||||
"template_text": "{{hidden_link}}\nheader\n\n{{actors}}",
|
"template_text": "{{hidden_link}}\nheader\n\n{{actors}}",
|
||||||
"hidden_link_url": "https://example.com/image.png",
|
"hidden_link_url": "https://example.com/image.png",
|
||||||
@@ -18,15 +18,13 @@ def test_build_channel_text_includes_phrase_and_status() -> None:
|
|||||||
"phrases": {"open": "принимает тейки"},
|
"phrases": {"open": "принимает тейки"},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"status_labels": {"open": "исполняет роль", "backstage": "в закулисье"},
|
|
||||||
}
|
}
|
||||||
state = {"actors": {"astat": {"status": "open", "phrase": "готов к игре"}}}
|
state = {"actors": {"astat": {"status": "open", "phrase": "готов к игре"}}}
|
||||||
|
|
||||||
text = build_channel_text(config, state)
|
text = build_channel_text(config, state)
|
||||||
|
|
||||||
assert '<a href="https://example.com/image.png">' in text
|
assert text.startswith('<a href="https://example.com/image.png">')
|
||||||
assert '<a href="https://t.me/example"><b>ASTAT</b></a>' in text
|
assert '<a href="https://t.me/example"><b>ASTAT</b></a>' in text
|
||||||
assert "исполняет роль" in text
|
|
||||||
assert "готов к игре" in text
|
assert "готов к игре" in text
|
||||||
|
|
||||||
|
|
||||||
@@ -58,9 +56,8 @@ def test_build_channel_text_supports_per_actor_placeholders() -> None:
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
state = {"actors": {}}
|
|
||||||
|
|
||||||
text = build_channel_text(config, state)
|
text = build_channel_text(config, {"actors": {}})
|
||||||
|
|
||||||
assert text.count("<b>ASTAT</b>") == 1
|
assert text.count("<b>ASTAT</b>") == 1
|
||||||
assert text.count("<b>MARI</b>") == 1
|
assert text.count("<b>MARI</b>") == 1
|
||||||
@@ -86,10 +83,31 @@ def test_build_channel_text_inlines_actor_fragment_without_duplication() -> None
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
state = {"actors": {}}
|
|
||||||
|
|
||||||
text = build_channel_text(config, state)
|
text = build_channel_text(config, {"actors": {}})
|
||||||
|
|
||||||
assert text.count("<b>LIEBE</b>") == 1
|
assert text.count("<b>LIEBE</b>") == 1
|
||||||
assert "she/her в закулисье." in text
|
assert "she/her в закулисье." in text
|
||||||
assert "в закулисье." in text
|
|
||||||
|
|
||||||
|
def test_plain_links_are_converted_to_html() -> None:
|
||||||
|
config = {
|
||||||
|
"template_text": "rules (https://example.com)\n\n{{actors}}",
|
||||||
|
"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": "в закулисье."},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
text = build_channel_text(config, {"actors": {}})
|
||||||
|
|
||||||
|
assert '<a href="https://example.com">rules</a>' in text
|
||||||
|
|||||||
Reference in New Issue
Block a user