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