Add Linkki broadcast image
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 39s
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 39s
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from linkki_poster.formatting import (
|
||||
CAPTION_LIMIT,
|
||||
ParsedTemplate,
|
||||
estimate_caption_length,
|
||||
parse_markdown_template,
|
||||
render_template,
|
||||
)
|
||||
from linkki_poster.models import Config, LANGUAGE_DISPLAY_NAMES, Language, LanguageAssets, PostedMessageRef
|
||||
from linkki_poster.progress import ProgressDisplay
|
||||
from linkki_poster.telegram_api import TelegramAPI, TelegramApiError
|
||||
|
||||
|
||||
MESSAGE_LIMIT = 4096
|
||||
_FAKE_MESSAGE_ID = "9" * 20
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TextSegmentState:
|
||||
label: str
|
||||
message_id: int | None
|
||||
mode: Literal["message", "caption"]
|
||||
parsed_template: ParsedTemplate
|
||||
last_rendered: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PostState:
|
||||
posted_ref: PostedMessageRef
|
||||
text_segments: list[_TextSegmentState]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RuntimeChunkPlan:
|
||||
raw_text: str
|
||||
parsed_template: ParsedTemplate
|
||||
label: str
|
||||
has_placeholders: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class _LanguageRuntimePlan:
|
||||
language_assets: LanguageAssets
|
||||
full_template: ParsedTemplate
|
||||
full_rendered_text: str
|
||||
has_text: bool
|
||||
can_use_caption: bool
|
||||
chunks: list[_RuntimeChunkPlan]
|
||||
|
||||
|
||||
def post_assets(
|
||||
api: TelegramAPI,
|
||||
config: Config,
|
||||
global_images: list[Path],
|
||||
ordered_languages: list[LanguageAssets],
|
||||
progress: ProgressDisplay | None = None,
|
||||
) -> list[PostedMessageRef]:
|
||||
reporter = progress or ProgressDisplay()
|
||||
leading_global_images, effective_languages = resolve_global_image_strategy(global_images, ordered_languages)
|
||||
normalized_username = normalize_channel_username(config.channel_username)
|
||||
link_target = resolve_link_target(config.chat_id, normalized_username)
|
||||
runtime_plans = build_runtime_plans(effective_languages, link_target)
|
||||
|
||||
posted_message_ids: list[int] = []
|
||||
post_states: list[_PostState] = []
|
||||
language_links: dict[Language, str] = {}
|
||||
|
||||
try:
|
||||
if leading_global_images:
|
||||
execute_image_post(
|
||||
api=api,
|
||||
chat_id=config.chat_id,
|
||||
image_paths=leading_global_images,
|
||||
label=build_image_label("global", len(leading_global_images)),
|
||||
reporter=reporter,
|
||||
posted_message_ids=posted_message_ids,
|
||||
)
|
||||
|
||||
for runtime_plan in runtime_plans:
|
||||
post_state = execute_language_plan(
|
||||
api=api,
|
||||
config=config,
|
||||
runtime_plan=runtime_plan,
|
||||
reporter=reporter,
|
||||
posted_message_ids=posted_message_ids,
|
||||
language_links=language_links,
|
||||
link_target=link_target,
|
||||
)
|
||||
post_states.append(post_state)
|
||||
language_links[post_state.posted_ref.language] = post_state.posted_ref.message_url
|
||||
|
||||
execute_final_backfill(api, config.chat_id, post_states, language_links, reporter)
|
||||
return [post_state.posted_ref for post_state in post_states]
|
||||
except Exception as error:
|
||||
rollback_posted_messages(api, config.chat_id, posted_message_ids, reporter)
|
||||
raise error
|
||||
|
||||
|
||||
def build_runtime_plans(ordered_languages: list[LanguageAssets], link_target: str) -> list[_LanguageRuntimePlan]:
|
||||
runtime_plans: list[_LanguageRuntimePlan] = []
|
||||
for language_assets in ordered_languages:
|
||||
full_template = parse_markdown_template(language_assets.text_raw)
|
||||
full_rendered_text = render_template(full_template, {})
|
||||
has_text = full_rendered_text.strip() != ""
|
||||
split_chunks = split_markdown_text_for_messages(language_assets.text_raw, link_target) if has_text else []
|
||||
chunk_plans = []
|
||||
for index, chunk_text in enumerate(split_chunks, start=1):
|
||||
parsed_chunk = parse_markdown_template(chunk_text)
|
||||
chunk_plans.append(
|
||||
_RuntimeChunkPlan(
|
||||
raw_text=chunk_text,
|
||||
parsed_template=parsed_chunk,
|
||||
label=build_text_label(language_assets.language, index, len(split_chunks)),
|
||||
has_placeholders=bool(parsed_chunk.placeholders),
|
||||
)
|
||||
)
|
||||
can_use_caption = (
|
||||
bool(language_assets.images)
|
||||
and has_text
|
||||
and len(chunk_plans) == 1
|
||||
and estimate_caption_length(full_template, link_target) <= CAPTION_LIMIT
|
||||
)
|
||||
runtime_plans.append(
|
||||
_LanguageRuntimePlan(
|
||||
language_assets=language_assets,
|
||||
full_template=full_template,
|
||||
full_rendered_text=full_rendered_text,
|
||||
has_text=has_text,
|
||||
can_use_caption=can_use_caption,
|
||||
chunks=chunk_plans,
|
||||
)
|
||||
)
|
||||
return runtime_plans
|
||||
|
||||
|
||||
def execute_language_plan(
|
||||
api: TelegramAPI,
|
||||
config: Config,
|
||||
runtime_plan: _LanguageRuntimePlan,
|
||||
reporter: ProgressDisplay,
|
||||
posted_message_ids: list[int],
|
||||
language_links: dict[Language, str],
|
||||
link_target: str,
|
||||
) -> _PostState:
|
||||
language_assets = runtime_plan.language_assets
|
||||
language_name = LANGUAGE_DISPLAY_NAMES[language_assets.language]
|
||||
text_segments: list[_TextSegmentState] = []
|
||||
text_mode: Literal["message", "caption", "none"] = "none"
|
||||
text_message_id: int | None = None
|
||||
caption_message_id: int | None = None
|
||||
|
||||
if language_assets.images:
|
||||
image_label = build_image_label(language_name, len(language_assets.images))
|
||||
|
||||
if runtime_plan.can_use_caption:
|
||||
only_chunk = runtime_plan.chunks[0]
|
||||
try:
|
||||
message_ids = execute_image_post(
|
||||
api=api,
|
||||
chat_id=config.chat_id,
|
||||
image_paths=language_assets.images,
|
||||
label=image_label,
|
||||
reporter=reporter,
|
||||
posted_message_ids=posted_message_ids,
|
||||
caption=render_template(runtime_plan.full_template, language_links),
|
||||
)
|
||||
caption_message_id = message_ids[0]
|
||||
text_mode = "caption"
|
||||
text_step_index = reporter.add_step(only_chunk.label, status="done", detail="included as caption")
|
||||
text_segments.append(
|
||||
_TextSegmentState(
|
||||
label=only_chunk.label,
|
||||
message_id=caption_message_id,
|
||||
mode="caption",
|
||||
parsed_template=runtime_plan.full_template,
|
||||
last_rendered=render_template(runtime_plan.full_template, language_links),
|
||||
)
|
||||
)
|
||||
except TelegramApiError as error:
|
||||
if not is_caption_too_long_error(error):
|
||||
raise
|
||||
reporter.add_step(image_label, status="failed", detail="caption too long, retrying without caption")
|
||||
message_ids = execute_image_post(
|
||||
api=api,
|
||||
chat_id=config.chat_id,
|
||||
image_paths=language_assets.images,
|
||||
label=image_label,
|
||||
reporter=reporter,
|
||||
posted_message_ids=posted_message_ids,
|
||||
)
|
||||
if runtime_plan.has_text:
|
||||
segments, first_message_id = execute_text_chunks(
|
||||
api=api,
|
||||
chat_id=config.chat_id,
|
||||
chunks=runtime_plan.chunks,
|
||||
reporter=reporter,
|
||||
posted_message_ids=posted_message_ids,
|
||||
language_links=language_links,
|
||||
)
|
||||
text_segments.extend(segments)
|
||||
text_message_id = first_message_id
|
||||
text_mode = "message"
|
||||
caption_message_id = None
|
||||
else:
|
||||
message_ids = execute_image_post(
|
||||
api=api,
|
||||
chat_id=config.chat_id,
|
||||
image_paths=language_assets.images,
|
||||
label=image_label,
|
||||
reporter=reporter,
|
||||
posted_message_ids=posted_message_ids,
|
||||
)
|
||||
if runtime_plan.has_text:
|
||||
segments, first_message_id = execute_text_chunks(
|
||||
api=api,
|
||||
chat_id=config.chat_id,
|
||||
chunks=runtime_plan.chunks,
|
||||
reporter=reporter,
|
||||
posted_message_ids=posted_message_ids,
|
||||
language_links=language_links,
|
||||
)
|
||||
text_segments.extend(segments)
|
||||
text_message_id = first_message_id
|
||||
text_mode = "message"
|
||||
elif runtime_plan.has_text:
|
||||
segments, first_message_id = execute_text_chunks(
|
||||
api=api,
|
||||
chat_id=config.chat_id,
|
||||
chunks=runtime_plan.chunks,
|
||||
reporter=reporter,
|
||||
posted_message_ids=posted_message_ids,
|
||||
language_links=language_links,
|
||||
)
|
||||
text_segments.extend(segments)
|
||||
text_message_id = first_message_id
|
||||
text_mode = "message"
|
||||
else:
|
||||
raise ValueError(f"Language '{language_assets.language.value}' has neither text nor images.")
|
||||
|
||||
primary_message_id = caption_message_id or text_message_id
|
||||
if primary_message_id is None:
|
||||
raise ValueError(f"Language '{language_name}' did not produce a primary message.")
|
||||
|
||||
message_url = build_message_url(link_target, primary_message_id)
|
||||
posted_ref = PostedMessageRef(
|
||||
language=language_assets.language,
|
||||
text_message_id=text_message_id,
|
||||
caption_message_id=caption_message_id,
|
||||
message_url=message_url,
|
||||
had_placeholders=any(bool(segment.parsed_template.placeholders) for segment in text_segments),
|
||||
text_mode=text_mode,
|
||||
)
|
||||
return _PostState(posted_ref=posted_ref, text_segments=text_segments)
|
||||
|
||||
|
||||
def execute_image_post(
|
||||
api: TelegramAPI,
|
||||
chat_id: str,
|
||||
image_paths: list[Path],
|
||||
label: str,
|
||||
reporter: ProgressDisplay,
|
||||
posted_message_ids: list[int],
|
||||
caption: str | None = None,
|
||||
) -> list[int]:
|
||||
step_index = reporter.add_step(label)
|
||||
reporter.update(step_index, "in_progress")
|
||||
response_items = api.send_media_group(
|
||||
chat_id,
|
||||
image_paths,
|
||||
caption=caption,
|
||||
parse_mode="HTML" if caption is not None else None,
|
||||
)
|
||||
message_ids = [int(item["message_id"]) for item in response_items]
|
||||
posted_message_ids.extend(message_ids)
|
||||
detail = "with caption" if caption is not None else ""
|
||||
reporter.update(step_index, "done", detail)
|
||||
return message_ids
|
||||
|
||||
|
||||
def execute_text_chunks(
|
||||
api: TelegramAPI,
|
||||
chat_id: str,
|
||||
chunks: list[_RuntimeChunkPlan],
|
||||
reporter: ProgressDisplay,
|
||||
posted_message_ids: list[int],
|
||||
language_links: dict[Language, str],
|
||||
) -> tuple[list[_TextSegmentState], int]:
|
||||
segments: list[_TextSegmentState] = []
|
||||
first_message_id: int | None = None
|
||||
|
||||
for chunk in chunks:
|
||||
post_step_index = reporter.add_step(chunk.label)
|
||||
reporter.update(post_step_index, "in_progress")
|
||||
rendered_text = render_template(chunk.parsed_template, language_links)
|
||||
detail = ""
|
||||
if len(chunks) > 1:
|
||||
detail = chunk.label.replace("Post ", "").replace(" text part ", " split part ")
|
||||
sent_message = api.send_message(chat_id, rendered_text, parse_mode="HTML")
|
||||
message_id = int(sent_message["message_id"])
|
||||
posted_message_ids.append(message_id)
|
||||
reporter.update(post_step_index, "done", detail)
|
||||
if first_message_id is None:
|
||||
first_message_id = message_id
|
||||
segments.append(
|
||||
_TextSegmentState(
|
||||
label=chunk.label,
|
||||
message_id=message_id,
|
||||
mode="message",
|
||||
parsed_template=chunk.parsed_template,
|
||||
last_rendered=rendered_text,
|
||||
)
|
||||
)
|
||||
|
||||
if first_message_id is None:
|
||||
raise ValueError("Expected at least one text message segment.")
|
||||
return segments, first_message_id
|
||||
|
||||
|
||||
def execute_final_backfill(
|
||||
api: TelegramAPI,
|
||||
chat_id: str,
|
||||
post_states: list[_PostState],
|
||||
language_links: dict[Language, str],
|
||||
reporter: ProgressDisplay,
|
||||
) -> None:
|
||||
for post_state in post_states:
|
||||
for text_segment in post_state.text_segments:
|
||||
if not text_segment.parsed_template.placeholders:
|
||||
continue
|
||||
|
||||
updated_text = render_template(text_segment.parsed_template, language_links)
|
||||
if updated_text == text_segment.last_rendered:
|
||||
continue
|
||||
|
||||
edit_step_index = reporter.add_step(build_edit_label(text_segment.label))
|
||||
reporter.update(edit_step_index, "in_progress")
|
||||
if text_segment.message_id is None:
|
||||
raise ValueError("Missing message id for edit step.")
|
||||
if text_segment.mode == "message":
|
||||
api.edit_message_text(chat_id, text_segment.message_id, updated_text, parse_mode="HTML")
|
||||
else:
|
||||
api.edit_message_caption(chat_id, text_segment.message_id, updated_text, parse_mode="HTML")
|
||||
reporter.update(edit_step_index, "done")
|
||||
text_segment.last_rendered = updated_text
|
||||
|
||||
|
||||
def rollback_posted_messages(
|
||||
api: TelegramAPI,
|
||||
chat_id: str,
|
||||
posted_message_ids: list[int],
|
||||
reporter: ProgressDisplay,
|
||||
) -> None:
|
||||
for message_id in reversed(posted_message_ids):
|
||||
step_index = reporter.add_step(f"Delete message {message_id}")
|
||||
reporter.update(step_index, "in_progress")
|
||||
try:
|
||||
api.delete_message(chat_id, message_id)
|
||||
except TelegramApiError as error:
|
||||
reporter.update(step_index, "failed", error.description)
|
||||
continue
|
||||
reporter.update(step_index, "done")
|
||||
|
||||
|
||||
def normalize_channel_username(channel_username: str) -> str:
|
||||
return channel_username.strip().lstrip("@")
|
||||
|
||||
|
||||
def build_message_url(link_target: str, message_id: int) -> str:
|
||||
return f"https://t.me/{link_target}/{message_id}"
|
||||
|
||||
|
||||
def resolve_global_image_strategy(
|
||||
global_images: list[Path],
|
||||
ordered_languages: list[LanguageAssets],
|
||||
) -> tuple[list[Path], list[LanguageAssets]]:
|
||||
if not global_images:
|
||||
return [], ordered_languages
|
||||
|
||||
if not ordered_languages:
|
||||
return global_images, ordered_languages
|
||||
|
||||
first_language = ordered_languages[0]
|
||||
if first_language.images:
|
||||
return global_images, ordered_languages
|
||||
|
||||
with_attached_globals = LanguageAssets(
|
||||
language=first_language.language,
|
||||
text_file=first_language.text_file,
|
||||
text_raw=first_language.text_raw,
|
||||
images=global_images,
|
||||
)
|
||||
return [], [with_attached_globals, *ordered_languages[1:]]
|
||||
|
||||
|
||||
def is_caption_too_long_error(error: TelegramApiError) -> bool:
|
||||
return "caption is too long" in error.description.lower()
|
||||
|
||||
|
||||
def split_markdown_text_for_messages(
|
||||
markdown_text: str,
|
||||
link_target: str,
|
||||
message_limit: int = MESSAGE_LIMIT,
|
||||
) -> list[str]:
|
||||
if markdown_text == "":
|
||||
return []
|
||||
|
||||
chunks: list[str] = []
|
||||
remaining = markdown_text
|
||||
|
||||
while remaining:
|
||||
fit_index = find_max_fitting_prefix_index(remaining, link_target, message_limit)
|
||||
if fit_index == len(remaining):
|
||||
chunks.append(remaining)
|
||||
break
|
||||
|
||||
split_index = choose_split_index(remaining, fit_index)
|
||||
if split_index <= 0:
|
||||
split_index = fit_index
|
||||
|
||||
chunks.append(remaining[:split_index])
|
||||
remaining = remaining[split_index:]
|
||||
|
||||
return [chunk for chunk in chunks if chunk != ""]
|
||||
|
||||
|
||||
def find_max_fitting_prefix_index(markdown_text: str, link_target: str, message_limit: int) -> int:
|
||||
cached_lengths: dict[int, int] = {}
|
||||
|
||||
def measured_length(end_index: int) -> int:
|
||||
if end_index not in cached_lengths:
|
||||
cached_lengths[end_index] = estimate_rendered_message_length(markdown_text[:end_index], link_target)
|
||||
return cached_lengths[end_index]
|
||||
|
||||
if measured_length(len(markdown_text)) <= message_limit:
|
||||
return len(markdown_text)
|
||||
|
||||
low = 1
|
||||
high = len(markdown_text)
|
||||
best = 0
|
||||
|
||||
while low <= high:
|
||||
middle = (low + high) // 2
|
||||
if measured_length(middle) <= message_limit:
|
||||
best = middle
|
||||
low = middle + 1
|
||||
else:
|
||||
high = middle - 1
|
||||
|
||||
if best == 0:
|
||||
raise ValueError("Unable to split message content into Telegram-compatible chunks.")
|
||||
return best
|
||||
|
||||
|
||||
def choose_split_index(markdown_text: str, max_index: int) -> int:
|
||||
double_newline_match = find_last_break_end(markdown_text, re.compile(r"\n{2,}"), max_index)
|
||||
if double_newline_match is not None:
|
||||
return double_newline_match
|
||||
|
||||
single_newline_index = markdown_text.rfind("\n", 0, max_index)
|
||||
if single_newline_index != -1:
|
||||
return single_newline_index + 1
|
||||
|
||||
whitespace_index = find_last_whitespace_break(markdown_text, max_index)
|
||||
if whitespace_index is not None:
|
||||
return whitespace_index
|
||||
|
||||
return max_index
|
||||
|
||||
|
||||
def find_last_break_end(markdown_text: str, pattern: re.Pattern[str], max_index: int) -> int | None:
|
||||
last_end: int | None = None
|
||||
for match in pattern.finditer(markdown_text):
|
||||
if match.end() > max_index:
|
||||
break
|
||||
last_end = match.end()
|
||||
return last_end
|
||||
|
||||
|
||||
def find_last_whitespace_break(markdown_text: str, max_index: int) -> int | None:
|
||||
for index in range(max_index - 1, -1, -1):
|
||||
if markdown_text[index] in {" ", "\t"}:
|
||||
return index + 1
|
||||
return None
|
||||
|
||||
|
||||
def estimate_rendered_message_length(markdown_text: str, link_target: str) -> int:
|
||||
parsed_template = parse_markdown_template(markdown_text)
|
||||
fake_url = f"https://t.me/{link_target}/{_FAKE_MESSAGE_ID}"
|
||||
fake_links = {
|
||||
Language.FI: fake_url,
|
||||
Language.SV: fake_url,
|
||||
Language.EN: fake_url,
|
||||
}
|
||||
rendered_text = render_template(parsed_template, fake_links)
|
||||
return len(rendered_text)
|
||||
|
||||
|
||||
def resolve_link_target(chat_id: str, normalized_username: str = "") -> str:
|
||||
if normalized_username != "":
|
||||
return normalized_username
|
||||
|
||||
internal_chat_id = extract_internal_chat_id(chat_id)
|
||||
if internal_chat_id is None:
|
||||
raise ValueError(
|
||||
"Cannot generate message links without channel_username or a channel-style chat_id (-100...)."
|
||||
)
|
||||
return f"c/{internal_chat_id}"
|
||||
|
||||
|
||||
def extract_internal_chat_id(chat_id: str) -> str | None:
|
||||
stripped = chat_id.strip()
|
||||
if not stripped.startswith("-100"):
|
||||
return None
|
||||
suffix = stripped[4:]
|
||||
if suffix == "" or not suffix.isdigit():
|
||||
return None
|
||||
return suffix
|
||||
|
||||
|
||||
def build_image_label(target: str, image_count: int) -> str:
|
||||
noun = "image" if image_count == 1 else "images"
|
||||
return f"Post {image_count} {target} {noun}"
|
||||
|
||||
|
||||
def build_text_label(language: Language, chunk_index: int, chunk_count: int) -> str:
|
||||
language_name = LANGUAGE_DISPLAY_NAMES[language]
|
||||
if chunk_count == 1:
|
||||
return f"Post {language_name} text"
|
||||
return f"Post {language_name} text part {chunk_index}/{chunk_count}"
|
||||
|
||||
|
||||
def build_edit_label(text_label: str) -> str:
|
||||
if text_label.startswith("Post "):
|
||||
return "Edit " + text_label[5:]
|
||||
return "Edit " + text_label
|
||||
Reference in New Issue
Block a user