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