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

This commit is contained in:
ajp_anton
2026-08-19 20:51:26 +00:00
parent 7e6dea8b24
commit 8ec3cde4f2
56 changed files with 13441 additions and 0 deletions
@@ -0,0 +1 @@
"""Telegram broadcast posting helpers."""
@@ -0,0 +1,979 @@
import time
from dataclasses import dataclass
from pathlib import Path
from linkki_poster.formatting import CAPTION_LIMIT, ParsedTemplate, parse_markdown_template, render_template
from linkki_poster.models import (
Config,
Language,
LanguageAssets,
LanguageBinding,
LANGUAGE_DISPLAY_NAMES,
PublishBinding,
ScanResult,
TextSegmentBinding,
)
from linkki_poster.progress import ProgressDisplay
from linkki_poster.scanner import scan_directory
from linkki_poster.telegram_api import (
TelegramAPI,
TelegramApiError,
is_message_missing_error,
is_message_not_modified_error,
)
from linkki_poster.workflow import (
build_edit_label,
build_image_label,
build_message_url,
build_text_label,
is_caption_too_long_error,
normalize_channel_username,
resolve_global_image_strategy,
resolve_link_target,
split_markdown_text_for_messages,
)
@dataclass(frozen=True)
class PublishResult:
binding: PublishBinding
message_urls: dict[Language, str]
@dataclass(frozen=True)
class DeleteBindingResult:
warnings: list[str]
failures: list[str]
binding: PublishBinding | None
@dataclass(frozen=True)
class DiscussionResolutionStats:
updates_seen: int
discussion_group_updates: int
automatic_forward_updates: int
linked_channel_forward_updates: int
@dataclass(frozen=True)
class DiscussionPinningResult:
binding: PublishBinding
pinning_failed: bool
@dataclass
class _PublishedTextSegment:
label: str
binding: TextSegmentBinding
parsed_template: ParsedTemplate
last_rendered: str
@dataclass(frozen=True)
class _PublishedLanguage:
binding: LanguageBinding
segments: list[_PublishedTextSegment]
def publish_directory(
directory: Path,
config: Config,
ordered_languages: list[Language] | None = None,
use_captions: bool = True,
progress: ProgressDisplay | None = None,
disable_link_previews: dict[Language, bool] | None = None,
) -> PublishResult:
scan_result = scan_directory(directory)
languages = order_scan_languages(scan_result, ordered_languages)
reporter = progress or ProgressDisplay()
api = TelegramAPI(config.bot_token)
normalized_username = normalize_channel_username(config.channel_username)
link_target = resolve_link_target(config.chat_id, normalized_username)
language_links: dict[Language, str] = {}
published_languages: dict[Language, _PublishedLanguage] = {}
global_message_ids: list[int] = []
global_attached_to_language: Language | None = None
posted_message_ids: list[int] = []
leading_global_images, effective_languages = resolve_global_image_strategy(scan_result.global_images, languages)
if scan_result.global_images and not leading_global_images and effective_languages:
global_attached_to_language = effective_languages[0].language
try:
if leading_global_images:
global_message_ids = post_images(
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 language_assets in effective_languages:
published_language = publish_language(
api=api,
chat_id=config.chat_id,
language_assets=language_assets,
language_links=language_links,
disable_link_preview=bool((disable_link_previews or {}).get(language_assets.language, False)),
link_target=link_target,
use_captions=use_captions,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
published_languages[language_assets.language] = published_language
language_links[language_assets.language] = published_language.binding.primary_message_url
backfill_links(
api=api,
chat_id=config.chat_id,
published_segments={language: item.segments for language, item in published_languages.items()},
language_links=language_links,
disable_link_previews=disable_link_previews or {},
reporter=reporter,
)
return PublishResult(
binding=PublishBinding(
global_message_ids=global_message_ids,
global_attached_to_language=global_attached_to_language,
languages={language: item.binding for language, item in published_languages.items()},
discussion_message_ids={},
global_primary_message_id=global_message_ids[0] if global_message_ids and global_attached_to_language is None else None,
),
message_urls=language_links,
)
except Exception:
rollback_messages(api, config.chat_id, posted_message_ids, reporter)
raise
def update_texts(
directory: Path,
config: Config,
binding: PublishBinding,
changed_languages: set[Language],
progress: ProgressDisplay | None = None,
disable_link_previews: dict[Language, bool] | None = None,
) -> PublishBinding:
reporter = progress or ProgressDisplay()
api = TelegramAPI(config.bot_token)
scan_result = scan_directory(directory)
scan_languages = {language_assets.language: language_assets for language_assets in scan_result.languages}
language_links = {language: language_binding.primary_message_url for language, language_binding in binding.languages.items()}
normalized_username = normalize_channel_username(config.channel_username)
link_target = resolve_link_target(config.chat_id, normalized_username)
updated_languages: dict[Language, LanguageBinding] = dict(binding.languages)
for language in changed_languages:
language_assets = scan_languages.get(language)
existing_binding = binding.languages.get(language)
if language_assets is None or existing_binding is None:
continue
chunk_texts = split_markdown_text_for_messages(language_assets.text_raw, link_target)
if len(chunk_texts) != len(existing_binding.text_segments):
raise ValueError(
f"Updated {LANGUAGE_DISPLAY_NAMES[language]} text changed message count. Delete or unlink and publish again."
)
for index, (chunk_text, existing_segment) in enumerate(zip(chunk_texts, existing_binding.text_segments), start=1):
parsed_template = parse_markdown_template(chunk_text)
rendered_text = render_template(parsed_template, language_links)
step_index = reporter.add_step(build_edit_label(build_text_label(language, index, len(chunk_texts))))
reporter.update(step_index, "in_progress")
try:
if existing_segment.mode == "message":
api.edit_message_text(
config.chat_id,
existing_segment.message_id,
rendered_text,
parse_mode="HTML",
disable_link_preview=bool((disable_link_previews or {}).get(language, False)),
)
else:
api.edit_message_caption(config.chat_id, existing_segment.message_id, rendered_text, parse_mode="HTML")
except TelegramApiError as error:
if is_message_missing_error(error):
raise ValueError(
f"Telegram message for {LANGUAGE_DISPLAY_NAMES[language]} no longer exists. "
"Delete from Telegram or unlink this broadcast and publish again."
) from error
if is_message_not_modified_error(error):
reporter.update(step_index, "skipped", "no Telegram-visible change")
continue
raise
reporter.update(step_index, "done")
return PublishBinding(
global_message_ids=binding.global_message_ids,
global_attached_to_language=binding.global_attached_to_language,
languages=updated_languages,
discussion_message_ids=dict(binding.discussion_message_ids),
global_primary_message_id=binding.global_primary_message_id,
global_discussion_message_id=binding.global_discussion_message_id,
)
def delete_binding_from_telegram(
config: Config,
binding: PublishBinding,
progress: ProgressDisplay | None = None,
) -> DeleteBindingResult:
reporter = progress or ProgressDisplay()
api = TelegramAPI(config.bot_token)
failures: list[str] = []
warnings: list[str] = []
deleted_channel_ids: set[int] = set()
deleted_discussion_channel_ids: set[int] = set()
deleted_discussion_message_ids: set[int] = set()
if config.discussion_group_id:
discussion_targets: list[tuple[int | None, int]] = []
if binding.global_discussion_message_id:
discussion_targets.append((binding.global_primary_message_id, binding.global_discussion_message_id))
for language_binding in binding.languages.values():
if language_binding.discussion_message_id:
discussion_targets.append((language_binding.primary_message_id, language_binding.discussion_message_id))
seen_discussion_ids: set[int] = set()
for channel_message_id, discussion_message_id in reversed(discussion_targets):
if discussion_message_id in seen_discussion_ids:
continue
seen_discussion_ids.add(discussion_message_id)
step_index = reporter.add_step(f"Delete discussion message {discussion_message_id}")
reporter.update(step_index, "in_progress")
try:
api.delete_message(config.discussion_group_id, discussion_message_id)
except TelegramApiError as error:
if is_message_missing_error(error):
detail = f"{error.description}; it may already have been deleted manually"
reporter.update(step_index, "skipped", detail)
warnings.append(f"Discussion message {discussion_message_id}: {error.description}")
if channel_message_id:
deleted_discussion_channel_ids.add(channel_message_id)
deleted_discussion_message_ids.add(discussion_message_id)
continue
reporter.update(step_index, "failed", error.description)
failures.append(f"Discussion message {discussion_message_id}: {error.description}")
continue
if channel_message_id:
deleted_discussion_channel_ids.add(channel_message_id)
deleted_discussion_message_ids.add(discussion_message_id)
reporter.update(step_index, "done")
if failures:
return DeleteBindingResult(
warnings=warnings,
failures=failures,
binding=prune_binding_after_deletions(
binding,
deleted_channel_ids=set(),
deleted_discussion_channel_ids=deleted_discussion_channel_ids,
deleted_discussion_message_ids=deleted_discussion_message_ids,
chat_id=config.chat_id,
channel_username=config.channel_username,
),
)
message_ids: list[int] = []
message_ids.extend(binding.global_message_ids)
for language_binding in binding.languages.values():
message_ids.extend(language_binding.image_message_ids)
message_ids.extend(segment.message_id for segment in language_binding.text_segments)
for message_id in reversed(deduplicate_message_ids(message_ids)):
step_index = reporter.add_step(f"Delete message {message_id}")
reporter.update(step_index, "in_progress")
try:
api.delete_message(config.chat_id, message_id)
except TelegramApiError as error:
if is_message_missing_error(error):
detail = f"{error.description}; it may already have been deleted manually"
reporter.update(step_index, "skipped", detail)
warnings.append(f"Message {message_id}: {error.description}")
deleted_channel_ids.add(message_id)
continue
reporter.update(step_index, "failed", error.description)
failures.append(f"Message {message_id}: {error.description}")
continue
deleted_channel_ids.add(message_id)
reporter.update(step_index, "done")
return DeleteBindingResult(
warnings=warnings,
failures=failures,
binding=prune_binding_after_deletions(
binding,
deleted_channel_ids=deleted_channel_ids,
deleted_discussion_channel_ids=deleted_discussion_channel_ids,
deleted_discussion_message_ids=deleted_discussion_message_ids,
chat_id=config.chat_id,
channel_username=config.channel_username,
),
)
def prune_binding_after_deletions(
binding: PublishBinding,
deleted_channel_ids: set[int],
deleted_discussion_channel_ids: set[int],
deleted_discussion_message_ids: set[int],
chat_id: str,
channel_username: str,
) -> PublishBinding | None:
link_target = resolve_link_target(chat_id, normalize_channel_username(channel_username))
global_message_ids = [message_id for message_id in binding.global_message_ids if message_id not in deleted_channel_ids]
languages: dict[Language, LanguageBinding] = {}
for language, language_binding in binding.languages.items():
image_message_ids = [message_id for message_id in language_binding.image_message_ids if message_id not in deleted_channel_ids]
text_segments = [
TextSegmentBinding(message_id=segment.message_id, mode=segment.mode)
for segment in language_binding.text_segments
if segment.message_id not in deleted_channel_ids
]
if not image_message_ids and not text_segments:
continue
primary_message_id = text_segments[0].message_id if text_segments else image_message_ids[0]
languages[language] = LanguageBinding(
language=language,
image_message_ids=image_message_ids,
text_segments=text_segments,
primary_message_id=primary_message_id,
primary_message_url=build_message_url(link_target, primary_message_id),
discussion_message_id=None,
)
discussion_message_ids = {
channel_message_id: discussion_message_id
for channel_message_id, discussion_message_id in binding.discussion_message_ids.items()
if channel_message_id not in deleted_channel_ids and channel_message_id not in deleted_discussion_channel_ids
}
global_primary_message_id = (
binding.global_primary_message_id
if binding.global_primary_message_id and binding.global_primary_message_id not in deleted_channel_ids
else (global_message_ids[0] if global_message_ids and binding.global_attached_to_language is None else None)
)
global_discussion_message_id = binding.global_discussion_message_id
if global_primary_message_id and global_primary_message_id in deleted_discussion_channel_ids:
global_discussion_message_id = None
elif global_primary_message_id and global_primary_message_id in discussion_message_ids:
global_discussion_message_id = discussion_message_ids[global_primary_message_id]
elif not global_primary_message_id:
global_discussion_message_id = None
elif global_discussion_message_id in deleted_discussion_message_ids:
global_discussion_message_id = None
languages = {
language: LanguageBinding(
language=language_binding.language,
image_message_ids=list(language_binding.image_message_ids),
text_segments=list(language_binding.text_segments),
primary_message_id=language_binding.primary_message_id,
primary_message_url=language_binding.primary_message_url,
discussion_message_id=(
None
if language_binding.discussion_message_id in deleted_discussion_message_ids
else (
discussion_message_ids.get(language_binding.primary_message_id)
if language_binding.primary_message_id
else language_binding.discussion_message_id
)
),
)
for language, language_binding in languages.items()
}
if not global_message_ids and not languages:
return None
return PublishBinding(
global_message_ids=global_message_ids,
global_attached_to_language=binding.global_attached_to_language if global_message_ids else None,
languages=languages,
discussion_message_ids=discussion_message_ids,
global_primary_message_id=global_primary_message_id,
global_discussion_message_id=global_discussion_message_id,
)
def snapshot_next_update_offset(config: Config) -> int:
api = TelegramAPI(config.bot_token)
offset: int | None = None
latest_offset = 0
while True:
updates = api.get_updates(offset=offset, limit=100, timeout=0)
if not updates:
return latest_offset
latest_offset = max(int(update["update_id"]) for update in updates) + 1
if len(updates) < 100:
return latest_offset
offset = latest_offset
def apply_discussion_group_pinning(
config: Config,
binding: PublishBinding,
ordered_languages: list[Language],
pinning_mode: str,
delete_service_messages: bool = False,
update_offset: int = 0,
progress: ProgressDisplay | None = None,
) -> DiscussionPinningResult:
if not config.discussion_group_id:
return DiscussionPinningResult(binding=binding, pinning_failed=False)
reporter = progress or ProgressDisplay()
binding, update_offset = ensure_discussion_group_message_ids(
config=config,
binding=binding,
ordered_languages=ordered_languages,
update_offset=update_offset,
progress=progress,
required=True,
)
primary_channel_message_ids = build_primary_channel_message_ids(binding, ordered_languages)
ordered_discussion_ids = [
binding.discussion_message_ids[channel_message_id]
for channel_message_id in primary_channel_message_ids
if channel_message_id in binding.discussion_message_ids
]
if not ordered_discussion_ids:
return DiscussionPinningResult(binding=binding, pinning_failed=False)
api = TelegramAPI(config.bot_token)
keep_ids = select_discussion_keep_ids(ordered_discussion_ids, pinning_mode)
pinning_failed = False
for discussion_message_id in ordered_discussion_ids:
if discussion_message_id in keep_ids:
pin_step = reporter.add_step(f"Pin discussion message {discussion_message_id}")
reporter.update(pin_step, "in_progress")
try:
api.pin_chat_message(config.discussion_group_id, discussion_message_id, disable_notification=True)
except TelegramApiError as error:
pinning_failed = True
reporter.update(pin_step, "failed", error.description)
continue
if delete_service_messages:
update_offset = delete_pin_service_message(
api=api,
discussion_group_id=config.discussion_group_id,
pinned_message_id=discussion_message_id,
update_offset=update_offset,
reporter=reporter,
)
reporter.update(pin_step, "done")
elif discussion_message_id not in keep_ids:
unpin_step = reporter.add_step(f"Unpin discussion message {discussion_message_id}")
reporter.update(unpin_step, "in_progress")
try:
api.unpin_chat_message(config.discussion_group_id, discussion_message_id)
except TelegramApiError as error:
pinning_failed = True
reporter.update(unpin_step, "failed", error.description)
continue
reporter.update(unpin_step, "done")
return DiscussionPinningResult(
binding=PublishBinding(
global_message_ids=binding.global_message_ids,
global_attached_to_language=binding.global_attached_to_language,
languages=binding.languages,
discussion_message_ids=dict(binding.discussion_message_ids),
global_primary_message_id=binding.global_primary_message_id,
global_discussion_message_id=binding.global_discussion_message_id,
),
pinning_failed=pinning_failed,
)
def select_discussion_keep_ids(ordered_discussion_ids: list[int], pinning_mode: str | None) -> set[int]:
if not ordered_discussion_ids:
return set()
if pinning_mode == "all":
return set(ordered_discussion_ids)
if pinning_mode == "first":
return {ordered_discussion_ids[0]}
if pinning_mode == "last":
return {ordered_discussion_ids[-1]}
return set()
def order_scan_languages(scan_result: ScanResult, ordered_languages: list[Language] | None) -> list[LanguageAssets]:
if ordered_languages is None:
return scan_result.languages
by_language = {language_assets.language: language_assets for language_assets in scan_result.languages}
ordered: list[LanguageAssets] = []
for language in ordered_languages:
match = by_language.get(language)
if match is None:
continue
ordered.append(match)
return ordered
def build_primary_channel_message_ids(binding: PublishBinding, ordered_languages: list[Language]) -> list[int]:
message_ids: list[int] = []
if binding.global_primary_message_id and binding.global_attached_to_language is None:
message_ids.append(binding.global_primary_message_id)
elif binding.global_message_ids and binding.global_attached_to_language is None:
message_ids.append(binding.global_message_ids[0])
for language in ordered_languages:
language_binding = binding.languages.get(language)
if language_binding is None:
continue
if language_binding.primary_message_id:
message_ids.append(language_binding.primary_message_id)
elif language_binding.text_segments:
message_ids.append(language_binding.text_segments[0].message_id)
elif language_binding.image_message_ids:
message_ids.append(language_binding.image_message_ids[0])
return message_ids
def ensure_discussion_group_message_ids(
config: Config,
binding: PublishBinding,
ordered_languages: list[Language],
update_offset: int = 0,
progress: ProgressDisplay | None = None,
required: bool = True,
) -> tuple[PublishBinding, int]:
if not config.discussion_group_id:
return binding, update_offset
reporter = progress or ProgressDisplay()
primary_channel_message_ids = build_primary_channel_message_ids(binding, ordered_languages)
discussion_message_ids = dict(binding.discussion_message_ids)
missing_message_ids = [
message_id for message_id in primary_channel_message_ids if message_id not in discussion_message_ids
]
if not missing_message_ids:
return binding, update_offset
step_index = reporter.add_step("Resolve discussion-group messages")
reporter.update(step_index, "in_progress")
matched_ids, next_offset, stats = collect_discussion_message_ids(
config=config,
channel_message_ids=primary_channel_message_ids,
update_offset=update_offset,
)
discussion_message_ids.update(matched_ids)
ordered_discussion_ids = [
discussion_message_ids[channel_message_id]
for channel_message_id in primary_channel_message_ids
if channel_message_id in discussion_message_ids
]
if not ordered_discussion_ids:
detail = build_discussion_resolution_detail(
stats=stats,
expected_count=len(primary_channel_message_ids),
matched_count=0,
)
reporter.update(step_index, "failed", detail)
if required:
raise ValueError(
"Could not find forwarded discussion-group messages for this announcement. "
f"{detail}"
)
return binding, next_offset
if len(ordered_discussion_ids) < len(primary_channel_message_ids):
reporter.update(
step_index,
"done",
build_discussion_resolution_detail(
stats=stats,
expected_count=len(primary_channel_message_ids),
matched_count=len(ordered_discussion_ids),
),
)
else:
reporter.update(step_index, "done")
updated_languages = {
language: LanguageBinding(
language=language_binding.language,
image_message_ids=list(language_binding.image_message_ids),
text_segments=list(language_binding.text_segments),
primary_message_id=language_binding.primary_message_id,
primary_message_url=language_binding.primary_message_url,
discussion_message_id=(
discussion_message_ids.get(language_binding.primary_message_id)
if language_binding.primary_message_id
else language_binding.discussion_message_id
),
)
for language, language_binding in binding.languages.items()
}
return (
PublishBinding(
global_message_ids=binding.global_message_ids,
global_attached_to_language=binding.global_attached_to_language,
languages=updated_languages,
discussion_message_ids=discussion_message_ids,
global_primary_message_id=binding.global_primary_message_id,
global_discussion_message_id=(
discussion_message_ids.get(binding.global_primary_message_id)
if binding.global_primary_message_id
else binding.global_discussion_message_id
),
),
next_offset,
)
def collect_discussion_message_ids(
config: Config,
channel_message_ids: list[int],
update_offset: int,
timeout_seconds: int = 12,
) -> tuple[dict[int, int], int, DiscussionResolutionStats]:
api = TelegramAPI(config.bot_token)
expected = set(channel_message_ids)
matched: dict[int, int] = {}
offset = update_offset
deadline = time.monotonic() + timeout_seconds
updates_seen = 0
discussion_group_updates = 0
automatic_forward_updates = 0
linked_channel_forward_updates = 0
while time.monotonic() < deadline and len(matched) < len(expected):
updates = api.get_updates(offset=offset, limit=100, timeout=1)
updates_seen += len(updates)
if updates:
offset = max(int(update["update_id"]) for update in updates) + 1
for update in updates:
message = extract_message_from_update(update)
if message is None:
continue
if str((message.get("chat") or {}).get("id", "")).strip() != config.discussion_group_id:
continue
discussion_group_updates += 1
if not bool(message.get("is_automatic_forward", False)):
continue
automatic_forward_updates += 1
origin = message.get("forward_origin")
if not isinstance(origin, dict):
continue
origin_chat = origin.get("chat")
if not isinstance(origin_chat, dict):
continue
if str(origin_chat.get("id", "")).strip() != config.chat_id:
continue
linked_channel_forward_updates += 1
channel_message_id = int(origin.get("message_id", 0) or 0)
if channel_message_id not in expected or channel_message_id in matched:
continue
matched[channel_message_id] = int(message["message_id"])
return (
matched,
offset,
DiscussionResolutionStats(
updates_seen=updates_seen,
discussion_group_updates=discussion_group_updates,
automatic_forward_updates=automatic_forward_updates,
linked_channel_forward_updates=linked_channel_forward_updates,
),
)
def build_discussion_resolution_detail(
stats: DiscussionResolutionStats,
expected_count: int,
matched_count: int,
) -> str:
detail = [f"matched {matched_count}/{expected_count} discussion-group messages"]
if stats.updates_seen == 0:
detail.append("saw no new bot updates")
elif stats.discussion_group_updates == 0:
detail.append(
f"saw {stats.updates_seen} bot updates, but none were from the configured discussion group"
)
elif stats.automatic_forward_updates == 0:
detail.append(
f"saw {stats.discussion_group_updates} discussion-group updates, but none were automatic forwards"
)
elif stats.linked_channel_forward_updates == 0:
detail.append(
f"saw {stats.automatic_forward_updates} automatic forwards in the discussion group, but none came from the linked channel"
)
else:
detail.append(
f"saw {stats.linked_channel_forward_updates} automatic forwards from the linked channel"
)
detail.append(
"check bot message access in the discussion group and make sure no other process is consuming getUpdates"
)
detail.append(
"if discussion-group links were missed, they must be linked manually"
)
return "; ".join(detail)
def delete_pin_service_message(
api: TelegramAPI,
discussion_group_id: str,
pinned_message_id: int,
update_offset: int,
reporter: ProgressDisplay,
timeout_seconds: float = 2.0,
) -> int:
deadline = time.monotonic() + timeout_seconds
offset = update_offset
while time.monotonic() < deadline:
updates = api.get_updates(offset=offset, limit=100, timeout=1)
if updates:
offset = max(int(update["update_id"]) for update in updates) + 1
for update in updates:
message = extract_message_from_update(update)
if message is None:
continue
if str((message.get("chat") or {}).get("id", "")).strip() != discussion_group_id:
continue
pinned_payload = message.get("pinned_message")
if not isinstance(pinned_payload, dict):
continue
if int(pinned_payload.get("message_id", 0) or 0) != pinned_message_id:
continue
service_message_id = int(message.get("message_id", 0) or 0)
if service_message_id <= 0:
return offset
cleanup_step = reporter.add_step(f"Delete pin service message {service_message_id}")
reporter.update(cleanup_step, "in_progress")
try:
api.delete_message(discussion_group_id, service_message_id)
except TelegramApiError as error:
if is_message_missing_error(error):
reporter.update(cleanup_step, "skipped", "already gone")
else:
reporter.update(cleanup_step, "failed", error.description)
else:
reporter.update(cleanup_step, "done")
return offset
return offset
def extract_message_from_update(update: dict) -> dict | None:
for key in ("message", "edited_message", "channel_post", "edited_channel_post"):
payload = update.get(key)
if isinstance(payload, dict):
return payload
return None
def publish_language(
api: TelegramAPI,
chat_id: str,
language_assets: LanguageAssets,
language_links: dict[Language, str],
disable_link_preview: bool,
link_target: str,
use_captions: bool,
reporter: ProgressDisplay,
posted_message_ids: list[int],
) -> _PublishedLanguage:
full_template = parse_markdown_template(language_assets.text_raw)
full_rendered_text = render_template(full_template, language_links)
chunk_texts = split_markdown_text_for_messages(language_assets.text_raw, link_target)
image_message_ids: list[int] = []
published_segments: list[_PublishedTextSegment] = []
if language_assets.images:
can_use_caption = use_captions and len(chunk_texts) == 1 and len(full_rendered_text) <= CAPTION_LIMIT
image_label = build_image_label(LANGUAGE_DISPLAY_NAMES[language_assets.language], len(language_assets.images))
if can_use_caption:
try:
image_message_ids = post_images(
api=api,
chat_id=chat_id,
image_paths=language_assets.images,
label=image_label,
reporter=reporter,
posted_message_ids=posted_message_ids,
caption=full_rendered_text,
)
text_label = build_text_label(language_assets.language, 1, 1)
reporter.add_step(text_label, status="done", detail="included as caption")
published_segments.append(
_PublishedTextSegment(
label=text_label,
binding=TextSegmentBinding(message_id=image_message_ids[0], mode="caption"),
parsed_template=full_template,
last_rendered=full_rendered_text,
)
)
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")
image_message_ids = post_images(
api=api,
chat_id=chat_id,
image_paths=language_assets.images,
label=image_label,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
published_segments = post_text_segments(
api=api,
chat_id=chat_id,
language=language_assets.language,
chunk_texts=chunk_texts,
language_links=language_links,
disable_link_preview=disable_link_preview,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
else:
image_message_ids = post_images(
api=api,
chat_id=chat_id,
image_paths=language_assets.images,
label=image_label,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
published_segments = post_text_segments(
api=api,
chat_id=chat_id,
language=language_assets.language,
chunk_texts=chunk_texts,
language_links=language_links,
disable_link_preview=disable_link_preview,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
else:
published_segments = post_text_segments(
api=api,
chat_id=chat_id,
language=language_assets.language,
chunk_texts=chunk_texts,
language_links=language_links,
disable_link_preview=disable_link_preview,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
if not published_segments:
raise ValueError(f"Language '{language_assets.language.value}' produced no text segments.")
binding = LanguageBinding(
language=language_assets.language,
image_message_ids=image_message_ids,
text_segments=[segment.binding for segment in published_segments],
primary_message_id=published_segments[0].binding.message_id,
primary_message_url=build_message_url(link_target, published_segments[0].binding.message_id),
)
return _PublishedLanguage(binding=binding, segments=published_segments)
def post_images(
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")
result = api.send_media_group(chat_id, image_paths, caption=caption, parse_mode="HTML" if caption else None)
message_ids = [int(item["message_id"]) for item in result]
posted_message_ids.extend(message_ids)
reporter.update(step_index, "done", "with caption" if caption else "")
return message_ids
def post_text_segments(
api: TelegramAPI,
chat_id: str,
language: Language,
chunk_texts: list[str],
language_links: dict[Language, str],
disable_link_preview: bool,
reporter: ProgressDisplay,
posted_message_ids: list[int],
) -> list[_PublishedTextSegment]:
segments: list[_PublishedTextSegment] = []
for index, chunk_text in enumerate(chunk_texts, start=1):
label = build_text_label(language, index, len(chunk_texts))
step_index = reporter.add_step(label)
reporter.update(step_index, "in_progress")
parsed_template = parse_markdown_template(chunk_text)
rendered_text = render_template(parsed_template, language_links)
result = api.send_message(chat_id, rendered_text, parse_mode="HTML", disable_link_preview=disable_link_preview)
message_id = int(result["message_id"])
posted_message_ids.append(message_id)
detail = f"split part {index}/{len(chunk_texts)}" if len(chunk_texts) > 1 else ""
reporter.update(step_index, "done", detail)
segments.append(
_PublishedTextSegment(
label=label,
binding=TextSegmentBinding(message_id=message_id, mode="message"),
parsed_template=parsed_template,
last_rendered=rendered_text,
)
)
return segments
def backfill_links(
api: TelegramAPI,
chat_id: str,
published_segments: dict[Language, list[_PublishedTextSegment]],
language_links: dict[Language, str],
disable_link_previews: dict[Language, bool],
reporter: ProgressDisplay,
) -> None:
for language, segments in published_segments.items():
for segment in segments:
updated_text = render_template(segment.parsed_template, language_links)
if updated_text == segment.last_rendered:
continue
step_index = reporter.add_step(build_edit_label(segment.label))
reporter.update(step_index, "in_progress")
if segment.binding.mode == "message":
api.edit_message_text(
chat_id,
segment.binding.message_id,
updated_text,
parse_mode="HTML",
disable_link_preview=bool(disable_link_previews.get(language, False)),
)
else:
api.edit_message_caption(chat_id, segment.binding.message_id, updated_text, parse_mode="HTML")
reporter.update(step_index, "done")
segment.last_rendered = updated_text
def rollback_messages(api: TelegramAPI, chat_id: str, posted_message_ids: list[int], reporter: ProgressDisplay) -> None:
for message_id in reversed(deduplicate_message_ids(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 deduplicate_message_ids(message_ids: list[int]) -> list[int]:
seen: set[int] = set()
ordered: list[int] = []
for message_id in message_ids:
if message_id in seen:
continue
seen.add(message_id)
ordered.append(message_id)
return ordered
+354
View File
@@ -0,0 +1,354 @@
import argparse
import json
from json import JSONDecodeError
from pathlib import Path
from linkki_poster.formatting import validate_markdown_syntax
from linkki_poster.models import Config, LANGUAGE_DISPLAY_NAMES, Language, LanguageAssets, ScanResult
from linkki_poster.ordering import prompt_language_order
from linkki_poster.progress import ProgressDisplay
from linkki_poster.scanner import scan_directory
from linkki_poster.telegram_api import TelegramAPI
from linkki_poster.workflow import post_assets
LANGUAGE_ORDER_ALIASES: dict[str, Language] = {
"fi": Language.FI,
"finnish": Language.FI,
"suomi": Language.FI,
"sv": Language.SV,
"swedish": Language.SV,
"svenska": Language.SV,
"ruotsi": Language.SV,
"en": Language.EN,
"english": Language.EN,
"engelska": Language.EN,
"englanti": Language.EN,
}
def run_cli(script_dir: Path | None = None, argv: list[str] | None = None, input_fn=input) -> None:
effective_script_dir = script_dir or Path(__file__).resolve().parent.parent
args = parse_args(argv)
target_directory = get_target_directory(
script_dir=effective_script_dir,
raw_directory=args.directory,
non_interactive=args.non_interactive,
input_fn=input_fn,
)
initial_scan_result = scan_directory(target_directory)
ordered_languages = determine_language_order(
initial_scan_result.languages,
args.order,
args.non_interactive,
input_fn,
)
while True:
scan_result = scan_directory(target_directory)
effective_scan_result = ScanResult(
global_images=scan_result.global_images,
languages=reapply_language_order(scan_result.languages, ordered_languages),
)
print_scan_summary(effective_scan_result)
if not effective_scan_result.global_images and not effective_scan_result.languages:
print("Nothing to post.")
return
syntax_issues = collect_syntax_issues(effective_scan_result.languages)
if not syntax_issues:
break
print_syntax_issues(syntax_issues)
if args.allow_invalid_syntax:
break
if args.non_interactive:
raise ValueError("Broken markdown syntax detected.")
action = prompt_syntax_action(input_fn)
if action == "post":
break
config = load_config(
script_dir=effective_script_dir,
non_interactive=args.non_interactive,
input_fn=input_fn,
)
api = TelegramAPI(config.bot_token)
progress = ProgressDisplay()
posted_refs = post_assets(
api=api,
config=config,
global_images=effective_scan_result.global_images,
ordered_languages=effective_scan_result.languages,
progress=progress,
)
if posted_refs:
print("Posted language messages:")
for posted_ref in posted_refs:
print(f"- {LANGUAGE_DISPLAY_NAMES[posted_ref.language]}: {posted_ref.message_url}")
def parse_args(argv: list[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Post a multilingual Telegram broadcast from one asset directory.")
parser.add_argument(
"directory",
nargs="?",
help="Directory to scan. Relative paths are resolved from the script directory.",
)
parser.add_argument(
"--order",
help="Optional language order, for example: fi,sv,en or finnish,english.",
)
parser.add_argument(
"--allow-invalid-syntax",
action="store_true",
help="Post even if supported markdown syntax appears broken.",
)
parser.add_argument(
"--non-interactive",
action="store_true",
help="Never prompt. Fail immediately when required input is missing or invalid.",
)
return parser.parse_args(argv)
def get_target_directory(
script_dir: Path,
raw_directory: str | None,
non_interactive: bool,
input_fn=input,
) -> Path:
if raw_directory is not None:
return resolve_target_directory(script_dir, raw_directory)
if non_interactive:
raise ValueError("Directory argument is required in non-interactive mode.")
return prompt_target_directory(script_dir, input_fn)
def resolve_target_directory(script_dir: Path, raw_value: str) -> Path:
candidate = Path(raw_value).expanduser()
if not candidate.is_absolute():
candidate = (script_dir / candidate).resolve()
if not candidate.is_dir():
raise FileNotFoundError(f"Directory not found: {candidate}")
return candidate
def prompt_target_directory(script_dir: Path, input_fn=input) -> Path:
while True:
raw_value = input_fn("Directory to scan: ").strip()
if raw_value == "":
print("Value cannot be empty.")
continue
try:
return resolve_target_directory(script_dir, raw_value)
except FileNotFoundError as error:
print(error)
def determine_language_order(
languages: list[LanguageAssets],
raw_order: str | None,
non_interactive: bool,
input_fn=input,
) -> list[LanguageAssets]:
if raw_order is not None:
return apply_language_order(languages, raw_order)
if non_interactive:
return languages
return prompt_language_order(languages, input_fn=input_fn)
def reapply_language_order(
current_languages: list[LanguageAssets],
ordered_template: list[LanguageAssets],
) -> list[LanguageAssets]:
current_by_language = {language_assets.language: language_assets for language_assets in current_languages}
ordered: list[LanguageAssets] = []
seen: set[Language] = set()
for language_assets in ordered_template:
current = current_by_language.get(language_assets.language)
if current is None:
continue
ordered.append(current)
seen.add(current.language)
for language_assets in current_languages:
if language_assets.language not in seen:
ordered.append(language_assets)
return ordered
def apply_language_order(languages: list[LanguageAssets], raw_order: str) -> list[LanguageAssets]:
requested_languages = parse_language_order(raw_order)
language_by_code = {language_assets.language: language_assets for language_assets in languages}
seen: set[Language] = set()
ordered: list[LanguageAssets] = []
for language in requested_languages:
if language in seen:
raise ValueError(f"Duplicate language in --order: {language.value}")
seen.add(language)
if language in language_by_code:
ordered.append(language_by_code[language])
remaining = [language_assets for language_assets in languages if language_assets.language not in seen]
return [*ordered, *remaining]
def parse_language_order(raw_order: str) -> list[Language]:
tokens = [token.strip().lower() for token in raw_order.replace(";", ",").split(",") if token.strip()]
if not tokens:
raise ValueError("Language order cannot be empty.")
parsed_languages: list[Language] = []
for token in tokens:
language = LANGUAGE_ORDER_ALIASES.get(token)
if language is None:
raise ValueError(f"Unknown language in --order: {token}")
parsed_languages.append(language)
return parsed_languages
def load_config(script_dir: Path, non_interactive: bool, input_fn=input) -> Config:
config_path = script_dir / "telegram_config.json"
config_data: dict[str, str] = {}
if config_path.exists():
raw_text = config_path.read_text(encoding="utf-8")
try:
loaded = json.loads(raw_text)
except JSONDecodeError as error:
if non_interactive:
raise ValueError(f"Invalid JSON in {config_path}: {error.msg}") from error
print(f"Invalid JSON in {config_path}: {error.msg}")
loaded = {}
if isinstance(loaded, dict):
config_data = {str(key): str(value) for key, value in loaded.items()}
elif non_interactive:
raise FileNotFoundError(f"Missing config file: {config_path}")
bot_token = resolve_config_value(
config_data=config_data,
key="bot_token",
prompt="Bot token: ",
non_interactive=non_interactive,
input_fn=input_fn,
)
chat_id = resolve_config_value(
config_data=config_data,
key="chat_id",
prompt="Chat ID: ",
non_interactive=non_interactive,
input_fn=input_fn,
)
channel_username = resolve_optional_config_value(
config_data=config_data,
key="channel_username",
prompt="Channel username (optional, for public links): ",
non_interactive=non_interactive,
input_fn=input_fn,
)
return Config(bot_token=bot_token, chat_id=chat_id, channel_username=channel_username)
def load_config_strict(script_dir: Path) -> Config:
return load_config(script_dir=script_dir, non_interactive=True)
def resolve_config_value(
config_data: dict[str, str],
key: str,
prompt: str,
non_interactive: bool,
input_fn=input,
) -> str:
existing = config_data.get(key, "").strip()
if existing:
return existing
if non_interactive:
raise ValueError(f"Config field '{key}' is required.")
while True:
entered = input_fn(prompt).strip()
if entered:
return entered
print("Value cannot be empty.")
def resolve_optional_config_value(
config_data: dict[str, str],
key: str,
prompt: str,
non_interactive: bool,
input_fn=input,
) -> str:
existing = config_data.get(key)
if existing is not None:
return existing.strip()
if non_interactive:
return ""
return input_fn(prompt).strip()
def collect_syntax_issues(languages: list[LanguageAssets]) -> dict[Language, list[str]]:
issues_by_language: dict[Language, list[str]] = {}
for language_assets in languages:
issues = validate_markdown_syntax(language_assets.text_raw)
if issues:
issues_by_language[language_assets.language] = [
f"{language_assets.text_file.name}: {issue.message}" for issue in issues
]
return issues_by_language
def print_syntax_issues(issues_by_language: dict[Language, list[str]]) -> None:
print("Potential markdown syntax issues detected:")
for language in (Language.FI, Language.SV, Language.EN):
issues = issues_by_language.get(language)
if not issues:
continue
print(f"- {LANGUAGE_DISPLAY_NAMES[language]}")
for issue in issues:
print(f" - {issue}")
def prompt_syntax_action(input_fn=input) -> str:
while True:
answer = input_fn("Post anyway or retry after fixing files? [p/r]: ").strip().lower()
if answer in {"p", "post"}:
return "post"
if answer in {"r", "retry"}:
return "retry"
print("Enter 'p' to post anyway or 'r' to retry.")
def print_scan_summary(scan_result: ScanResult) -> None:
print("Scan summary:")
if scan_result.global_images:
print("Global images:")
for image_path in scan_result.global_images:
print(f"- {image_path.name}")
else:
print("Global images: none")
if not scan_result.languages:
print("Languages: none (no language text files found)")
return
print("Languages:")
for index, language_assets in enumerate(scan_result.languages, start=1):
print(f"{index}. {LANGUAGE_DISPLAY_NAMES[language_assets.language]}")
print(f" Text: {language_assets.text_file.name}")
if language_assets.images:
print(" Images:")
for image_path in language_assets.images:
print(f" - {image_path.name}")
else:
print(" Images: none")
+271
View File
@@ -0,0 +1,271 @@
import html
import re
from dataclasses import dataclass
from linkki_poster.models import Language, PlaceholderToken
CAPTION_LIMIT = 1024
_FAKE_MESSAGE_ID = "9" * 20
@dataclass(frozen=True)
class ParsedTemplate:
html_template: str
placeholders: list[PlaceholderToken]
markers: list[str]
@dataclass(frozen=True)
class SyntaxIssue:
marker: str
message: str
def parse_markdown_template(raw_text: str) -> ParsedTemplate:
text_with_markers, placeholders, markers = extract_language_placeholders(raw_text)
html_template = markdown_subset_to_html(text_with_markers)
return ParsedTemplate(html_template=html_template, placeholders=placeholders, markers=markers)
def extract_language_placeholders(raw_text: str) -> tuple[str, list[PlaceholderToken], list[str]]:
pattern = re.compile(
r"(?<!\\)\[(?P<label>(?:\\.|[^\]\\])*)\](?<!\\)\((?P<target>fi|sv|en)\)",
re.IGNORECASE,
)
placeholders: list[PlaceholderToken] = []
markers: list[str] = []
chunks: list[str] = []
start_index = 0
for placeholder_index, match in enumerate(pattern.finditer(raw_text)):
marker = f"ZZPHTOKEN{placeholder_index}ZZ"
target_language = Language(match.group("target").lower())
placeholders.append(
PlaceholderToken(
label=match.group("label"),
target_lang=target_language,
source_span=(match.start(), match.end()),
)
)
markers.append(marker)
chunks.append(raw_text[start_index : match.start()])
chunks.append(marker)
start_index = match.end()
chunks.append(raw_text[start_index:])
return "".join(chunks), placeholders, markers
def render_template(parsed_template: ParsedTemplate, language_links: dict[Language, str]) -> str:
rendered = parsed_template.html_template
for marker, placeholder in zip(parsed_template.markers, parsed_template.placeholders):
target_url = language_links.get(placeholder.target_lang)
if target_url is None:
replacement = html.escape(placeholder.label, quote=False)
else:
escaped_url = html.escape(target_url, quote=True)
escaped_label = html.escape(placeholder.label, quote=False)
replacement = f'<a href="{escaped_url}">{escaped_label}</a>'
rendered = rendered.replace(marker, replacement)
return rendered
def render_preview_html(raw_text: str, available_targets: set[Language]) -> str:
parsed_template = parse_markdown_template(raw_text)
fake_links = {language: f"#{language.value}" for language in available_targets}
return render_template(parsed_template, fake_links)
def estimate_caption_length(parsed_template: ParsedTemplate, normalized_channel_username: str) -> int:
fake_url = f"https://t.me/{normalized_channel_username}/{_FAKE_MESSAGE_ID}"
fake_links = {
Language.FI: fake_url,
Language.SV: fake_url,
Language.EN: fake_url,
}
rendered = render_template(parsed_template, fake_links)
return len(rendered)
def markdown_subset_to_html(markdown_text: str) -> str:
text_with_escape_tokens, escape_token_values = _stash_escaped_literals(markdown_text)
text_with_code_tokens, code_token_values = _stash_code_segments(text_with_escape_tokens)
escaped_text = html.escape(text_with_code_tokens, quote=False)
escaped_text = _map_blockquotes(escaped_text)
escaped_text = _map_headings(escaped_text)
escaped_text = _map_lists(escaped_text)
escaped_text = _map_standard_links(escaped_text)
escaped_text = _map_style_pairs(escaped_text, r"\|\|([\s\S]+?)\|\|", "tg-spoiler")
escaped_text = _map_style_pairs(escaped_text, r"\*\*(.+?)\*\*", "b")
escaped_text = _map_style_pairs(escaped_text, r"__(.+?)__", "b")
escaped_text = _map_style_pairs(escaped_text, r"\+\+(.+?)\+\+", "u")
escaped_text = _map_style_pairs(escaped_text, r"~~(.+?)~~", "s")
escaped_text = _map_style_pairs(escaped_text, r"(?<!\*)\*([^*\n]+)\*(?!\*)", "i")
escaped_text = _map_style_pairs(escaped_text, r"(?<!_)_([^_\n]+)_(?!_)", "i")
for token, value in escape_token_values.items():
escaped_text = escaped_text.replace(token, value)
for token, value in code_token_values.items():
escaped_text = escaped_text.replace(token, value)
return escaped_text
def _stash_code_segments(markdown_text: str) -> tuple[str, dict[str, str]]:
token_values: dict[str, str] = {}
def replace_code_block(match: re.Match[str]) -> str:
token = f"ZZCODEBLOCK{len(token_values)}ZZ"
code = html.escape(match.group(1), quote=False)
token_values[token] = f"<pre><code>{code}</code></pre>"
return token
code_block_pattern = re.compile(r"```(?:[^\n`]*)\n?(.*?)```", re.DOTALL)
text = code_block_pattern.sub(replace_code_block, markdown_text)
def replace_inline_code(match: re.Match[str]) -> str:
token = f"ZZINLINECODE{len(token_values)}ZZ"
code = html.escape(match.group(1), quote=False)
token_values[token] = f"<code>{code}</code>"
return token
inline_code_pattern = re.compile(r"`([^`\n]+)`")
text = inline_code_pattern.sub(replace_inline_code, text)
return text, token_values
def _stash_escaped_literals(markdown_text: str) -> tuple[str, dict[str, str]]:
token_values: dict[str, str] = {}
def replace_escape(match: re.Match[str]) -> str:
token = f"ZZESCAPED{len(token_values)}ZZ"
token_values[token] = html.escape(match.group(1), quote=False)
return token
text = re.sub(r"\\([\\`*_+\-~|.\[\]()#>])", replace_escape, markdown_text)
return text, token_values
def _map_headings(text: str) -> str:
mapped_lines: list[str] = []
for line in text.splitlines():
heading_match = re.match(r"^(#{1,6})\s*(.+)$", line)
if heading_match is None:
mapped_lines.append(line)
continue
mapped_lines.append(f"<b>{heading_match.group(2).upper()}</b>")
return "\n".join(mapped_lines)
def _map_lists(text: str) -> str:
mapped_lines: list[str] = []
for line in text.splitlines():
unordered_match = re.match(r"^(?P<indent>[ \t]*)(?P<marker>[-+*])\s+(?P<content>.+)$", line)
if unordered_match is not None:
level = _indent_level(unordered_match.group("indent"))
mapped_lines.append(f"{_list_indent(level)}\u2022 {unordered_match.group('content')}")
continue
ordered_match = re.match(r"^(?P<indent>[ \t]*)(?P<number>\d+)\.\s+(?P<content>.+)$", line)
if ordered_match is not None:
level = _indent_level(ordered_match.group("indent"))
mapped_lines.append(
f"{_list_indent(level)}{ordered_match.group('number')}. {ordered_match.group('content')}"
)
continue
mapped_lines.append(line)
return "\n".join(mapped_lines)
def _map_blockquotes(text: str) -> str:
mapped_lines: list[str] = []
quote_lines: list[str] = []
def flush_quote_lines() -> None:
if not quote_lines:
return
mapped_lines.append(f"<blockquote>{'\n'.join(quote_lines)}</blockquote>")
quote_lines.clear()
for line in text.splitlines():
quote_match = re.match(r"^[ \t]*&gt;\s?(.*)$", line)
if quote_match is None:
flush_quote_lines()
mapped_lines.append(line)
continue
quote_lines.append(quote_match.group(1))
flush_quote_lines()
return "\n".join(mapped_lines)
def _indent_level(indent: str) -> int:
expanded = indent.replace("\t", " ")
return len(expanded) // 2
def _list_indent(level: int) -> str:
return "\u00a0" * (level * 4)
def _map_standard_links(text: str) -> str:
def repl(match: re.Match[str]) -> str:
label = match.group(1)
url = match.group(2)
return f'<a href="{html.escape(url, quote=True)}">{label}</a>'
return re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", repl, text)
def _map_style_pairs(text: str, pattern: str, html_tag: str) -> str:
return re.sub(pattern, rf"<{html_tag}>\1</{html_tag}>", text)
def validate_markdown_syntax(markdown_text: str) -> list[SyntaxIssue]:
issues: list[SyntaxIssue] = []
masked_text = _mask_escaped_markers(_mask_complete_code_segments(markdown_text))
if masked_text.count("```") % 2 != 0:
issues.append(SyntaxIssue(marker="```", message="Unclosed code block marker."))
if masked_text.count("`") % 2 != 0:
issues.append(SyntaxIssue(marker="`", message="Unclosed inline code marker."))
sanitized_text = _mask_escaped_markers(_remove_complete_code_segments(markdown_text))
for marker in ("**", "__", "++", "~~", "||"):
if sanitized_text.count(marker) % 2 != 0:
issues.append(SyntaxIssue(marker=marker, message=f"Unclosed or unmatched {marker} marker."))
bracket_balance = 0
for character in sanitized_text:
if character == "[":
bracket_balance += 1
elif character == "]" and bracket_balance > 0:
bracket_balance -= 1
if bracket_balance != 0:
issues.append(SyntaxIssue(marker="[]", message="Unmatched square brackets in link-like syntax."))
parenthesis_balance = 0
for character in sanitized_text:
if character == "(":
parenthesis_balance += 1
elif character == ")" and parenthesis_balance > 0:
parenthesis_balance -= 1
if parenthesis_balance != 0:
issues.append(SyntaxIssue(marker="()", message="Unmatched parentheses in link-like syntax."))
return issues
def _mask_complete_code_segments(markdown_text: str) -> str:
text = re.sub(r"```(?:[^\n`]*)\n?(.*?)```", "", markdown_text, flags=re.DOTALL)
return re.sub(r"`([^`\n]+)`", "", text)
def _remove_complete_code_segments(markdown_text: str) -> str:
text = re.sub(r"```(?:[^\n`]*)\n?(.*?)```", "", markdown_text, flags=re.DOTALL)
return re.sub(r"`([^`\n]+)`", "", text)
def _mask_escaped_markers(markdown_text: str) -> str:
return re.sub(r"\\([\\`*_+\-~|.\[\]()#>])", "", markdown_text)
+82
View File
@@ -0,0 +1,82 @@
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Literal
class Language(str, Enum):
FI = "fi"
SV = "sv"
EN = "en"
LANGUAGE_DISPLAY_NAMES: dict[Language, str] = {
Language.FI: "Finnish",
Language.SV: "Swedish",
Language.EN: "English",
}
@dataclass(frozen=True)
class Config:
bot_token: str
chat_id: str
channel_username: str
discussion_group_id: str = ""
@dataclass(frozen=True)
class LanguageAssets:
language: Language
text_file: Path
text_raw: str
images: list[Path]
@dataclass(frozen=True)
class ScanResult:
global_images: list[Path]
languages: list[LanguageAssets]
@dataclass(frozen=True)
class PlaceholderToken:
label: str
target_lang: Language
source_span: tuple[int, int]
@dataclass
class PostedMessageRef:
language: Language
text_message_id: int | None
caption_message_id: int | None
message_url: str
had_placeholders: bool
text_mode: Literal["message", "caption", "none"]
@dataclass(frozen=True)
class TextSegmentBinding:
message_id: int
mode: Literal["message", "caption"]
@dataclass(frozen=True)
class LanguageBinding:
language: Language
image_message_ids: list[int]
text_segments: list[TextSegmentBinding]
primary_message_id: int | None
primary_message_url: str
discussion_message_id: int | None = None
@dataclass(frozen=True)
class PublishBinding:
global_message_ids: list[int]
global_attached_to_language: Language | None
languages: dict[Language, LanguageBinding]
discussion_message_ids: dict[int, int]
global_primary_message_id: int | None = None
global_discussion_message_id: int | None = None
+35
View File
@@ -0,0 +1,35 @@
import re
from linkki_poster.models import LANGUAGE_DISPLAY_NAMES, LanguageAssets
def prompt_language_order(languages: list[LanguageAssets], input_fn=input) -> list[LanguageAssets]:
if len(languages) <= 1:
return languages
print("Language order:")
for index, language_assets in enumerate(languages, start=1):
print(f"{index}. {LANGUAGE_DISPLAY_NAMES[language_assets.language]}")
expected = list(range(1, len(languages) + 1))
while True:
raw = input_fn("Enter language order: ")
if raw.strip() == "":
return languages
parsed = parse_order_input(raw, len(languages))
if parsed is None:
print(f"Invalid order. Press Enter for default order {expected}, or enter digits {expected}.")
continue
return [languages[index - 1] for index in parsed]
def parse_order_input(raw_text: str, language_count: int) -> list[int] | None:
digits = [int(item) for item in re.findall(r"\d", raw_text)]
if len(digits) != language_count:
return None
expected = set(range(1, language_count + 1))
if set(digits) != expected:
return None
return digits
+87
View File
@@ -0,0 +1,87 @@
import sys
from dataclasses import dataclass
@dataclass
class ProgressStep:
label: str
status: str = "pending"
detail: str = ""
class ProgressDisplay:
def __init__(self, stream=None):
self.stream = stream or sys.stdout
self.steps: list[ProgressStep] = []
self._rendered_line_count = 0
self._supports_rewrite = hasattr(self.stream, "isatty") and self.stream.isatty()
def add_step(self, label: str, status: str = "pending", detail: str = "") -> int:
self.steps.append(ProgressStep(label=label, status=status, detail=detail))
self._render()
return len(self.steps) - 1
def update(self, step_index: int, status: str, detail: str = "") -> None:
step = self.steps[step_index]
step.status = status
step.detail = detail
self._render()
def insert_step(self, index: int, label: str, status: str = "pending", detail: str = "") -> int:
self.steps.insert(index, ProgressStep(label=label, status=status, detail=detail))
self._render()
return index
def _render(self) -> None:
lines = [format_step(step) for step in self.steps]
output = "\n".join(lines)
if output:
output += "\n"
if self._supports_rewrite and self._rendered_line_count:
self.stream.write(f"\x1b[{self._rendered_line_count}A")
for _ in range(self._rendered_line_count):
self.stream.write("\x1b[2K\x1b[1B")
self.stream.write(f"\x1b[{self._rendered_line_count}A")
self.stream.write(output)
self.stream.flush()
self._rendered_line_count = len(lines)
class LogProgressDisplay:
def __init__(self, on_change=None):
self.steps: list[ProgressStep] = []
self.on_change = on_change
def add_step(self, label: str, status: str = "pending", detail: str = "") -> int:
self.steps.append(ProgressStep(label=label, status=status, detail=detail))
self._notify()
return len(self.steps) - 1
def update(self, step_index: int, status: str, detail: str = "") -> None:
step = self.steps[step_index]
step.status = status
step.detail = detail
self._notify()
def get_output(self) -> str:
return "\n".join(format_step(step) for step in self.steps)
def _notify(self) -> None:
if self.on_change is not None:
self.on_change(self.get_output())
def format_step(step: ProgressStep) -> str:
prefix_by_status = {
"pending": "[ ]",
"in_progress": "[>]",
"done": "[x]",
"failed": "[!]",
"skipped": "[-]",
}
prefix = prefix_by_status.get(step.status, "[?]")
if step.detail:
return f"{prefix} {step.label} - {step.detail}"
return f"{prefix} {step.label}"
+110
View File
@@ -0,0 +1,110 @@
import re
from pathlib import Path
from linkki_poster.models import Language, LanguageAssets, ScanResult
KNOWN_IMAGE_EXTENSIONS = {
"jpg",
"jpeg",
"png",
"webp",
"gif",
}
GLOBAL_IMAGE_PREFIXES = ["Kuva"]
LANGUAGE_TEXT_CANDIDATES: dict[Language, list[str]] = {
Language.FI: ["Suomi.md"],
Language.SV: ["Svenska.md"],
Language.EN: ["English.md"],
}
LANGUAGE_IMAGE_PREFIXES: dict[Language, list[str]] = {
Language.FI: ["Suomi"],
Language.SV: ["Svenska"],
Language.EN: ["English"],
}
def scan_directory(directory: Path) -> ScanResult:
files = sorted((entry for entry in directory.iterdir() if entry.is_file()), key=lambda path: path.name.lower())
casefold_name_index = _build_casefold_name_index(files)
global_images = select_first_matching_image_family(files, GLOBAL_IMAGE_PREFIXES)
language_assets: list[LanguageAssets] = []
for language in (Language.FI, Language.SV, Language.EN):
text_file = select_first_matching_text_file(casefold_name_index, LANGUAGE_TEXT_CANDIDATES[language])
if text_file is None:
continue
text_raw = text_file.read_text(encoding="utf-8")
if text_raw.strip() == "":
continue
language_images = select_first_matching_image_family(files, LANGUAGE_IMAGE_PREFIXES[language])
language_assets.append(
LanguageAssets(
language=language,
text_file=text_file,
text_raw=text_raw,
images=language_images,
)
)
return ScanResult(global_images=global_images, languages=language_assets)
def select_first_matching_text_file(casefold_name_index: dict[str, Path], candidate_names: list[str]) -> Path | None:
for candidate_name in candidate_names:
match = casefold_name_index.get(candidate_name.lower())
if match is not None:
return match
return None
def select_first_matching_image_family(files: list[Path], prefixes: list[str]) -> list[Path]:
for prefix in prefixes:
matches = collect_image_matches_for_prefix(files, prefix)
if matches:
return [match[0] for match in sorted(matches, key=image_sort_key)]
return []
def collect_image_matches_for_prefix(files: list[Path], prefix: str) -> list[tuple[Path, str, str]]:
pattern = re.compile(rf"^{re.escape(prefix)}(\d*)\.([^.]+)$", re.IGNORECASE)
matches: list[tuple[Path, str, str]] = []
for file_path in files:
match = pattern.match(file_path.name)
if match is None:
continue
extension = match.group(2).lower()
if extension not in KNOWN_IMAGE_EXTENSIONS:
continue
numeric_suffix = match.group(1)
matches.append((file_path, numeric_suffix, extension))
return matches
def image_sort_key(match_item: tuple[Path, str, str]) -> tuple[int, int, str, str]:
file_path, numeric_suffix, extension = match_item
has_numeric_suffix = numeric_suffix != ""
numeric_value = int(numeric_suffix) if has_numeric_suffix else -1
return (
1 if has_numeric_suffix else 0,
numeric_value,
extension.lower(),
file_path.name.lower(),
)
def _build_casefold_name_index(files: list[Path]) -> dict[str, Path]:
index: dict[str, Path] = {}
for file_path in files:
lowered = file_path.name.lower()
if lowered not in index:
index[lowered] = file_path
return index
@@ -0,0 +1,186 @@
import json
from contextlib import ExitStack
from pathlib import Path
import requests
class TelegramApiError(RuntimeError):
def __init__(self, description: str, error_code: int | None = None):
super().__init__(description)
self.description = description
self.error_code = error_code
class TelegramAPI:
def __init__(self, bot_token: str):
self.base_url = f"https://api.telegram.org/bot{bot_token}"
def send_media_group(
self,
chat_id: str,
image_paths: list[Path],
caption: str | None = None,
parse_mode: str | None = None,
) -> list[dict]:
if not image_paths:
raise ValueError("send_media_group requires at least one image.")
media_items = []
for index, _ in enumerate(image_paths):
media_item = {
"type": "photo",
"media": f"attach://file{index}",
}
if index == 0 and caption is not None:
media_item["caption"] = caption
if parse_mode is not None:
media_item["parse_mode"] = parse_mode
media_items.append(media_item)
payload = {"chat_id": chat_id, "media": json.dumps(media_items, ensure_ascii=False)}
with ExitStack() as stack:
files = {}
for index, image_path in enumerate(image_paths):
files[f"file{index}"] = stack.enter_context(image_path.open("rb"))
return self._post("sendMediaGroup", data=payload, files=files, timeout=60)
def get_updates(self, offset: int | None = None, limit: int = 100, timeout: int = 0) -> list[dict]:
payload = {"limit": limit, "timeout": timeout}
if offset is not None:
payload["offset"] = offset
return self._post("getUpdates", data=payload, timeout=timeout + 10)
def send_message(
self,
chat_id: str,
text: str,
parse_mode: str | None = None,
disable_link_preview: bool = False,
) -> dict:
payload = {"chat_id": chat_id, "text": text}
if parse_mode is not None:
payload["parse_mode"] = parse_mode
if disable_link_preview:
payload["link_preview_options"] = json.dumps({"is_disabled": True})
return self._post("sendMessage", data=payload, timeout=30)
def edit_message_text(
self,
chat_id: str,
message_id: int,
text: str,
parse_mode: str | None = None,
disable_link_preview: bool = False,
) -> dict:
payload = {
"chat_id": chat_id,
"message_id": message_id,
"text": text,
}
if parse_mode is not None:
payload["parse_mode"] = parse_mode
if disable_link_preview:
payload["link_preview_options"] = json.dumps({"is_disabled": True})
return self._post("editMessageText", data=payload, timeout=30)
def edit_message_caption(
self,
chat_id: str,
message_id: int,
caption: str,
parse_mode: str | None = None,
) -> dict:
payload = {
"chat_id": chat_id,
"message_id": message_id,
"caption": caption,
}
if parse_mode is not None:
payload["parse_mode"] = parse_mode
return self._post("editMessageCaption", data=payload, timeout=30)
def delete_message(self, chat_id: str, message_id: int) -> bool:
payload = {
"chat_id": chat_id,
"message_id": message_id,
}
return bool(self._post("deleteMessage", data=payload, timeout=30))
def pin_chat_message(self, chat_id: str, message_id: int, disable_notification: bool = True) -> bool:
payload = {
"chat_id": chat_id,
"message_id": message_id,
}
if disable_notification:
payload["disable_notification"] = "true"
return bool(self._post("pinChatMessage", data=payload, timeout=30))
def unpin_chat_message(self, chat_id: str, message_id: int) -> bool:
payload = {
"chat_id": chat_id,
"message_id": message_id,
}
return bool(self._post("unpinChatMessage", data=payload, timeout=30))
def _post(self, endpoint: str, data: dict, files: dict | None = None, timeout: int = 30):
response = requests.post(f"{self.base_url}/{endpoint}", data=data, files=files, timeout=timeout)
try:
response.raise_for_status()
except requests.HTTPError as error:
description = _extract_error_description(response) or str(error)
error_code = _extract_error_code(response)
raise TelegramApiError(description=description, error_code=error_code) from error
payload = response.json()
if not payload.get("ok", False):
raise TelegramApiError(
description=str(payload.get("description", "Telegram API request failed.")),
error_code=_to_int_or_none(payload.get("error_code")),
)
return payload["result"]
def _extract_error_description(response: requests.Response) -> str | None:
try:
payload = response.json()
except ValueError:
return None
description = payload.get("description")
if description is None:
return None
return str(description)
def _extract_error_code(response: requests.Response) -> int | None:
try:
payload = response.json()
except ValueError:
return None
return _to_int_or_none(payload.get("error_code"))
def _to_int_or_none(value) -> int | None:
if isinstance(value, int):
return value
if isinstance(value, str) and value.isdigit():
return int(value)
return None
def is_message_missing_error(error: TelegramApiError | str) -> bool:
description = error.description if isinstance(error, TelegramApiError) else str(error)
lowered = description.lower()
return "not found" in lowered or "message to delete not found" in lowered or "message to edit not found" in lowered
def is_message_not_modified_error(error: TelegramApiError | str) -> bool:
description = error.description if isinstance(error, TelegramApiError) else str(error)
return "message is not modified" in description.lower()
def is_message_not_deletable_error(error: TelegramApiError | str) -> bool:
description = error.description if isinstance(error, TelegramApiError) else str(error)
return "can't be deleted" in description.lower()
+539
View File
@@ -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