Files
docker-images/linkki-tiedotus/linkki_poster/workflow.py
T
ajp_anton 8ec3cde4f2
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
Add Linkki broadcast image
2026-08-19 20:51:26 +00:00

540 lines
19 KiB
Python

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