Add Linkki broadcast image
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8 context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; } name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18 POSTGIS_VERSION=3 VCHORD_VERSION=0.5.3 context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3 context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:linkki-tiedotus oci_labels:… (push) Successful in 39s

This commit is contained in:
ajp_anton
2026-08-19 20:51:26 +00:00
parent 7e6dea8b24
commit 8ec3cde4f2
56 changed files with 13441 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
__pycache__
*.py[cod]
.pytest_cache
.venv
.tmp
data
tmp_test_runs
testposts
.codex
.vscode
telegram_config.json
+112
View File
@@ -0,0 +1,112 @@
# Linkki Handoff Brief
## Purpose
This directory is the extracted Linkki Telegram broadcast editor. The active product is a FastAPI web app in a Docker container. Users draft Finnish, Swedish, and English channel broadcasts, preview Telegram formatting, schedule publishing, update published text, map channel messages to linked discussion-group messages, manage pinning, and delete or unlink Telegram posts.
The source is intended to be absorbed into a larger project. Read `README.md` for setup and operation; this document is implementation context for the next coding agent.
## Important entry points
| Path | Role |
| --- | --- |
| `webapp_main.py` | Container and local web-server entry point. |
| `webapp/app.py` | FastAPI routes, async scheduler, publish/update/delete orchestration. |
| `webapp/storage.py` | On-disk post schema, optimistic field revisions, state transitions, title/directory renames. |
| `webapp/static/app.js` | Stateful editor client. Large single IIFE; also handles editor-only sidebar navigation without a full page load. |
| `webapp/static/app.css` | Existing desktop-first editor/sidebar layout. |
| `webapp/templates/` | Base shell, editor, login, and instructions markup. |
| `webapp/translations/catalog.json` | All visible UI strings in Finnish, Swedish, and English. |
| `linkki_poster/` | Telegram API wrapper, formatting parser, directory scanning, publishing, updates, deletes, and pinning. |
| `tests/` | Pytest suite. |
## Data model
Runtime data is outside the source tree under `LINKKI_DATA_DIR` (`/data` in Docker):
```text
config/
app_config.json
users.json
telegram_config.json
posts/
slug/
post.json
Suomi.md
Svenska.md
English.md
images named Kuva*, Suomi*, Svenska*, English*
```
`post.json` is canonical metadata. `webapp.storage.load_metadata()` merges it with current defaults and normalizes fields. Current schema is `METADATA_SCHEMA_VERSION = 3`. Scheduled publishes retain a retry count and next-attempt timestamp; they retry after 1, 5, 15, and then 30 minutes until success. A one-shot migration previously normalized production posts, and old binding-format fallback support was deliberately removed afterward. Do not casually reintroduce old schema handling; if a new schema change is needed, use a deliberate migration.
Every editable persistent field has a revision in `metadata.field_revisions`. Browser saves include a base revision; storage returns a conflict if another editor changed the field. When adding a metadata field, update all of:
- `default_metadata()`
- `default_field_revisions()` when the field is independently editable
- `merge_metadata_with_defaults()`
- the browser editable snapshot and save payload in `app.js`
- storage tests
The title is also the directory slug. `apply_title_change()` renames the directory after checking the target path does not exist.
## Publish state and Telegram bindings
The central state values are `draft`, `scheduled`, `published`, `modified`, and `failed`.
- A channel broadcast stores target selection as `telegram_bot_id` and `telegram_chat_id`.
- `telegram_binding` stores channel message IDs and corresponding discussion-group message IDs.
- Linked broadcasts lock target/language/image setup, but title and text remain editable.
- Editing a linked text marks `dirty_text_languages`; editing pinning marks `dirty_discussion_pinning`. `perform_publish_action()` sends the required Telegram update.
- A successful scheduled unpin clears its schedule and sets pin mode to `none`.
Keep channel-broadcast bindings (`PublishBinding`, `LanguageBinding`, `TextSegmentBinding`) separate from any future direct-message or group-management feature. The current binding model assumes channel posts with optional discussion messages.
Discussion mapping is a Telegram limitation, not merely a UI concern. The bot must receive fresh automatic-forward updates from the linked discussion group. Privacy mode/admin visibility and competing `getUpdates` consumers matter. The automatic historical repair path was removed because it could only help while relevant updates were still arriving. Manual channel/group ID mapping is the intended recovery path.
`Delete from Telegram` deletes discussion messages first, retains partial bindings after failures, then deletes channel messages. Telegram may reject bot deletions after about 48 hours. Pin-service-message deletion is separate from pinning and defaults to enabled for new broadcasts.
## Frontend notes
`app.js` owns a `state` object containing the current and last-saved broadcast, permissions, operation polling, sidebar data, and active tab. Re-rendering rebuilds language panels and Telegram mapping cells. Existing helpers keep textarea and preview heights synchronized.
Sidebar navigation between editor pages is deliberately partial:
1. Sidebar announcement clicks are intercepted.
2. The client fetches `GET /api/announcements/{directory}`.
3. It replaces editor state, re-renders, and calls `history.pushState`.
4. Browser back/forward uses `popstate` and refetches editor state.
Do not turn ordinary sidebar forms (creation, reordering, sign-out, language change) into partial navigation without designing their return/state behavior. The base template still owns sidebar scroll persistence.
The Telegram link tab uses an aligned table. A group link cannot exist without its channel link. The Pinning tab is disabled when the chosen channel target lacks a discussion group. Preserve those constraints in both UI and backend routes.
Text formatting is conservative by design. Inline styles are parsed/re-serialized to avoid crossing tags. The parser in `linkki_poster/formatting.py` is authoritative. Text-node HTML escaping intentionally uses `quote=False`: escaping apostrophes as `'` caused Telegram heading rendering bugs.
## Permissions and security
Permissions are in `webapp/auth.py` and normalized into `users.json`. New actions need both backend enforcement and corresponding browser disabled state.
Never commit real Telegram tokens, user data, password hashes, or `/data` contents.
## Operations and testing
The FastAPI scheduler runs in-process and checks persisted schedules every `scheduler_poll_seconds`. Its first pass posts every overdue scheduled broadcast immediately after startup, then follows the persisted retry delay. In-memory operation progress (`app.state.operations`) disappears on container restart; the persisted logs and schedules remain.
Run:
```text
python -m pip install -r requirements-dev.txt
python -m pytest -q
python -m compileall webapp linkki_poster tests
node --check webapp/static/app.js
```
Use a virtual environment if the host Python is externally managed.
## Non-goals and recent decisions
- The application only supports channel broadcasts today.
- A proposed direct/mass-message feature was not implemented because Telegram Bot API bots cannot reliably resolve arbitrary private-user `@usernames` to chat IDs or initiate chats with them. Treat any future direct-message feature as a separate design with numeric chat IDs or a local known-contact store.
- A proposed group-creation feature was also deferred; Bot API does not provide general create-group/add-arbitrary-user-by-username behavior.
- The root standalone CLI scripts (`post_broadcast.py`, `main.py`, and `manage_broadcast.py`) remain as legacy/manual tools. They are not part of the container workflow.
+22
View File
@@ -0,0 +1,22 @@
ARG PYTHON_VERSION=3.12-slim
FROM python:${PYTHON_VERSION}
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV LINKKI_DATA_DIR=/data
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY webapp_main.py ./
COPY webapp ./webapp
COPY linkki_poster ./linkki_poster
RUN mkdir -p /data/config /data/posts
EXPOSE 8080
VOLUME ["/data"]
CMD ["python", "webapp_main.py"]
+185
View File
@@ -0,0 +1,185 @@
# Linkki Telegram Broadcast Editor
Linkki is a Docker-hosted web application for drafting, scheduling, publishing, updating, and deleting multilingual Telegram channel broadcasts. It supports Finnish, Swedish, and English, using a Telegram-oriented Markdown dialect with a live preview.
The app stores its configuration and broadcasts outside the source tree, so container rebuilds do not remove published-message mappings, images, or drafts.
For codebase and maintenance context, see [CODEX_HANDOFF.md](CODEX_HANDOFF.md).
## Run locally
Requires Python 3.12 or newer.
```text
python -m pip install -r requirements.txt
python webapp_main.py
```
Open `http://localhost:8080`.
The data directory comes from `LINKKI_DATA_DIR`; without it, the app uses `data/` beside the source tree.
```text
data/
config/
app_config.json
users.json
telegram_config.json
posts/
my-broadcast/
post.json
Suomi.md
Svenska.md
English.md
Kuva1.jpg
Suomi1.jpg
Svenska1.jpg
English1.jpg
```
Copy and adapt the files under `config-examples/`. Do not commit real tokens, password hashes, or production data.
## Configuration
`data/config/app_config.json`:
```json
{
"timezone": "Europe/Helsinki",
"session_secret": "replace-with-a-long-random-secret",
"scheduler_poll_seconds": 60,
"users_file": "users.json"
}
```
`session_secret` signs login session cookies. Changing it logs out every active user.
`data/config/telegram_config.json` supports multiple bots and channel targets:
```json
{
"bots": [
{
"display_name": "Main bot",
"token": "123456:ABCDEF...",
"default": true,
"channels": [
{
"display_name": "Main channel",
"channel_id": "-1001234567890",
"discussion_group_id": "-1001234567891",
"channel_username": "my_public_channel",
"default": true
}
]
}
]
}
```
`discussion_group_id` is optional. It enables discussion-message mapping and pinning. `channel_username` is optional; it is used for public Telegram message URLs, while private channels use `t.me/c/...` URLs derived from `channel_id`.
`data/config/users.json` contains password hashes, UI language preference, and permissions. Generate a password hash with:
```text
python -m webapp.auth
```
Permissions default to `false` when omitted. The available keys are:
```text
edit_content
edit_targets
save_announcements
open_telegram
post_now
schedule
unschedule
unlink_telegram
delete_telegram
delete_storage
```
The app normalizes missing permission keys and saved UI-language preferences back into `users.json`.
## Using the editor
New broadcasts appear first in the sidebar. The editor has four settings tabs:
- `Main`: title, selected bot/channel, language order, scheduling, images, and text editors.
- `Telegram link`: channel and discussion-group message mappings. Missing links can be entered manually as a message ID or a Telegram message URL.
- `Pinning`: discussion-group pin mode, service-message cleanup, and scheduled unpinning.
- `Logs`: accumulated publish, update, delete, and unpin output.
The sidebar markers convey state: green is published, blue is modified after publish, yellow is scheduled, and red is failed. Incomplete Telegram links make the status marker red. A pin marker means linked discussion messages are pinned; it becomes yellow when an unpin is scheduled.
The broadcast lifecycle is deliberately restrictive:
- Drafts can change targets, language setup, images, and text.
- Scheduled broadcasts keep their selected bot/channel locked until unscheduled.
- Linked broadcasts keep targets, language setup, and images locked. The internal title, text, link-preview settings, and pinning settings remain editable.
- Published text changes become `modified` and are sent with `Post modifications now`.
- `Delete from Telegram` tries discussion-group messages before channel messages. It retains links for messages that could not be deleted.
- Telegram bots cannot delete messages after roughly 48 hours. The UI disables that action once the stored deadline has passed.
Discussion-message mappings require the bot to see the discussion group's forwarded messages. In practice, the bot needs privacy mode disabled or sufficient admin visibility. Historic links missed at posting time cannot be recovered automatically; use the manual mapping fields.
`Delete service messages` is enabled by default for new broadcasts. It requires Telegram deletion rights in the discussion group and may fail independently of pinning.
## Text syntax
The text editor supports Telegram-oriented Markdown and preview:
~~~text
**bold**
_italic_
++underline++
~~strikethrough~~
`inline code`
```code block```
[label](https://example.com)
||spoiler||
> quote
- bullet
1. numbered item
# heading
~~~
Cross-language links use `[label](fi)`, `[label](sv)`, or `[label](en)`. A backslash escapes formatting markers. The editor toolbar and `Ctrl/Cmd+B`, `I`, and `U` apply common inline styles.
## Container image
This repository publishes `git.ajpanton.se/ajp_anton/linkki-tiedotus:1` and the mutable
`latest` tag. The image serves port `8080`; put an authenticated reverse proxy
in front of it when needed. `compose.example.yaml` is a complete generic
service definition. The application data directory must be persistent and must
not be committed to source control.
```yaml
services:
linkki-tiedotus:
image: git.ajpanton.se/ajp_anton/linkki-tiedotus:1
ports:
- "8008:8080"
environment:
LINKKI_DATA_DIR: /data
volumes:
- /host/linkki-data:/data
restart: unless-stopped
```
Scheduled broadcasts are persisted in `/data`. If the container is offline
when one becomes due, it is posted on the scheduler's first pass after startup.
Failed scheduled posts retry after 1, 5, 15, and then every 30 minutes until
they succeed or are unscheduled. A manually started post is retried once
immediately before it is marked failed.
## Legacy standalone CLI
The web app is the maintained workflow. The repository also keeps the original one-directory poster for manual use and as a lower-level reference:
```text
python post_broadcast.py path/to/broadcast
```
It expects a separate, single-bot `telegram_config.json` beside the script with `bot_token`, `chat_id`, and optional `channel_username`. `main.py` is a compatibility alias. `manage_broadcast.py` is an advanced helper for updating or deleting a previously linked broadcast. These scripts are not used by the container.
+10
View File
@@ -0,0 +1,10 @@
services:
linkki-tiedotus:
image: git.ajpanton.se/ajp_anton/linkki-tiedotus:1
environment:
LINKKI_DATA_DIR: /data
ports:
- "8080:8080"
volumes:
- ${LINKKI_DATA_DIR:?Set LINKKI_DATA_DIR to the persistent host directory}:/data
restart: unless-stopped
@@ -0,0 +1,6 @@
{
"timezone": "UTC",
"session_secret": "replace-with-a-long-random-secret",
"scheduler_poll_seconds": 60,
"users_file": "users.json"
}
@@ -0,0 +1,18 @@
{
"bots": [
{
"display_name": "Main bot",
"token": "123456:ABCDEF...",
"default": true,
"channels": [
{
"display_name": "Main channel",
"channel_id": "-1001234567890",
"discussion_group_id": "-1001234567891",
"channel_username": "",
"default": true
}
]
}
]
}
@@ -0,0 +1,21 @@
{
"users": [
{
"username": "admin",
"password_hash": "output of auth.py",
"language": "auto",
"permissions": {
"edit_content": false,
"edit_targets": false,
"save_announcements": false,
"open_telegram": false,
"post_now": false,
"schedule": false,
"unschedule": false,
"unlink_telegram": false,
"delete_telegram": false,
"delete_storage": false
}
}
]
}
@@ -0,0 +1 @@
"""Telegram broadcast posting helpers."""
@@ -0,0 +1,979 @@
import time
from dataclasses import dataclass
from pathlib import Path
from linkki_poster.formatting import CAPTION_LIMIT, ParsedTemplate, parse_markdown_template, render_template
from linkki_poster.models import (
Config,
Language,
LanguageAssets,
LanguageBinding,
LANGUAGE_DISPLAY_NAMES,
PublishBinding,
ScanResult,
TextSegmentBinding,
)
from linkki_poster.progress import ProgressDisplay
from linkki_poster.scanner import scan_directory
from linkki_poster.telegram_api import (
TelegramAPI,
TelegramApiError,
is_message_missing_error,
is_message_not_modified_error,
)
from linkki_poster.workflow import (
build_edit_label,
build_image_label,
build_message_url,
build_text_label,
is_caption_too_long_error,
normalize_channel_username,
resolve_global_image_strategy,
resolve_link_target,
split_markdown_text_for_messages,
)
@dataclass(frozen=True)
class PublishResult:
binding: PublishBinding
message_urls: dict[Language, str]
@dataclass(frozen=True)
class DeleteBindingResult:
warnings: list[str]
failures: list[str]
binding: PublishBinding | None
@dataclass(frozen=True)
class DiscussionResolutionStats:
updates_seen: int
discussion_group_updates: int
automatic_forward_updates: int
linked_channel_forward_updates: int
@dataclass(frozen=True)
class DiscussionPinningResult:
binding: PublishBinding
pinning_failed: bool
@dataclass
class _PublishedTextSegment:
label: str
binding: TextSegmentBinding
parsed_template: ParsedTemplate
last_rendered: str
@dataclass(frozen=True)
class _PublishedLanguage:
binding: LanguageBinding
segments: list[_PublishedTextSegment]
def publish_directory(
directory: Path,
config: Config,
ordered_languages: list[Language] | None = None,
use_captions: bool = True,
progress: ProgressDisplay | None = None,
disable_link_previews: dict[Language, bool] | None = None,
) -> PublishResult:
scan_result = scan_directory(directory)
languages = order_scan_languages(scan_result, ordered_languages)
reporter = progress or ProgressDisplay()
api = TelegramAPI(config.bot_token)
normalized_username = normalize_channel_username(config.channel_username)
link_target = resolve_link_target(config.chat_id, normalized_username)
language_links: dict[Language, str] = {}
published_languages: dict[Language, _PublishedLanguage] = {}
global_message_ids: list[int] = []
global_attached_to_language: Language | None = None
posted_message_ids: list[int] = []
leading_global_images, effective_languages = resolve_global_image_strategy(scan_result.global_images, languages)
if scan_result.global_images and not leading_global_images and effective_languages:
global_attached_to_language = effective_languages[0].language
try:
if leading_global_images:
global_message_ids = post_images(
api=api,
chat_id=config.chat_id,
image_paths=leading_global_images,
label=build_image_label("global", len(leading_global_images)),
reporter=reporter,
posted_message_ids=posted_message_ids,
)
for language_assets in effective_languages:
published_language = publish_language(
api=api,
chat_id=config.chat_id,
language_assets=language_assets,
language_links=language_links,
disable_link_preview=bool((disable_link_previews or {}).get(language_assets.language, False)),
link_target=link_target,
use_captions=use_captions,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
published_languages[language_assets.language] = published_language
language_links[language_assets.language] = published_language.binding.primary_message_url
backfill_links(
api=api,
chat_id=config.chat_id,
published_segments={language: item.segments for language, item in published_languages.items()},
language_links=language_links,
disable_link_previews=disable_link_previews or {},
reporter=reporter,
)
return PublishResult(
binding=PublishBinding(
global_message_ids=global_message_ids,
global_attached_to_language=global_attached_to_language,
languages={language: item.binding for language, item in published_languages.items()},
discussion_message_ids={},
global_primary_message_id=global_message_ids[0] if global_message_ids and global_attached_to_language is None else None,
),
message_urls=language_links,
)
except Exception:
rollback_messages(api, config.chat_id, posted_message_ids, reporter)
raise
def update_texts(
directory: Path,
config: Config,
binding: PublishBinding,
changed_languages: set[Language],
progress: ProgressDisplay | None = None,
disable_link_previews: dict[Language, bool] | None = None,
) -> PublishBinding:
reporter = progress or ProgressDisplay()
api = TelegramAPI(config.bot_token)
scan_result = scan_directory(directory)
scan_languages = {language_assets.language: language_assets for language_assets in scan_result.languages}
language_links = {language: language_binding.primary_message_url for language, language_binding in binding.languages.items()}
normalized_username = normalize_channel_username(config.channel_username)
link_target = resolve_link_target(config.chat_id, normalized_username)
updated_languages: dict[Language, LanguageBinding] = dict(binding.languages)
for language in changed_languages:
language_assets = scan_languages.get(language)
existing_binding = binding.languages.get(language)
if language_assets is None or existing_binding is None:
continue
chunk_texts = split_markdown_text_for_messages(language_assets.text_raw, link_target)
if len(chunk_texts) != len(existing_binding.text_segments):
raise ValueError(
f"Updated {LANGUAGE_DISPLAY_NAMES[language]} text changed message count. Delete or unlink and publish again."
)
for index, (chunk_text, existing_segment) in enumerate(zip(chunk_texts, existing_binding.text_segments), start=1):
parsed_template = parse_markdown_template(chunk_text)
rendered_text = render_template(parsed_template, language_links)
step_index = reporter.add_step(build_edit_label(build_text_label(language, index, len(chunk_texts))))
reporter.update(step_index, "in_progress")
try:
if existing_segment.mode == "message":
api.edit_message_text(
config.chat_id,
existing_segment.message_id,
rendered_text,
parse_mode="HTML",
disable_link_preview=bool((disable_link_previews or {}).get(language, False)),
)
else:
api.edit_message_caption(config.chat_id, existing_segment.message_id, rendered_text, parse_mode="HTML")
except TelegramApiError as error:
if is_message_missing_error(error):
raise ValueError(
f"Telegram message for {LANGUAGE_DISPLAY_NAMES[language]} no longer exists. "
"Delete from Telegram or unlink this broadcast and publish again."
) from error
if is_message_not_modified_error(error):
reporter.update(step_index, "skipped", "no Telegram-visible change")
continue
raise
reporter.update(step_index, "done")
return PublishBinding(
global_message_ids=binding.global_message_ids,
global_attached_to_language=binding.global_attached_to_language,
languages=updated_languages,
discussion_message_ids=dict(binding.discussion_message_ids),
global_primary_message_id=binding.global_primary_message_id,
global_discussion_message_id=binding.global_discussion_message_id,
)
def delete_binding_from_telegram(
config: Config,
binding: PublishBinding,
progress: ProgressDisplay | None = None,
) -> DeleteBindingResult:
reporter = progress or ProgressDisplay()
api = TelegramAPI(config.bot_token)
failures: list[str] = []
warnings: list[str] = []
deleted_channel_ids: set[int] = set()
deleted_discussion_channel_ids: set[int] = set()
deleted_discussion_message_ids: set[int] = set()
if config.discussion_group_id:
discussion_targets: list[tuple[int | None, int]] = []
if binding.global_discussion_message_id:
discussion_targets.append((binding.global_primary_message_id, binding.global_discussion_message_id))
for language_binding in binding.languages.values():
if language_binding.discussion_message_id:
discussion_targets.append((language_binding.primary_message_id, language_binding.discussion_message_id))
seen_discussion_ids: set[int] = set()
for channel_message_id, discussion_message_id in reversed(discussion_targets):
if discussion_message_id in seen_discussion_ids:
continue
seen_discussion_ids.add(discussion_message_id)
step_index = reporter.add_step(f"Delete discussion message {discussion_message_id}")
reporter.update(step_index, "in_progress")
try:
api.delete_message(config.discussion_group_id, discussion_message_id)
except TelegramApiError as error:
if is_message_missing_error(error):
detail = f"{error.description}; it may already have been deleted manually"
reporter.update(step_index, "skipped", detail)
warnings.append(f"Discussion message {discussion_message_id}: {error.description}")
if channel_message_id:
deleted_discussion_channel_ids.add(channel_message_id)
deleted_discussion_message_ids.add(discussion_message_id)
continue
reporter.update(step_index, "failed", error.description)
failures.append(f"Discussion message {discussion_message_id}: {error.description}")
continue
if channel_message_id:
deleted_discussion_channel_ids.add(channel_message_id)
deleted_discussion_message_ids.add(discussion_message_id)
reporter.update(step_index, "done")
if failures:
return DeleteBindingResult(
warnings=warnings,
failures=failures,
binding=prune_binding_after_deletions(
binding,
deleted_channel_ids=set(),
deleted_discussion_channel_ids=deleted_discussion_channel_ids,
deleted_discussion_message_ids=deleted_discussion_message_ids,
chat_id=config.chat_id,
channel_username=config.channel_username,
),
)
message_ids: list[int] = []
message_ids.extend(binding.global_message_ids)
for language_binding in binding.languages.values():
message_ids.extend(language_binding.image_message_ids)
message_ids.extend(segment.message_id for segment in language_binding.text_segments)
for message_id in reversed(deduplicate_message_ids(message_ids)):
step_index = reporter.add_step(f"Delete message {message_id}")
reporter.update(step_index, "in_progress")
try:
api.delete_message(config.chat_id, message_id)
except TelegramApiError as error:
if is_message_missing_error(error):
detail = f"{error.description}; it may already have been deleted manually"
reporter.update(step_index, "skipped", detail)
warnings.append(f"Message {message_id}: {error.description}")
deleted_channel_ids.add(message_id)
continue
reporter.update(step_index, "failed", error.description)
failures.append(f"Message {message_id}: {error.description}")
continue
deleted_channel_ids.add(message_id)
reporter.update(step_index, "done")
return DeleteBindingResult(
warnings=warnings,
failures=failures,
binding=prune_binding_after_deletions(
binding,
deleted_channel_ids=deleted_channel_ids,
deleted_discussion_channel_ids=deleted_discussion_channel_ids,
deleted_discussion_message_ids=deleted_discussion_message_ids,
chat_id=config.chat_id,
channel_username=config.channel_username,
),
)
def prune_binding_after_deletions(
binding: PublishBinding,
deleted_channel_ids: set[int],
deleted_discussion_channel_ids: set[int],
deleted_discussion_message_ids: set[int],
chat_id: str,
channel_username: str,
) -> PublishBinding | None:
link_target = resolve_link_target(chat_id, normalize_channel_username(channel_username))
global_message_ids = [message_id for message_id in binding.global_message_ids if message_id not in deleted_channel_ids]
languages: dict[Language, LanguageBinding] = {}
for language, language_binding in binding.languages.items():
image_message_ids = [message_id for message_id in language_binding.image_message_ids if message_id not in deleted_channel_ids]
text_segments = [
TextSegmentBinding(message_id=segment.message_id, mode=segment.mode)
for segment in language_binding.text_segments
if segment.message_id not in deleted_channel_ids
]
if not image_message_ids and not text_segments:
continue
primary_message_id = text_segments[0].message_id if text_segments else image_message_ids[0]
languages[language] = LanguageBinding(
language=language,
image_message_ids=image_message_ids,
text_segments=text_segments,
primary_message_id=primary_message_id,
primary_message_url=build_message_url(link_target, primary_message_id),
discussion_message_id=None,
)
discussion_message_ids = {
channel_message_id: discussion_message_id
for channel_message_id, discussion_message_id in binding.discussion_message_ids.items()
if channel_message_id not in deleted_channel_ids and channel_message_id not in deleted_discussion_channel_ids
}
global_primary_message_id = (
binding.global_primary_message_id
if binding.global_primary_message_id and binding.global_primary_message_id not in deleted_channel_ids
else (global_message_ids[0] if global_message_ids and binding.global_attached_to_language is None else None)
)
global_discussion_message_id = binding.global_discussion_message_id
if global_primary_message_id and global_primary_message_id in deleted_discussion_channel_ids:
global_discussion_message_id = None
elif global_primary_message_id and global_primary_message_id in discussion_message_ids:
global_discussion_message_id = discussion_message_ids[global_primary_message_id]
elif not global_primary_message_id:
global_discussion_message_id = None
elif global_discussion_message_id in deleted_discussion_message_ids:
global_discussion_message_id = None
languages = {
language: LanguageBinding(
language=language_binding.language,
image_message_ids=list(language_binding.image_message_ids),
text_segments=list(language_binding.text_segments),
primary_message_id=language_binding.primary_message_id,
primary_message_url=language_binding.primary_message_url,
discussion_message_id=(
None
if language_binding.discussion_message_id in deleted_discussion_message_ids
else (
discussion_message_ids.get(language_binding.primary_message_id)
if language_binding.primary_message_id
else language_binding.discussion_message_id
)
),
)
for language, language_binding in languages.items()
}
if not global_message_ids and not languages:
return None
return PublishBinding(
global_message_ids=global_message_ids,
global_attached_to_language=binding.global_attached_to_language if global_message_ids else None,
languages=languages,
discussion_message_ids=discussion_message_ids,
global_primary_message_id=global_primary_message_id,
global_discussion_message_id=global_discussion_message_id,
)
def snapshot_next_update_offset(config: Config) -> int:
api = TelegramAPI(config.bot_token)
offset: int | None = None
latest_offset = 0
while True:
updates = api.get_updates(offset=offset, limit=100, timeout=0)
if not updates:
return latest_offset
latest_offset = max(int(update["update_id"]) for update in updates) + 1
if len(updates) < 100:
return latest_offset
offset = latest_offset
def apply_discussion_group_pinning(
config: Config,
binding: PublishBinding,
ordered_languages: list[Language],
pinning_mode: str,
delete_service_messages: bool = False,
update_offset: int = 0,
progress: ProgressDisplay | None = None,
) -> DiscussionPinningResult:
if not config.discussion_group_id:
return DiscussionPinningResult(binding=binding, pinning_failed=False)
reporter = progress or ProgressDisplay()
binding, update_offset = ensure_discussion_group_message_ids(
config=config,
binding=binding,
ordered_languages=ordered_languages,
update_offset=update_offset,
progress=progress,
required=True,
)
primary_channel_message_ids = build_primary_channel_message_ids(binding, ordered_languages)
ordered_discussion_ids = [
binding.discussion_message_ids[channel_message_id]
for channel_message_id in primary_channel_message_ids
if channel_message_id in binding.discussion_message_ids
]
if not ordered_discussion_ids:
return DiscussionPinningResult(binding=binding, pinning_failed=False)
api = TelegramAPI(config.bot_token)
keep_ids = select_discussion_keep_ids(ordered_discussion_ids, pinning_mode)
pinning_failed = False
for discussion_message_id in ordered_discussion_ids:
if discussion_message_id in keep_ids:
pin_step = reporter.add_step(f"Pin discussion message {discussion_message_id}")
reporter.update(pin_step, "in_progress")
try:
api.pin_chat_message(config.discussion_group_id, discussion_message_id, disable_notification=True)
except TelegramApiError as error:
pinning_failed = True
reporter.update(pin_step, "failed", error.description)
continue
if delete_service_messages:
update_offset = delete_pin_service_message(
api=api,
discussion_group_id=config.discussion_group_id,
pinned_message_id=discussion_message_id,
update_offset=update_offset,
reporter=reporter,
)
reporter.update(pin_step, "done")
elif discussion_message_id not in keep_ids:
unpin_step = reporter.add_step(f"Unpin discussion message {discussion_message_id}")
reporter.update(unpin_step, "in_progress")
try:
api.unpin_chat_message(config.discussion_group_id, discussion_message_id)
except TelegramApiError as error:
pinning_failed = True
reporter.update(unpin_step, "failed", error.description)
continue
reporter.update(unpin_step, "done")
return DiscussionPinningResult(
binding=PublishBinding(
global_message_ids=binding.global_message_ids,
global_attached_to_language=binding.global_attached_to_language,
languages=binding.languages,
discussion_message_ids=dict(binding.discussion_message_ids),
global_primary_message_id=binding.global_primary_message_id,
global_discussion_message_id=binding.global_discussion_message_id,
),
pinning_failed=pinning_failed,
)
def select_discussion_keep_ids(ordered_discussion_ids: list[int], pinning_mode: str | None) -> set[int]:
if not ordered_discussion_ids:
return set()
if pinning_mode == "all":
return set(ordered_discussion_ids)
if pinning_mode == "first":
return {ordered_discussion_ids[0]}
if pinning_mode == "last":
return {ordered_discussion_ids[-1]}
return set()
def order_scan_languages(scan_result: ScanResult, ordered_languages: list[Language] | None) -> list[LanguageAssets]:
if ordered_languages is None:
return scan_result.languages
by_language = {language_assets.language: language_assets for language_assets in scan_result.languages}
ordered: list[LanguageAssets] = []
for language in ordered_languages:
match = by_language.get(language)
if match is None:
continue
ordered.append(match)
return ordered
def build_primary_channel_message_ids(binding: PublishBinding, ordered_languages: list[Language]) -> list[int]:
message_ids: list[int] = []
if binding.global_primary_message_id and binding.global_attached_to_language is None:
message_ids.append(binding.global_primary_message_id)
elif binding.global_message_ids and binding.global_attached_to_language is None:
message_ids.append(binding.global_message_ids[0])
for language in ordered_languages:
language_binding = binding.languages.get(language)
if language_binding is None:
continue
if language_binding.primary_message_id:
message_ids.append(language_binding.primary_message_id)
elif language_binding.text_segments:
message_ids.append(language_binding.text_segments[0].message_id)
elif language_binding.image_message_ids:
message_ids.append(language_binding.image_message_ids[0])
return message_ids
def ensure_discussion_group_message_ids(
config: Config,
binding: PublishBinding,
ordered_languages: list[Language],
update_offset: int = 0,
progress: ProgressDisplay | None = None,
required: bool = True,
) -> tuple[PublishBinding, int]:
if not config.discussion_group_id:
return binding, update_offset
reporter = progress or ProgressDisplay()
primary_channel_message_ids = build_primary_channel_message_ids(binding, ordered_languages)
discussion_message_ids = dict(binding.discussion_message_ids)
missing_message_ids = [
message_id for message_id in primary_channel_message_ids if message_id not in discussion_message_ids
]
if not missing_message_ids:
return binding, update_offset
step_index = reporter.add_step("Resolve discussion-group messages")
reporter.update(step_index, "in_progress")
matched_ids, next_offset, stats = collect_discussion_message_ids(
config=config,
channel_message_ids=primary_channel_message_ids,
update_offset=update_offset,
)
discussion_message_ids.update(matched_ids)
ordered_discussion_ids = [
discussion_message_ids[channel_message_id]
for channel_message_id in primary_channel_message_ids
if channel_message_id in discussion_message_ids
]
if not ordered_discussion_ids:
detail = build_discussion_resolution_detail(
stats=stats,
expected_count=len(primary_channel_message_ids),
matched_count=0,
)
reporter.update(step_index, "failed", detail)
if required:
raise ValueError(
"Could not find forwarded discussion-group messages for this announcement. "
f"{detail}"
)
return binding, next_offset
if len(ordered_discussion_ids) < len(primary_channel_message_ids):
reporter.update(
step_index,
"done",
build_discussion_resolution_detail(
stats=stats,
expected_count=len(primary_channel_message_ids),
matched_count=len(ordered_discussion_ids),
),
)
else:
reporter.update(step_index, "done")
updated_languages = {
language: LanguageBinding(
language=language_binding.language,
image_message_ids=list(language_binding.image_message_ids),
text_segments=list(language_binding.text_segments),
primary_message_id=language_binding.primary_message_id,
primary_message_url=language_binding.primary_message_url,
discussion_message_id=(
discussion_message_ids.get(language_binding.primary_message_id)
if language_binding.primary_message_id
else language_binding.discussion_message_id
),
)
for language, language_binding in binding.languages.items()
}
return (
PublishBinding(
global_message_ids=binding.global_message_ids,
global_attached_to_language=binding.global_attached_to_language,
languages=updated_languages,
discussion_message_ids=discussion_message_ids,
global_primary_message_id=binding.global_primary_message_id,
global_discussion_message_id=(
discussion_message_ids.get(binding.global_primary_message_id)
if binding.global_primary_message_id
else binding.global_discussion_message_id
),
),
next_offset,
)
def collect_discussion_message_ids(
config: Config,
channel_message_ids: list[int],
update_offset: int,
timeout_seconds: int = 12,
) -> tuple[dict[int, int], int, DiscussionResolutionStats]:
api = TelegramAPI(config.bot_token)
expected = set(channel_message_ids)
matched: dict[int, int] = {}
offset = update_offset
deadline = time.monotonic() + timeout_seconds
updates_seen = 0
discussion_group_updates = 0
automatic_forward_updates = 0
linked_channel_forward_updates = 0
while time.monotonic() < deadline and len(matched) < len(expected):
updates = api.get_updates(offset=offset, limit=100, timeout=1)
updates_seen += len(updates)
if updates:
offset = max(int(update["update_id"]) for update in updates) + 1
for update in updates:
message = extract_message_from_update(update)
if message is None:
continue
if str((message.get("chat") or {}).get("id", "")).strip() != config.discussion_group_id:
continue
discussion_group_updates += 1
if not bool(message.get("is_automatic_forward", False)):
continue
automatic_forward_updates += 1
origin = message.get("forward_origin")
if not isinstance(origin, dict):
continue
origin_chat = origin.get("chat")
if not isinstance(origin_chat, dict):
continue
if str(origin_chat.get("id", "")).strip() != config.chat_id:
continue
linked_channel_forward_updates += 1
channel_message_id = int(origin.get("message_id", 0) or 0)
if channel_message_id not in expected or channel_message_id in matched:
continue
matched[channel_message_id] = int(message["message_id"])
return (
matched,
offset,
DiscussionResolutionStats(
updates_seen=updates_seen,
discussion_group_updates=discussion_group_updates,
automatic_forward_updates=automatic_forward_updates,
linked_channel_forward_updates=linked_channel_forward_updates,
),
)
def build_discussion_resolution_detail(
stats: DiscussionResolutionStats,
expected_count: int,
matched_count: int,
) -> str:
detail = [f"matched {matched_count}/{expected_count} discussion-group messages"]
if stats.updates_seen == 0:
detail.append("saw no new bot updates")
elif stats.discussion_group_updates == 0:
detail.append(
f"saw {stats.updates_seen} bot updates, but none were from the configured discussion group"
)
elif stats.automatic_forward_updates == 0:
detail.append(
f"saw {stats.discussion_group_updates} discussion-group updates, but none were automatic forwards"
)
elif stats.linked_channel_forward_updates == 0:
detail.append(
f"saw {stats.automatic_forward_updates} automatic forwards in the discussion group, but none came from the linked channel"
)
else:
detail.append(
f"saw {stats.linked_channel_forward_updates} automatic forwards from the linked channel"
)
detail.append(
"check bot message access in the discussion group and make sure no other process is consuming getUpdates"
)
detail.append(
"if discussion-group links were missed, they must be linked manually"
)
return "; ".join(detail)
def delete_pin_service_message(
api: TelegramAPI,
discussion_group_id: str,
pinned_message_id: int,
update_offset: int,
reporter: ProgressDisplay,
timeout_seconds: float = 2.0,
) -> int:
deadline = time.monotonic() + timeout_seconds
offset = update_offset
while time.monotonic() < deadline:
updates = api.get_updates(offset=offset, limit=100, timeout=1)
if updates:
offset = max(int(update["update_id"]) for update in updates) + 1
for update in updates:
message = extract_message_from_update(update)
if message is None:
continue
if str((message.get("chat") or {}).get("id", "")).strip() != discussion_group_id:
continue
pinned_payload = message.get("pinned_message")
if not isinstance(pinned_payload, dict):
continue
if int(pinned_payload.get("message_id", 0) or 0) != pinned_message_id:
continue
service_message_id = int(message.get("message_id", 0) or 0)
if service_message_id <= 0:
return offset
cleanup_step = reporter.add_step(f"Delete pin service message {service_message_id}")
reporter.update(cleanup_step, "in_progress")
try:
api.delete_message(discussion_group_id, service_message_id)
except TelegramApiError as error:
if is_message_missing_error(error):
reporter.update(cleanup_step, "skipped", "already gone")
else:
reporter.update(cleanup_step, "failed", error.description)
else:
reporter.update(cleanup_step, "done")
return offset
return offset
def extract_message_from_update(update: dict) -> dict | None:
for key in ("message", "edited_message", "channel_post", "edited_channel_post"):
payload = update.get(key)
if isinstance(payload, dict):
return payload
return None
def publish_language(
api: TelegramAPI,
chat_id: str,
language_assets: LanguageAssets,
language_links: dict[Language, str],
disable_link_preview: bool,
link_target: str,
use_captions: bool,
reporter: ProgressDisplay,
posted_message_ids: list[int],
) -> _PublishedLanguage:
full_template = parse_markdown_template(language_assets.text_raw)
full_rendered_text = render_template(full_template, language_links)
chunk_texts = split_markdown_text_for_messages(language_assets.text_raw, link_target)
image_message_ids: list[int] = []
published_segments: list[_PublishedTextSegment] = []
if language_assets.images:
can_use_caption = use_captions and len(chunk_texts) == 1 and len(full_rendered_text) <= CAPTION_LIMIT
image_label = build_image_label(LANGUAGE_DISPLAY_NAMES[language_assets.language], len(language_assets.images))
if can_use_caption:
try:
image_message_ids = post_images(
api=api,
chat_id=chat_id,
image_paths=language_assets.images,
label=image_label,
reporter=reporter,
posted_message_ids=posted_message_ids,
caption=full_rendered_text,
)
text_label = build_text_label(language_assets.language, 1, 1)
reporter.add_step(text_label, status="done", detail="included as caption")
published_segments.append(
_PublishedTextSegment(
label=text_label,
binding=TextSegmentBinding(message_id=image_message_ids[0], mode="caption"),
parsed_template=full_template,
last_rendered=full_rendered_text,
)
)
except TelegramApiError as error:
if not is_caption_too_long_error(error):
raise
reporter.add_step(image_label, status="failed", detail="caption too long, retrying without caption")
image_message_ids = post_images(
api=api,
chat_id=chat_id,
image_paths=language_assets.images,
label=image_label,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
published_segments = post_text_segments(
api=api,
chat_id=chat_id,
language=language_assets.language,
chunk_texts=chunk_texts,
language_links=language_links,
disable_link_preview=disable_link_preview,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
else:
image_message_ids = post_images(
api=api,
chat_id=chat_id,
image_paths=language_assets.images,
label=image_label,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
published_segments = post_text_segments(
api=api,
chat_id=chat_id,
language=language_assets.language,
chunk_texts=chunk_texts,
language_links=language_links,
disable_link_preview=disable_link_preview,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
else:
published_segments = post_text_segments(
api=api,
chat_id=chat_id,
language=language_assets.language,
chunk_texts=chunk_texts,
language_links=language_links,
disable_link_preview=disable_link_preview,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
if not published_segments:
raise ValueError(f"Language '{language_assets.language.value}' produced no text segments.")
binding = LanguageBinding(
language=language_assets.language,
image_message_ids=image_message_ids,
text_segments=[segment.binding for segment in published_segments],
primary_message_id=published_segments[0].binding.message_id,
primary_message_url=build_message_url(link_target, published_segments[0].binding.message_id),
)
return _PublishedLanguage(binding=binding, segments=published_segments)
def post_images(
api: TelegramAPI,
chat_id: str,
image_paths: list[Path],
label: str,
reporter: ProgressDisplay,
posted_message_ids: list[int],
caption: str | None = None,
) -> list[int]:
step_index = reporter.add_step(label)
reporter.update(step_index, "in_progress")
result = api.send_media_group(chat_id, image_paths, caption=caption, parse_mode="HTML" if caption else None)
message_ids = [int(item["message_id"]) for item in result]
posted_message_ids.extend(message_ids)
reporter.update(step_index, "done", "with caption" if caption else "")
return message_ids
def post_text_segments(
api: TelegramAPI,
chat_id: str,
language: Language,
chunk_texts: list[str],
language_links: dict[Language, str],
disable_link_preview: bool,
reporter: ProgressDisplay,
posted_message_ids: list[int],
) -> list[_PublishedTextSegment]:
segments: list[_PublishedTextSegment] = []
for index, chunk_text in enumerate(chunk_texts, start=1):
label = build_text_label(language, index, len(chunk_texts))
step_index = reporter.add_step(label)
reporter.update(step_index, "in_progress")
parsed_template = parse_markdown_template(chunk_text)
rendered_text = render_template(parsed_template, language_links)
result = api.send_message(chat_id, rendered_text, parse_mode="HTML", disable_link_preview=disable_link_preview)
message_id = int(result["message_id"])
posted_message_ids.append(message_id)
detail = f"split part {index}/{len(chunk_texts)}" if len(chunk_texts) > 1 else ""
reporter.update(step_index, "done", detail)
segments.append(
_PublishedTextSegment(
label=label,
binding=TextSegmentBinding(message_id=message_id, mode="message"),
parsed_template=parsed_template,
last_rendered=rendered_text,
)
)
return segments
def backfill_links(
api: TelegramAPI,
chat_id: str,
published_segments: dict[Language, list[_PublishedTextSegment]],
language_links: dict[Language, str],
disable_link_previews: dict[Language, bool],
reporter: ProgressDisplay,
) -> None:
for language, segments in published_segments.items():
for segment in segments:
updated_text = render_template(segment.parsed_template, language_links)
if updated_text == segment.last_rendered:
continue
step_index = reporter.add_step(build_edit_label(segment.label))
reporter.update(step_index, "in_progress")
if segment.binding.mode == "message":
api.edit_message_text(
chat_id,
segment.binding.message_id,
updated_text,
parse_mode="HTML",
disable_link_preview=bool(disable_link_previews.get(language, False)),
)
else:
api.edit_message_caption(chat_id, segment.binding.message_id, updated_text, parse_mode="HTML")
reporter.update(step_index, "done")
segment.last_rendered = updated_text
def rollback_messages(api: TelegramAPI, chat_id: str, posted_message_ids: list[int], reporter: ProgressDisplay) -> None:
for message_id in reversed(deduplicate_message_ids(posted_message_ids)):
step_index = reporter.add_step(f"Delete message {message_id}")
reporter.update(step_index, "in_progress")
try:
api.delete_message(chat_id, message_id)
except TelegramApiError as error:
reporter.update(step_index, "failed", error.description)
continue
reporter.update(step_index, "done")
def deduplicate_message_ids(message_ids: list[int]) -> list[int]:
seen: set[int] = set()
ordered: list[int] = []
for message_id in message_ids:
if message_id in seen:
continue
seen.add(message_id)
ordered.append(message_id)
return ordered
+354
View File
@@ -0,0 +1,354 @@
import argparse
import json
from json import JSONDecodeError
from pathlib import Path
from linkki_poster.formatting import validate_markdown_syntax
from linkki_poster.models import Config, LANGUAGE_DISPLAY_NAMES, Language, LanguageAssets, ScanResult
from linkki_poster.ordering import prompt_language_order
from linkki_poster.progress import ProgressDisplay
from linkki_poster.scanner import scan_directory
from linkki_poster.telegram_api import TelegramAPI
from linkki_poster.workflow import post_assets
LANGUAGE_ORDER_ALIASES: dict[str, Language] = {
"fi": Language.FI,
"finnish": Language.FI,
"suomi": Language.FI,
"sv": Language.SV,
"swedish": Language.SV,
"svenska": Language.SV,
"ruotsi": Language.SV,
"en": Language.EN,
"english": Language.EN,
"engelska": Language.EN,
"englanti": Language.EN,
}
def run_cli(script_dir: Path | None = None, argv: list[str] | None = None, input_fn=input) -> None:
effective_script_dir = script_dir or Path(__file__).resolve().parent.parent
args = parse_args(argv)
target_directory = get_target_directory(
script_dir=effective_script_dir,
raw_directory=args.directory,
non_interactive=args.non_interactive,
input_fn=input_fn,
)
initial_scan_result = scan_directory(target_directory)
ordered_languages = determine_language_order(
initial_scan_result.languages,
args.order,
args.non_interactive,
input_fn,
)
while True:
scan_result = scan_directory(target_directory)
effective_scan_result = ScanResult(
global_images=scan_result.global_images,
languages=reapply_language_order(scan_result.languages, ordered_languages),
)
print_scan_summary(effective_scan_result)
if not effective_scan_result.global_images and not effective_scan_result.languages:
print("Nothing to post.")
return
syntax_issues = collect_syntax_issues(effective_scan_result.languages)
if not syntax_issues:
break
print_syntax_issues(syntax_issues)
if args.allow_invalid_syntax:
break
if args.non_interactive:
raise ValueError("Broken markdown syntax detected.")
action = prompt_syntax_action(input_fn)
if action == "post":
break
config = load_config(
script_dir=effective_script_dir,
non_interactive=args.non_interactive,
input_fn=input_fn,
)
api = TelegramAPI(config.bot_token)
progress = ProgressDisplay()
posted_refs = post_assets(
api=api,
config=config,
global_images=effective_scan_result.global_images,
ordered_languages=effective_scan_result.languages,
progress=progress,
)
if posted_refs:
print("Posted language messages:")
for posted_ref in posted_refs:
print(f"- {LANGUAGE_DISPLAY_NAMES[posted_ref.language]}: {posted_ref.message_url}")
def parse_args(argv: list[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Post a multilingual Telegram broadcast from one asset directory.")
parser.add_argument(
"directory",
nargs="?",
help="Directory to scan. Relative paths are resolved from the script directory.",
)
parser.add_argument(
"--order",
help="Optional language order, for example: fi,sv,en or finnish,english.",
)
parser.add_argument(
"--allow-invalid-syntax",
action="store_true",
help="Post even if supported markdown syntax appears broken.",
)
parser.add_argument(
"--non-interactive",
action="store_true",
help="Never prompt. Fail immediately when required input is missing or invalid.",
)
return parser.parse_args(argv)
def get_target_directory(
script_dir: Path,
raw_directory: str | None,
non_interactive: bool,
input_fn=input,
) -> Path:
if raw_directory is not None:
return resolve_target_directory(script_dir, raw_directory)
if non_interactive:
raise ValueError("Directory argument is required in non-interactive mode.")
return prompt_target_directory(script_dir, input_fn)
def resolve_target_directory(script_dir: Path, raw_value: str) -> Path:
candidate = Path(raw_value).expanduser()
if not candidate.is_absolute():
candidate = (script_dir / candidate).resolve()
if not candidate.is_dir():
raise FileNotFoundError(f"Directory not found: {candidate}")
return candidate
def prompt_target_directory(script_dir: Path, input_fn=input) -> Path:
while True:
raw_value = input_fn("Directory to scan: ").strip()
if raw_value == "":
print("Value cannot be empty.")
continue
try:
return resolve_target_directory(script_dir, raw_value)
except FileNotFoundError as error:
print(error)
def determine_language_order(
languages: list[LanguageAssets],
raw_order: str | None,
non_interactive: bool,
input_fn=input,
) -> list[LanguageAssets]:
if raw_order is not None:
return apply_language_order(languages, raw_order)
if non_interactive:
return languages
return prompt_language_order(languages, input_fn=input_fn)
def reapply_language_order(
current_languages: list[LanguageAssets],
ordered_template: list[LanguageAssets],
) -> list[LanguageAssets]:
current_by_language = {language_assets.language: language_assets for language_assets in current_languages}
ordered: list[LanguageAssets] = []
seen: set[Language] = set()
for language_assets in ordered_template:
current = current_by_language.get(language_assets.language)
if current is None:
continue
ordered.append(current)
seen.add(current.language)
for language_assets in current_languages:
if language_assets.language not in seen:
ordered.append(language_assets)
return ordered
def apply_language_order(languages: list[LanguageAssets], raw_order: str) -> list[LanguageAssets]:
requested_languages = parse_language_order(raw_order)
language_by_code = {language_assets.language: language_assets for language_assets in languages}
seen: set[Language] = set()
ordered: list[LanguageAssets] = []
for language in requested_languages:
if language in seen:
raise ValueError(f"Duplicate language in --order: {language.value}")
seen.add(language)
if language in language_by_code:
ordered.append(language_by_code[language])
remaining = [language_assets for language_assets in languages if language_assets.language not in seen]
return [*ordered, *remaining]
def parse_language_order(raw_order: str) -> list[Language]:
tokens = [token.strip().lower() for token in raw_order.replace(";", ",").split(",") if token.strip()]
if not tokens:
raise ValueError("Language order cannot be empty.")
parsed_languages: list[Language] = []
for token in tokens:
language = LANGUAGE_ORDER_ALIASES.get(token)
if language is None:
raise ValueError(f"Unknown language in --order: {token}")
parsed_languages.append(language)
return parsed_languages
def load_config(script_dir: Path, non_interactive: bool, input_fn=input) -> Config:
config_path = script_dir / "telegram_config.json"
config_data: dict[str, str] = {}
if config_path.exists():
raw_text = config_path.read_text(encoding="utf-8")
try:
loaded = json.loads(raw_text)
except JSONDecodeError as error:
if non_interactive:
raise ValueError(f"Invalid JSON in {config_path}: {error.msg}") from error
print(f"Invalid JSON in {config_path}: {error.msg}")
loaded = {}
if isinstance(loaded, dict):
config_data = {str(key): str(value) for key, value in loaded.items()}
elif non_interactive:
raise FileNotFoundError(f"Missing config file: {config_path}")
bot_token = resolve_config_value(
config_data=config_data,
key="bot_token",
prompt="Bot token: ",
non_interactive=non_interactive,
input_fn=input_fn,
)
chat_id = resolve_config_value(
config_data=config_data,
key="chat_id",
prompt="Chat ID: ",
non_interactive=non_interactive,
input_fn=input_fn,
)
channel_username = resolve_optional_config_value(
config_data=config_data,
key="channel_username",
prompt="Channel username (optional, for public links): ",
non_interactive=non_interactive,
input_fn=input_fn,
)
return Config(bot_token=bot_token, chat_id=chat_id, channel_username=channel_username)
def load_config_strict(script_dir: Path) -> Config:
return load_config(script_dir=script_dir, non_interactive=True)
def resolve_config_value(
config_data: dict[str, str],
key: str,
prompt: str,
non_interactive: bool,
input_fn=input,
) -> str:
existing = config_data.get(key, "").strip()
if existing:
return existing
if non_interactive:
raise ValueError(f"Config field '{key}' is required.")
while True:
entered = input_fn(prompt).strip()
if entered:
return entered
print("Value cannot be empty.")
def resolve_optional_config_value(
config_data: dict[str, str],
key: str,
prompt: str,
non_interactive: bool,
input_fn=input,
) -> str:
existing = config_data.get(key)
if existing is not None:
return existing.strip()
if non_interactive:
return ""
return input_fn(prompt).strip()
def collect_syntax_issues(languages: list[LanguageAssets]) -> dict[Language, list[str]]:
issues_by_language: dict[Language, list[str]] = {}
for language_assets in languages:
issues = validate_markdown_syntax(language_assets.text_raw)
if issues:
issues_by_language[language_assets.language] = [
f"{language_assets.text_file.name}: {issue.message}" for issue in issues
]
return issues_by_language
def print_syntax_issues(issues_by_language: dict[Language, list[str]]) -> None:
print("Potential markdown syntax issues detected:")
for language in (Language.FI, Language.SV, Language.EN):
issues = issues_by_language.get(language)
if not issues:
continue
print(f"- {LANGUAGE_DISPLAY_NAMES[language]}")
for issue in issues:
print(f" - {issue}")
def prompt_syntax_action(input_fn=input) -> str:
while True:
answer = input_fn("Post anyway or retry after fixing files? [p/r]: ").strip().lower()
if answer in {"p", "post"}:
return "post"
if answer in {"r", "retry"}:
return "retry"
print("Enter 'p' to post anyway or 'r' to retry.")
def print_scan_summary(scan_result: ScanResult) -> None:
print("Scan summary:")
if scan_result.global_images:
print("Global images:")
for image_path in scan_result.global_images:
print(f"- {image_path.name}")
else:
print("Global images: none")
if not scan_result.languages:
print("Languages: none (no language text files found)")
return
print("Languages:")
for index, language_assets in enumerate(scan_result.languages, start=1):
print(f"{index}. {LANGUAGE_DISPLAY_NAMES[language_assets.language]}")
print(f" Text: {language_assets.text_file.name}")
if language_assets.images:
print(" Images:")
for image_path in language_assets.images:
print(f" - {image_path.name}")
else:
print(" Images: none")
+271
View File
@@ -0,0 +1,271 @@
import html
import re
from dataclasses import dataclass
from linkki_poster.models import Language, PlaceholderToken
CAPTION_LIMIT = 1024
_FAKE_MESSAGE_ID = "9" * 20
@dataclass(frozen=True)
class ParsedTemplate:
html_template: str
placeholders: list[PlaceholderToken]
markers: list[str]
@dataclass(frozen=True)
class SyntaxIssue:
marker: str
message: str
def parse_markdown_template(raw_text: str) -> ParsedTemplate:
text_with_markers, placeholders, markers = extract_language_placeholders(raw_text)
html_template = markdown_subset_to_html(text_with_markers)
return ParsedTemplate(html_template=html_template, placeholders=placeholders, markers=markers)
def extract_language_placeholders(raw_text: str) -> tuple[str, list[PlaceholderToken], list[str]]:
pattern = re.compile(
r"(?<!\\)\[(?P<label>(?:\\.|[^\]\\])*)\](?<!\\)\((?P<target>fi|sv|en)\)",
re.IGNORECASE,
)
placeholders: list[PlaceholderToken] = []
markers: list[str] = []
chunks: list[str] = []
start_index = 0
for placeholder_index, match in enumerate(pattern.finditer(raw_text)):
marker = f"ZZPHTOKEN{placeholder_index}ZZ"
target_language = Language(match.group("target").lower())
placeholders.append(
PlaceholderToken(
label=match.group("label"),
target_lang=target_language,
source_span=(match.start(), match.end()),
)
)
markers.append(marker)
chunks.append(raw_text[start_index : match.start()])
chunks.append(marker)
start_index = match.end()
chunks.append(raw_text[start_index:])
return "".join(chunks), placeholders, markers
def render_template(parsed_template: ParsedTemplate, language_links: dict[Language, str]) -> str:
rendered = parsed_template.html_template
for marker, placeholder in zip(parsed_template.markers, parsed_template.placeholders):
target_url = language_links.get(placeholder.target_lang)
if target_url is None:
replacement = html.escape(placeholder.label, quote=False)
else:
escaped_url = html.escape(target_url, quote=True)
escaped_label = html.escape(placeholder.label, quote=False)
replacement = f'<a href="{escaped_url}">{escaped_label}</a>'
rendered = rendered.replace(marker, replacement)
return rendered
def render_preview_html(raw_text: str, available_targets: set[Language]) -> str:
parsed_template = parse_markdown_template(raw_text)
fake_links = {language: f"#{language.value}" for language in available_targets}
return render_template(parsed_template, fake_links)
def estimate_caption_length(parsed_template: ParsedTemplate, normalized_channel_username: str) -> int:
fake_url = f"https://t.me/{normalized_channel_username}/{_FAKE_MESSAGE_ID}"
fake_links = {
Language.FI: fake_url,
Language.SV: fake_url,
Language.EN: fake_url,
}
rendered = render_template(parsed_template, fake_links)
return len(rendered)
def markdown_subset_to_html(markdown_text: str) -> str:
text_with_escape_tokens, escape_token_values = _stash_escaped_literals(markdown_text)
text_with_code_tokens, code_token_values = _stash_code_segments(text_with_escape_tokens)
escaped_text = html.escape(text_with_code_tokens, quote=False)
escaped_text = _map_blockquotes(escaped_text)
escaped_text = _map_headings(escaped_text)
escaped_text = _map_lists(escaped_text)
escaped_text = _map_standard_links(escaped_text)
escaped_text = _map_style_pairs(escaped_text, r"\|\|([\s\S]+?)\|\|", "tg-spoiler")
escaped_text = _map_style_pairs(escaped_text, r"\*\*(.+?)\*\*", "b")
escaped_text = _map_style_pairs(escaped_text, r"__(.+?)__", "b")
escaped_text = _map_style_pairs(escaped_text, r"\+\+(.+?)\+\+", "u")
escaped_text = _map_style_pairs(escaped_text, r"~~(.+?)~~", "s")
escaped_text = _map_style_pairs(escaped_text, r"(?<!\*)\*([^*\n]+)\*(?!\*)", "i")
escaped_text = _map_style_pairs(escaped_text, r"(?<!_)_([^_\n]+)_(?!_)", "i")
for token, value in escape_token_values.items():
escaped_text = escaped_text.replace(token, value)
for token, value in code_token_values.items():
escaped_text = escaped_text.replace(token, value)
return escaped_text
def _stash_code_segments(markdown_text: str) -> tuple[str, dict[str, str]]:
token_values: dict[str, str] = {}
def replace_code_block(match: re.Match[str]) -> str:
token = f"ZZCODEBLOCK{len(token_values)}ZZ"
code = html.escape(match.group(1), quote=False)
token_values[token] = f"<pre><code>{code}</code></pre>"
return token
code_block_pattern = re.compile(r"```(?:[^\n`]*)\n?(.*?)```", re.DOTALL)
text = code_block_pattern.sub(replace_code_block, markdown_text)
def replace_inline_code(match: re.Match[str]) -> str:
token = f"ZZINLINECODE{len(token_values)}ZZ"
code = html.escape(match.group(1), quote=False)
token_values[token] = f"<code>{code}</code>"
return token
inline_code_pattern = re.compile(r"`([^`\n]+)`")
text = inline_code_pattern.sub(replace_inline_code, text)
return text, token_values
def _stash_escaped_literals(markdown_text: str) -> tuple[str, dict[str, str]]:
token_values: dict[str, str] = {}
def replace_escape(match: re.Match[str]) -> str:
token = f"ZZESCAPED{len(token_values)}ZZ"
token_values[token] = html.escape(match.group(1), quote=False)
return token
text = re.sub(r"\\([\\`*_+\-~|.\[\]()#>])", replace_escape, markdown_text)
return text, token_values
def _map_headings(text: str) -> str:
mapped_lines: list[str] = []
for line in text.splitlines():
heading_match = re.match(r"^(#{1,6})\s*(.+)$", line)
if heading_match is None:
mapped_lines.append(line)
continue
mapped_lines.append(f"<b>{heading_match.group(2).upper()}</b>")
return "\n".join(mapped_lines)
def _map_lists(text: str) -> str:
mapped_lines: list[str] = []
for line in text.splitlines():
unordered_match = re.match(r"^(?P<indent>[ \t]*)(?P<marker>[-+*])\s+(?P<content>.+)$", line)
if unordered_match is not None:
level = _indent_level(unordered_match.group("indent"))
mapped_lines.append(f"{_list_indent(level)}\u2022 {unordered_match.group('content')}")
continue
ordered_match = re.match(r"^(?P<indent>[ \t]*)(?P<number>\d+)\.\s+(?P<content>.+)$", line)
if ordered_match is not None:
level = _indent_level(ordered_match.group("indent"))
mapped_lines.append(
f"{_list_indent(level)}{ordered_match.group('number')}. {ordered_match.group('content')}"
)
continue
mapped_lines.append(line)
return "\n".join(mapped_lines)
def _map_blockquotes(text: str) -> str:
mapped_lines: list[str] = []
quote_lines: list[str] = []
def flush_quote_lines() -> None:
if not quote_lines:
return
mapped_lines.append(f"<blockquote>{'\n'.join(quote_lines)}</blockquote>")
quote_lines.clear()
for line in text.splitlines():
quote_match = re.match(r"^[ \t]*&gt;\s?(.*)$", line)
if quote_match is None:
flush_quote_lines()
mapped_lines.append(line)
continue
quote_lines.append(quote_match.group(1))
flush_quote_lines()
return "\n".join(mapped_lines)
def _indent_level(indent: str) -> int:
expanded = indent.replace("\t", " ")
return len(expanded) // 2
def _list_indent(level: int) -> str:
return "\u00a0" * (level * 4)
def _map_standard_links(text: str) -> str:
def repl(match: re.Match[str]) -> str:
label = match.group(1)
url = match.group(2)
return f'<a href="{html.escape(url, quote=True)}">{label}</a>'
return re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", repl, text)
def _map_style_pairs(text: str, pattern: str, html_tag: str) -> str:
return re.sub(pattern, rf"<{html_tag}>\1</{html_tag}>", text)
def validate_markdown_syntax(markdown_text: str) -> list[SyntaxIssue]:
issues: list[SyntaxIssue] = []
masked_text = _mask_escaped_markers(_mask_complete_code_segments(markdown_text))
if masked_text.count("```") % 2 != 0:
issues.append(SyntaxIssue(marker="```", message="Unclosed code block marker."))
if masked_text.count("`") % 2 != 0:
issues.append(SyntaxIssue(marker="`", message="Unclosed inline code marker."))
sanitized_text = _mask_escaped_markers(_remove_complete_code_segments(markdown_text))
for marker in ("**", "__", "++", "~~", "||"):
if sanitized_text.count(marker) % 2 != 0:
issues.append(SyntaxIssue(marker=marker, message=f"Unclosed or unmatched {marker} marker."))
bracket_balance = 0
for character in sanitized_text:
if character == "[":
bracket_balance += 1
elif character == "]" and bracket_balance > 0:
bracket_balance -= 1
if bracket_balance != 0:
issues.append(SyntaxIssue(marker="[]", message="Unmatched square brackets in link-like syntax."))
parenthesis_balance = 0
for character in sanitized_text:
if character == "(":
parenthesis_balance += 1
elif character == ")" and parenthesis_balance > 0:
parenthesis_balance -= 1
if parenthesis_balance != 0:
issues.append(SyntaxIssue(marker="()", message="Unmatched parentheses in link-like syntax."))
return issues
def _mask_complete_code_segments(markdown_text: str) -> str:
text = re.sub(r"```(?:[^\n`]*)\n?(.*?)```", "", markdown_text, flags=re.DOTALL)
return re.sub(r"`([^`\n]+)`", "", text)
def _remove_complete_code_segments(markdown_text: str) -> str:
text = re.sub(r"```(?:[^\n`]*)\n?(.*?)```", "", markdown_text, flags=re.DOTALL)
return re.sub(r"`([^`\n]+)`", "", text)
def _mask_escaped_markers(markdown_text: str) -> str:
return re.sub(r"\\([\\`*_+\-~|.\[\]()#>])", "", markdown_text)
+82
View File
@@ -0,0 +1,82 @@
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Literal
class Language(str, Enum):
FI = "fi"
SV = "sv"
EN = "en"
LANGUAGE_DISPLAY_NAMES: dict[Language, str] = {
Language.FI: "Finnish",
Language.SV: "Swedish",
Language.EN: "English",
}
@dataclass(frozen=True)
class Config:
bot_token: str
chat_id: str
channel_username: str
discussion_group_id: str = ""
@dataclass(frozen=True)
class LanguageAssets:
language: Language
text_file: Path
text_raw: str
images: list[Path]
@dataclass(frozen=True)
class ScanResult:
global_images: list[Path]
languages: list[LanguageAssets]
@dataclass(frozen=True)
class PlaceholderToken:
label: str
target_lang: Language
source_span: tuple[int, int]
@dataclass
class PostedMessageRef:
language: Language
text_message_id: int | None
caption_message_id: int | None
message_url: str
had_placeholders: bool
text_mode: Literal["message", "caption", "none"]
@dataclass(frozen=True)
class TextSegmentBinding:
message_id: int
mode: Literal["message", "caption"]
@dataclass(frozen=True)
class LanguageBinding:
language: Language
image_message_ids: list[int]
text_segments: list[TextSegmentBinding]
primary_message_id: int | None
primary_message_url: str
discussion_message_id: int | None = None
@dataclass(frozen=True)
class PublishBinding:
global_message_ids: list[int]
global_attached_to_language: Language | None
languages: dict[Language, LanguageBinding]
discussion_message_ids: dict[int, int]
global_primary_message_id: int | None = None
global_discussion_message_id: int | None = None
+35
View File
@@ -0,0 +1,35 @@
import re
from linkki_poster.models import LANGUAGE_DISPLAY_NAMES, LanguageAssets
def prompt_language_order(languages: list[LanguageAssets], input_fn=input) -> list[LanguageAssets]:
if len(languages) <= 1:
return languages
print("Language order:")
for index, language_assets in enumerate(languages, start=1):
print(f"{index}. {LANGUAGE_DISPLAY_NAMES[language_assets.language]}")
expected = list(range(1, len(languages) + 1))
while True:
raw = input_fn("Enter language order: ")
if raw.strip() == "":
return languages
parsed = parse_order_input(raw, len(languages))
if parsed is None:
print(f"Invalid order. Press Enter for default order {expected}, or enter digits {expected}.")
continue
return [languages[index - 1] for index in parsed]
def parse_order_input(raw_text: str, language_count: int) -> list[int] | None:
digits = [int(item) for item in re.findall(r"\d", raw_text)]
if len(digits) != language_count:
return None
expected = set(range(1, language_count + 1))
if set(digits) != expected:
return None
return digits
+87
View File
@@ -0,0 +1,87 @@
import sys
from dataclasses import dataclass
@dataclass
class ProgressStep:
label: str
status: str = "pending"
detail: str = ""
class ProgressDisplay:
def __init__(self, stream=None):
self.stream = stream or sys.stdout
self.steps: list[ProgressStep] = []
self._rendered_line_count = 0
self._supports_rewrite = hasattr(self.stream, "isatty") and self.stream.isatty()
def add_step(self, label: str, status: str = "pending", detail: str = "") -> int:
self.steps.append(ProgressStep(label=label, status=status, detail=detail))
self._render()
return len(self.steps) - 1
def update(self, step_index: int, status: str, detail: str = "") -> None:
step = self.steps[step_index]
step.status = status
step.detail = detail
self._render()
def insert_step(self, index: int, label: str, status: str = "pending", detail: str = "") -> int:
self.steps.insert(index, ProgressStep(label=label, status=status, detail=detail))
self._render()
return index
def _render(self) -> None:
lines = [format_step(step) for step in self.steps]
output = "\n".join(lines)
if output:
output += "\n"
if self._supports_rewrite and self._rendered_line_count:
self.stream.write(f"\x1b[{self._rendered_line_count}A")
for _ in range(self._rendered_line_count):
self.stream.write("\x1b[2K\x1b[1B")
self.stream.write(f"\x1b[{self._rendered_line_count}A")
self.stream.write(output)
self.stream.flush()
self._rendered_line_count = len(lines)
class LogProgressDisplay:
def __init__(self, on_change=None):
self.steps: list[ProgressStep] = []
self.on_change = on_change
def add_step(self, label: str, status: str = "pending", detail: str = "") -> int:
self.steps.append(ProgressStep(label=label, status=status, detail=detail))
self._notify()
return len(self.steps) - 1
def update(self, step_index: int, status: str, detail: str = "") -> None:
step = self.steps[step_index]
step.status = status
step.detail = detail
self._notify()
def get_output(self) -> str:
return "\n".join(format_step(step) for step in self.steps)
def _notify(self) -> None:
if self.on_change is not None:
self.on_change(self.get_output())
def format_step(step: ProgressStep) -> str:
prefix_by_status = {
"pending": "[ ]",
"in_progress": "[>]",
"done": "[x]",
"failed": "[!]",
"skipped": "[-]",
}
prefix = prefix_by_status.get(step.status, "[?]")
if step.detail:
return f"{prefix} {step.label} - {step.detail}"
return f"{prefix} {step.label}"
+110
View File
@@ -0,0 +1,110 @@
import re
from pathlib import Path
from linkki_poster.models import Language, LanguageAssets, ScanResult
KNOWN_IMAGE_EXTENSIONS = {
"jpg",
"jpeg",
"png",
"webp",
"gif",
}
GLOBAL_IMAGE_PREFIXES = ["Kuva"]
LANGUAGE_TEXT_CANDIDATES: dict[Language, list[str]] = {
Language.FI: ["Suomi.md"],
Language.SV: ["Svenska.md"],
Language.EN: ["English.md"],
}
LANGUAGE_IMAGE_PREFIXES: dict[Language, list[str]] = {
Language.FI: ["Suomi"],
Language.SV: ["Svenska"],
Language.EN: ["English"],
}
def scan_directory(directory: Path) -> ScanResult:
files = sorted((entry for entry in directory.iterdir() if entry.is_file()), key=lambda path: path.name.lower())
casefold_name_index = _build_casefold_name_index(files)
global_images = select_first_matching_image_family(files, GLOBAL_IMAGE_PREFIXES)
language_assets: list[LanguageAssets] = []
for language in (Language.FI, Language.SV, Language.EN):
text_file = select_first_matching_text_file(casefold_name_index, LANGUAGE_TEXT_CANDIDATES[language])
if text_file is None:
continue
text_raw = text_file.read_text(encoding="utf-8")
if text_raw.strip() == "":
continue
language_images = select_first_matching_image_family(files, LANGUAGE_IMAGE_PREFIXES[language])
language_assets.append(
LanguageAssets(
language=language,
text_file=text_file,
text_raw=text_raw,
images=language_images,
)
)
return ScanResult(global_images=global_images, languages=language_assets)
def select_first_matching_text_file(casefold_name_index: dict[str, Path], candidate_names: list[str]) -> Path | None:
for candidate_name in candidate_names:
match = casefold_name_index.get(candidate_name.lower())
if match is not None:
return match
return None
def select_first_matching_image_family(files: list[Path], prefixes: list[str]) -> list[Path]:
for prefix in prefixes:
matches = collect_image_matches_for_prefix(files, prefix)
if matches:
return [match[0] for match in sorted(matches, key=image_sort_key)]
return []
def collect_image_matches_for_prefix(files: list[Path], prefix: str) -> list[tuple[Path, str, str]]:
pattern = re.compile(rf"^{re.escape(prefix)}(\d*)\.([^.]+)$", re.IGNORECASE)
matches: list[tuple[Path, str, str]] = []
for file_path in files:
match = pattern.match(file_path.name)
if match is None:
continue
extension = match.group(2).lower()
if extension not in KNOWN_IMAGE_EXTENSIONS:
continue
numeric_suffix = match.group(1)
matches.append((file_path, numeric_suffix, extension))
return matches
def image_sort_key(match_item: tuple[Path, str, str]) -> tuple[int, int, str, str]:
file_path, numeric_suffix, extension = match_item
has_numeric_suffix = numeric_suffix != ""
numeric_value = int(numeric_suffix) if has_numeric_suffix else -1
return (
1 if has_numeric_suffix else 0,
numeric_value,
extension.lower(),
file_path.name.lower(),
)
def _build_casefold_name_index(files: list[Path]) -> dict[str, Path]:
index: dict[str, Path] = {}
for file_path in files:
lowered = file_path.name.lower()
if lowered not in index:
index[lowered] = file_path
return index
@@ -0,0 +1,186 @@
import json
from contextlib import ExitStack
from pathlib import Path
import requests
class TelegramApiError(RuntimeError):
def __init__(self, description: str, error_code: int | None = None):
super().__init__(description)
self.description = description
self.error_code = error_code
class TelegramAPI:
def __init__(self, bot_token: str):
self.base_url = f"https://api.telegram.org/bot{bot_token}"
def send_media_group(
self,
chat_id: str,
image_paths: list[Path],
caption: str | None = None,
parse_mode: str | None = None,
) -> list[dict]:
if not image_paths:
raise ValueError("send_media_group requires at least one image.")
media_items = []
for index, _ in enumerate(image_paths):
media_item = {
"type": "photo",
"media": f"attach://file{index}",
}
if index == 0 and caption is not None:
media_item["caption"] = caption
if parse_mode is not None:
media_item["parse_mode"] = parse_mode
media_items.append(media_item)
payload = {"chat_id": chat_id, "media": json.dumps(media_items, ensure_ascii=False)}
with ExitStack() as stack:
files = {}
for index, image_path in enumerate(image_paths):
files[f"file{index}"] = stack.enter_context(image_path.open("rb"))
return self._post("sendMediaGroup", data=payload, files=files, timeout=60)
def get_updates(self, offset: int | None = None, limit: int = 100, timeout: int = 0) -> list[dict]:
payload = {"limit": limit, "timeout": timeout}
if offset is not None:
payload["offset"] = offset
return self._post("getUpdates", data=payload, timeout=timeout + 10)
def send_message(
self,
chat_id: str,
text: str,
parse_mode: str | None = None,
disable_link_preview: bool = False,
) -> dict:
payload = {"chat_id": chat_id, "text": text}
if parse_mode is not None:
payload["parse_mode"] = parse_mode
if disable_link_preview:
payload["link_preview_options"] = json.dumps({"is_disabled": True})
return self._post("sendMessage", data=payload, timeout=30)
def edit_message_text(
self,
chat_id: str,
message_id: int,
text: str,
parse_mode: str | None = None,
disable_link_preview: bool = False,
) -> dict:
payload = {
"chat_id": chat_id,
"message_id": message_id,
"text": text,
}
if parse_mode is not None:
payload["parse_mode"] = parse_mode
if disable_link_preview:
payload["link_preview_options"] = json.dumps({"is_disabled": True})
return self._post("editMessageText", data=payload, timeout=30)
def edit_message_caption(
self,
chat_id: str,
message_id: int,
caption: str,
parse_mode: str | None = None,
) -> dict:
payload = {
"chat_id": chat_id,
"message_id": message_id,
"caption": caption,
}
if parse_mode is not None:
payload["parse_mode"] = parse_mode
return self._post("editMessageCaption", data=payload, timeout=30)
def delete_message(self, chat_id: str, message_id: int) -> bool:
payload = {
"chat_id": chat_id,
"message_id": message_id,
}
return bool(self._post("deleteMessage", data=payload, timeout=30))
def pin_chat_message(self, chat_id: str, message_id: int, disable_notification: bool = True) -> bool:
payload = {
"chat_id": chat_id,
"message_id": message_id,
}
if disable_notification:
payload["disable_notification"] = "true"
return bool(self._post("pinChatMessage", data=payload, timeout=30))
def unpin_chat_message(self, chat_id: str, message_id: int) -> bool:
payload = {
"chat_id": chat_id,
"message_id": message_id,
}
return bool(self._post("unpinChatMessage", data=payload, timeout=30))
def _post(self, endpoint: str, data: dict, files: dict | None = None, timeout: int = 30):
response = requests.post(f"{self.base_url}/{endpoint}", data=data, files=files, timeout=timeout)
try:
response.raise_for_status()
except requests.HTTPError as error:
description = _extract_error_description(response) or str(error)
error_code = _extract_error_code(response)
raise TelegramApiError(description=description, error_code=error_code) from error
payload = response.json()
if not payload.get("ok", False):
raise TelegramApiError(
description=str(payload.get("description", "Telegram API request failed.")),
error_code=_to_int_or_none(payload.get("error_code")),
)
return payload["result"]
def _extract_error_description(response: requests.Response) -> str | None:
try:
payload = response.json()
except ValueError:
return None
description = payload.get("description")
if description is None:
return None
return str(description)
def _extract_error_code(response: requests.Response) -> int | None:
try:
payload = response.json()
except ValueError:
return None
return _to_int_or_none(payload.get("error_code"))
def _to_int_or_none(value) -> int | None:
if isinstance(value, int):
return value
if isinstance(value, str) and value.isdigit():
return int(value)
return None
def is_message_missing_error(error: TelegramApiError | str) -> bool:
description = error.description if isinstance(error, TelegramApiError) else str(error)
lowered = description.lower()
return "not found" in lowered or "message to delete not found" in lowered or "message to edit not found" in lowered
def is_message_not_modified_error(error: TelegramApiError | str) -> bool:
description = error.description if isinstance(error, TelegramApiError) else str(error)
return "message is not modified" in description.lower()
def is_message_not_deletable_error(error: TelegramApiError | str) -> bool:
description = error.description if isinstance(error, TelegramApiError) else str(error)
return "can't be deleted" in description.lower()
+539
View File
@@ -0,0 +1,539 @@
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from linkki_poster.formatting import (
CAPTION_LIMIT,
ParsedTemplate,
estimate_caption_length,
parse_markdown_template,
render_template,
)
from linkki_poster.models import Config, LANGUAGE_DISPLAY_NAMES, Language, LanguageAssets, PostedMessageRef
from linkki_poster.progress import ProgressDisplay
from linkki_poster.telegram_api import TelegramAPI, TelegramApiError
MESSAGE_LIMIT = 4096
_FAKE_MESSAGE_ID = "9" * 20
@dataclass
class _TextSegmentState:
label: str
message_id: int | None
mode: Literal["message", "caption"]
parsed_template: ParsedTemplate
last_rendered: str
@dataclass
class _PostState:
posted_ref: PostedMessageRef
text_segments: list[_TextSegmentState]
@dataclass(frozen=True)
class _RuntimeChunkPlan:
raw_text: str
parsed_template: ParsedTemplate
label: str
has_placeholders: bool
@dataclass
class _LanguageRuntimePlan:
language_assets: LanguageAssets
full_template: ParsedTemplate
full_rendered_text: str
has_text: bool
can_use_caption: bool
chunks: list[_RuntimeChunkPlan]
def post_assets(
api: TelegramAPI,
config: Config,
global_images: list[Path],
ordered_languages: list[LanguageAssets],
progress: ProgressDisplay | None = None,
) -> list[PostedMessageRef]:
reporter = progress or ProgressDisplay()
leading_global_images, effective_languages = resolve_global_image_strategy(global_images, ordered_languages)
normalized_username = normalize_channel_username(config.channel_username)
link_target = resolve_link_target(config.chat_id, normalized_username)
runtime_plans = build_runtime_plans(effective_languages, link_target)
posted_message_ids: list[int] = []
post_states: list[_PostState] = []
language_links: dict[Language, str] = {}
try:
if leading_global_images:
execute_image_post(
api=api,
chat_id=config.chat_id,
image_paths=leading_global_images,
label=build_image_label("global", len(leading_global_images)),
reporter=reporter,
posted_message_ids=posted_message_ids,
)
for runtime_plan in runtime_plans:
post_state = execute_language_plan(
api=api,
config=config,
runtime_plan=runtime_plan,
reporter=reporter,
posted_message_ids=posted_message_ids,
language_links=language_links,
link_target=link_target,
)
post_states.append(post_state)
language_links[post_state.posted_ref.language] = post_state.posted_ref.message_url
execute_final_backfill(api, config.chat_id, post_states, language_links, reporter)
return [post_state.posted_ref for post_state in post_states]
except Exception as error:
rollback_posted_messages(api, config.chat_id, posted_message_ids, reporter)
raise error
def build_runtime_plans(ordered_languages: list[LanguageAssets], link_target: str) -> list[_LanguageRuntimePlan]:
runtime_plans: list[_LanguageRuntimePlan] = []
for language_assets in ordered_languages:
full_template = parse_markdown_template(language_assets.text_raw)
full_rendered_text = render_template(full_template, {})
has_text = full_rendered_text.strip() != ""
split_chunks = split_markdown_text_for_messages(language_assets.text_raw, link_target) if has_text else []
chunk_plans = []
for index, chunk_text in enumerate(split_chunks, start=1):
parsed_chunk = parse_markdown_template(chunk_text)
chunk_plans.append(
_RuntimeChunkPlan(
raw_text=chunk_text,
parsed_template=parsed_chunk,
label=build_text_label(language_assets.language, index, len(split_chunks)),
has_placeholders=bool(parsed_chunk.placeholders),
)
)
can_use_caption = (
bool(language_assets.images)
and has_text
and len(chunk_plans) == 1
and estimate_caption_length(full_template, link_target) <= CAPTION_LIMIT
)
runtime_plans.append(
_LanguageRuntimePlan(
language_assets=language_assets,
full_template=full_template,
full_rendered_text=full_rendered_text,
has_text=has_text,
can_use_caption=can_use_caption,
chunks=chunk_plans,
)
)
return runtime_plans
def execute_language_plan(
api: TelegramAPI,
config: Config,
runtime_plan: _LanguageRuntimePlan,
reporter: ProgressDisplay,
posted_message_ids: list[int],
language_links: dict[Language, str],
link_target: str,
) -> _PostState:
language_assets = runtime_plan.language_assets
language_name = LANGUAGE_DISPLAY_NAMES[language_assets.language]
text_segments: list[_TextSegmentState] = []
text_mode: Literal["message", "caption", "none"] = "none"
text_message_id: int | None = None
caption_message_id: int | None = None
if language_assets.images:
image_label = build_image_label(language_name, len(language_assets.images))
if runtime_plan.can_use_caption:
only_chunk = runtime_plan.chunks[0]
try:
message_ids = execute_image_post(
api=api,
chat_id=config.chat_id,
image_paths=language_assets.images,
label=image_label,
reporter=reporter,
posted_message_ids=posted_message_ids,
caption=render_template(runtime_plan.full_template, language_links),
)
caption_message_id = message_ids[0]
text_mode = "caption"
text_step_index = reporter.add_step(only_chunk.label, status="done", detail="included as caption")
text_segments.append(
_TextSegmentState(
label=only_chunk.label,
message_id=caption_message_id,
mode="caption",
parsed_template=runtime_plan.full_template,
last_rendered=render_template(runtime_plan.full_template, language_links),
)
)
except TelegramApiError as error:
if not is_caption_too_long_error(error):
raise
reporter.add_step(image_label, status="failed", detail="caption too long, retrying without caption")
message_ids = execute_image_post(
api=api,
chat_id=config.chat_id,
image_paths=language_assets.images,
label=image_label,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
if runtime_plan.has_text:
segments, first_message_id = execute_text_chunks(
api=api,
chat_id=config.chat_id,
chunks=runtime_plan.chunks,
reporter=reporter,
posted_message_ids=posted_message_ids,
language_links=language_links,
)
text_segments.extend(segments)
text_message_id = first_message_id
text_mode = "message"
caption_message_id = None
else:
message_ids = execute_image_post(
api=api,
chat_id=config.chat_id,
image_paths=language_assets.images,
label=image_label,
reporter=reporter,
posted_message_ids=posted_message_ids,
)
if runtime_plan.has_text:
segments, first_message_id = execute_text_chunks(
api=api,
chat_id=config.chat_id,
chunks=runtime_plan.chunks,
reporter=reporter,
posted_message_ids=posted_message_ids,
language_links=language_links,
)
text_segments.extend(segments)
text_message_id = first_message_id
text_mode = "message"
elif runtime_plan.has_text:
segments, first_message_id = execute_text_chunks(
api=api,
chat_id=config.chat_id,
chunks=runtime_plan.chunks,
reporter=reporter,
posted_message_ids=posted_message_ids,
language_links=language_links,
)
text_segments.extend(segments)
text_message_id = first_message_id
text_mode = "message"
else:
raise ValueError(f"Language '{language_assets.language.value}' has neither text nor images.")
primary_message_id = caption_message_id or text_message_id
if primary_message_id is None:
raise ValueError(f"Language '{language_name}' did not produce a primary message.")
message_url = build_message_url(link_target, primary_message_id)
posted_ref = PostedMessageRef(
language=language_assets.language,
text_message_id=text_message_id,
caption_message_id=caption_message_id,
message_url=message_url,
had_placeholders=any(bool(segment.parsed_template.placeholders) for segment in text_segments),
text_mode=text_mode,
)
return _PostState(posted_ref=posted_ref, text_segments=text_segments)
def execute_image_post(
api: TelegramAPI,
chat_id: str,
image_paths: list[Path],
label: str,
reporter: ProgressDisplay,
posted_message_ids: list[int],
caption: str | None = None,
) -> list[int]:
step_index = reporter.add_step(label)
reporter.update(step_index, "in_progress")
response_items = api.send_media_group(
chat_id,
image_paths,
caption=caption,
parse_mode="HTML" if caption is not None else None,
)
message_ids = [int(item["message_id"]) for item in response_items]
posted_message_ids.extend(message_ids)
detail = "with caption" if caption is not None else ""
reporter.update(step_index, "done", detail)
return message_ids
def execute_text_chunks(
api: TelegramAPI,
chat_id: str,
chunks: list[_RuntimeChunkPlan],
reporter: ProgressDisplay,
posted_message_ids: list[int],
language_links: dict[Language, str],
) -> tuple[list[_TextSegmentState], int]:
segments: list[_TextSegmentState] = []
first_message_id: int | None = None
for chunk in chunks:
post_step_index = reporter.add_step(chunk.label)
reporter.update(post_step_index, "in_progress")
rendered_text = render_template(chunk.parsed_template, language_links)
detail = ""
if len(chunks) > 1:
detail = chunk.label.replace("Post ", "").replace(" text part ", " split part ")
sent_message = api.send_message(chat_id, rendered_text, parse_mode="HTML")
message_id = int(sent_message["message_id"])
posted_message_ids.append(message_id)
reporter.update(post_step_index, "done", detail)
if first_message_id is None:
first_message_id = message_id
segments.append(
_TextSegmentState(
label=chunk.label,
message_id=message_id,
mode="message",
parsed_template=chunk.parsed_template,
last_rendered=rendered_text,
)
)
if first_message_id is None:
raise ValueError("Expected at least one text message segment.")
return segments, first_message_id
def execute_final_backfill(
api: TelegramAPI,
chat_id: str,
post_states: list[_PostState],
language_links: dict[Language, str],
reporter: ProgressDisplay,
) -> None:
for post_state in post_states:
for text_segment in post_state.text_segments:
if not text_segment.parsed_template.placeholders:
continue
updated_text = render_template(text_segment.parsed_template, language_links)
if updated_text == text_segment.last_rendered:
continue
edit_step_index = reporter.add_step(build_edit_label(text_segment.label))
reporter.update(edit_step_index, "in_progress")
if text_segment.message_id is None:
raise ValueError("Missing message id for edit step.")
if text_segment.mode == "message":
api.edit_message_text(chat_id, text_segment.message_id, updated_text, parse_mode="HTML")
else:
api.edit_message_caption(chat_id, text_segment.message_id, updated_text, parse_mode="HTML")
reporter.update(edit_step_index, "done")
text_segment.last_rendered = updated_text
def rollback_posted_messages(
api: TelegramAPI,
chat_id: str,
posted_message_ids: list[int],
reporter: ProgressDisplay,
) -> None:
for message_id in reversed(posted_message_ids):
step_index = reporter.add_step(f"Delete message {message_id}")
reporter.update(step_index, "in_progress")
try:
api.delete_message(chat_id, message_id)
except TelegramApiError as error:
reporter.update(step_index, "failed", error.description)
continue
reporter.update(step_index, "done")
def normalize_channel_username(channel_username: str) -> str:
return channel_username.strip().lstrip("@")
def build_message_url(link_target: str, message_id: int) -> str:
return f"https://t.me/{link_target}/{message_id}"
def resolve_global_image_strategy(
global_images: list[Path],
ordered_languages: list[LanguageAssets],
) -> tuple[list[Path], list[LanguageAssets]]:
if not global_images:
return [], ordered_languages
if not ordered_languages:
return global_images, ordered_languages
first_language = ordered_languages[0]
if first_language.images:
return global_images, ordered_languages
with_attached_globals = LanguageAssets(
language=first_language.language,
text_file=first_language.text_file,
text_raw=first_language.text_raw,
images=global_images,
)
return [], [with_attached_globals, *ordered_languages[1:]]
def is_caption_too_long_error(error: TelegramApiError) -> bool:
return "caption is too long" in error.description.lower()
def split_markdown_text_for_messages(
markdown_text: str,
link_target: str,
message_limit: int = MESSAGE_LIMIT,
) -> list[str]:
if markdown_text == "":
return []
chunks: list[str] = []
remaining = markdown_text
while remaining:
fit_index = find_max_fitting_prefix_index(remaining, link_target, message_limit)
if fit_index == len(remaining):
chunks.append(remaining)
break
split_index = choose_split_index(remaining, fit_index)
if split_index <= 0:
split_index = fit_index
chunks.append(remaining[:split_index])
remaining = remaining[split_index:]
return [chunk for chunk in chunks if chunk != ""]
def find_max_fitting_prefix_index(markdown_text: str, link_target: str, message_limit: int) -> int:
cached_lengths: dict[int, int] = {}
def measured_length(end_index: int) -> int:
if end_index not in cached_lengths:
cached_lengths[end_index] = estimate_rendered_message_length(markdown_text[:end_index], link_target)
return cached_lengths[end_index]
if measured_length(len(markdown_text)) <= message_limit:
return len(markdown_text)
low = 1
high = len(markdown_text)
best = 0
while low <= high:
middle = (low + high) // 2
if measured_length(middle) <= message_limit:
best = middle
low = middle + 1
else:
high = middle - 1
if best == 0:
raise ValueError("Unable to split message content into Telegram-compatible chunks.")
return best
def choose_split_index(markdown_text: str, max_index: int) -> int:
double_newline_match = find_last_break_end(markdown_text, re.compile(r"\n{2,}"), max_index)
if double_newline_match is not None:
return double_newline_match
single_newline_index = markdown_text.rfind("\n", 0, max_index)
if single_newline_index != -1:
return single_newline_index + 1
whitespace_index = find_last_whitespace_break(markdown_text, max_index)
if whitespace_index is not None:
return whitespace_index
return max_index
def find_last_break_end(markdown_text: str, pattern: re.Pattern[str], max_index: int) -> int | None:
last_end: int | None = None
for match in pattern.finditer(markdown_text):
if match.end() > max_index:
break
last_end = match.end()
return last_end
def find_last_whitespace_break(markdown_text: str, max_index: int) -> int | None:
for index in range(max_index - 1, -1, -1):
if markdown_text[index] in {" ", "\t"}:
return index + 1
return None
def estimate_rendered_message_length(markdown_text: str, link_target: str) -> int:
parsed_template = parse_markdown_template(markdown_text)
fake_url = f"https://t.me/{link_target}/{_FAKE_MESSAGE_ID}"
fake_links = {
Language.FI: fake_url,
Language.SV: fake_url,
Language.EN: fake_url,
}
rendered_text = render_template(parsed_template, fake_links)
return len(rendered_text)
def resolve_link_target(chat_id: str, normalized_username: str = "") -> str:
if normalized_username != "":
return normalized_username
internal_chat_id = extract_internal_chat_id(chat_id)
if internal_chat_id is None:
raise ValueError(
"Cannot generate message links without channel_username or a channel-style chat_id (-100...)."
)
return f"c/{internal_chat_id}"
def extract_internal_chat_id(chat_id: str) -> str | None:
stripped = chat_id.strip()
if not stripped.startswith("-100"):
return None
suffix = stripped[4:]
if suffix == "" or not suffix.isdigit():
return None
return suffix
def build_image_label(target: str, image_count: int) -> str:
noun = "image" if image_count == 1 else "images"
return f"Post {image_count} {target} {noun}"
def build_text_label(language: Language, chunk_index: int, chunk_count: int) -> str:
language_name = LANGUAGE_DISPLAY_NAMES[language]
if chunk_count == 1:
return f"Post {language_name} text"
return f"Post {language_name} text part {chunk_index}/{chunk_count}"
def build_edit_label(text_label: str) -> str:
if text_label.startswith("Post "):
return "Edit " + text_label[5:]
return "Edit " + text_label
+8
View File
@@ -0,0 +1,8 @@
from pathlib import Path
import sys
from linkki_poster.cli import run_cli
if __name__ == "__main__":
run_cli(script_dir=Path(__file__).resolve().parent, argv=sys.argv[1:])
+49
View File
@@ -0,0 +1,49 @@
import argparse
import json
from pathlib import Path
from linkki_poster.broadcast_ops import delete_binding_from_telegram, update_texts
from linkki_poster.models import Config, Language
from linkki_poster.progress import ProgressDisplay
from webapp.storage import get_binding, load_metadata
def main() -> None:
parser = argparse.ArgumentParser(description="Advanced broadcast management helper.")
parser.add_argument("directory", help="Broadcast directory")
parser.add_argument("action", choices=["update-texts", "delete-telegram"])
parser.add_argument("--languages", default="fi,sv,en", help="Comma-separated languages for update-texts")
parser.add_argument("--config", default="telegram_config.json", help="Path to Telegram config JSON")
args = parser.parse_args()
config = load_config(Path(args.config))
post_dir = Path(args.directory).resolve()
metadata = load_metadata(post_dir)
binding = get_binding(metadata)
if binding is None:
raise ValueError("Broadcast is not linked to Telegram.")
progress = ProgressDisplay()
if args.action == "delete-telegram":
delete_binding_from_telegram(config, binding, progress)
return
changed_languages = {
Language(language.strip())
for language in args.languages.split(",")
if language.strip()
}
update_texts(post_dir, config, binding, changed_languages, progress)
def load_config(config_path: Path) -> Config:
payload = json.loads(config_path.read_text(encoding="utf-8"))
return Config(
bot_token=str(payload["bot_token"]).strip(),
chat_id=str(payload["chat_id"]).strip(),
channel_username=str(payload.get("channel_username", "")).strip(),
)
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
from pathlib import Path
import sys
from linkki_poster.cli import run_cli
if __name__ == "__main__":
run_cli(script_dir=Path(__file__).resolve().parent, argv=sys.argv[1:])
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pytest
+6
View File
@@ -0,0 +1,6 @@
requests
fastapi
uvicorn
jinja2
python-multipart
itsdangerous
+12
View File
@@ -0,0 +1,12 @@
import sys
import types
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
sys.modules.setdefault(
"requests",
types.SimpleNamespace(HTTPError=RuntimeError, Response=object, post=lambda *args, **kwargs: None),
)
@@ -0,0 +1,25 @@
from webapp.app import parse_accept_language, resolve_effective_ui_language
class _Request:
def __init__(self, header: str):
self.headers = {"accept-language": header}
def test_parse_accept_language_collects_supported_languages_in_browser_order() -> None:
assert parse_accept_language("en-US,en;q=0.9,fi;q=0.8,sv;q=0.7") == ["en", "fi", "sv"]
def test_resolve_effective_ui_language_prefers_finnish_then_swedish_then_english() -> None:
request = _Request("en-US,en;q=0.9,sv;q=0.8,fi;q=0.7")
assert resolve_effective_ui_language(request, "auto") == "fi"
def test_resolve_effective_ui_language_falls_back_to_english_when_no_supported_match() -> None:
request = _Request("de-DE,de;q=0.9,fr;q=0.8")
assert resolve_effective_ui_language(request, "auto") == "en"
def test_resolve_effective_ui_language_keeps_explicit_language() -> None:
request = _Request("fi,sv,en")
assert resolve_effective_ui_language(request, "sv") == "sv"
+202
View File
@@ -0,0 +1,202 @@
import json
from pathlib import Path
from webapp.auth import load_users, set_user_language
def test_load_users_defaults_permissions_to_false(tmp_path: Path) -> None:
users_file = tmp_path / "users.json"
users_file.write_text(
"""
{
"users": [
{
"username": "viewer",
"password_hash": "hash"
}
]
}
""",
encoding="utf-8",
)
users = load_users(users_file)
assert users["viewer"]["password_hash"] == "hash"
assert all(value is False for value in users["viewer"]["permissions"].values())
assert users["viewer"]["language"] == "en"
saved_payload = json.loads(users_file.read_text(encoding="utf-8"))
saved_permissions = saved_payload["users"][0]["permissions"]
assert all(saved_permissions[key] is False for key in saved_permissions)
def test_load_users_accepts_bool_like_permission_values(tmp_path: Path) -> None:
users_file = tmp_path / "users.json"
users_file.write_text(
"""
{
"users": [
{
"username": "editor",
"password_hash": "hash",
"permissions": {
"edit_content": 1,
"edit_targets": 1,
"save_announcements": 1,
"open_telegram": 1,
"post_now": "true",
"schedule": "yes",
"unschedule": true
}
}
]
}
""",
encoding="utf-8",
)
users = load_users(users_file)
permissions = users["editor"]["permissions"]
assert permissions["edit_content"] is True
assert permissions["edit_targets"] is True
assert permissions["save_announcements"] is True
assert permissions["open_telegram"] is True
assert permissions["post_now"] is True
assert permissions["schedule"] is True
assert permissions["unschedule"] is True
assert permissions["unlink_telegram"] is False
def test_set_user_language_updates_users_file(tmp_path: Path) -> None:
users_file = tmp_path / "users.json"
users_file.write_text(
"""
{
"users": [
{
"username": "editor",
"password_hash": "hash",
"permissions": {}
}
]
}
""",
encoding="utf-8",
)
set_user_language(users_file, "editor", "fi")
users = load_users(users_file)
assert users["editor"]["language"] == "fi"
def test_load_users_accepts_auto_language(tmp_path: Path) -> None:
users_file = tmp_path / "users.json"
users_file.write_text(
"""
{
"users": [
{
"username": "auto_user",
"password_hash": "hash",
"language": "auto"
}
]
}
""",
encoding="utf-8",
)
users = load_users(users_file)
assert users["auto_user"]["language"] == "auto"
def test_set_user_language_can_store_auto(tmp_path: Path) -> None:
users_file = tmp_path / "users.json"
users_file.write_text(
"""
{
"users": [
{
"username": "editor",
"password_hash": "hash",
"permissions": {}
}
]
}
""",
encoding="utf-8",
)
set_user_language(users_file, "editor", "auto")
users = load_users(users_file)
assert users["editor"]["language"] == "auto"
def test_load_users_adds_missing_permissions_to_existing_permissions_block(tmp_path: Path) -> None:
users_file = tmp_path / "users.json"
users_file.write_text(
"""
{
"users": [
{
"username": "editor",
"password_hash": "hash",
"permissions": {
"edit_content": true
}
}
]
}
""",
encoding="utf-8",
)
users = load_users(users_file)
assert users["editor"]["permissions"]["edit_content"] is True
assert users["editor"]["permissions"]["edit_targets"] is False
assert users["editor"]["permissions"]["save_announcements"] is False
assert users["editor"]["permissions"]["open_telegram"] is False
saved_payload = json.loads(users_file.read_text(encoding="utf-8"))
saved_permissions = saved_payload["users"][0]["permissions"]
assert saved_permissions["edit_content"] is True
assert saved_permissions["edit_targets"] is False
assert saved_permissions["save_announcements"] is False
assert saved_permissions["open_telegram"] is False
assert saved_permissions["post_now"] is False
assert saved_permissions["schedule"] is False
assert saved_permissions["unschedule"] is False
assert saved_permissions["unlink_telegram"] is False
assert saved_permissions["delete_telegram"] is False
assert saved_permissions["delete_storage"] is False
def test_load_users_removes_legacy_edit_broadcasts_permission(tmp_path: Path) -> None:
users_file = tmp_path / "users.json"
users_file.write_text(
"""
{
"users": [
{
"username": "editor",
"password_hash": "hash",
"permissions": {
"edit_broadcasts": true
}
}
]
}
""",
encoding="utf-8",
)
users = load_users(users_file)
assert "edit_broadcasts" not in users["editor"]["permissions"]
saved_payload = json.loads(users_file.read_text(encoding="utf-8"))
saved_permissions = saved_payload["users"][0]["permissions"]
assert "edit_broadcasts" not in saved_permissions
+204
View File
@@ -0,0 +1,204 @@
from linkki_poster.broadcast_ops import (
DiscussionResolutionStats,
apply_discussion_group_pinning,
build_discussion_resolution_detail,
prune_binding_after_deletions,
)
from linkki_poster.models import Config, Language, LanguageBinding, PublishBinding, TextSegmentBinding
from linkki_poster.telegram_api import TelegramApiError
def test_discussion_resolution_detail_mentions_missing_group_updates() -> None:
detail = build_discussion_resolution_detail(
DiscussionResolutionStats(
updates_seen=4,
discussion_group_updates=0,
automatic_forward_updates=0,
linked_channel_forward_updates=0,
),
expected_count=2,
matched_count=0,
)
assert "matched 0/2" in detail
assert "none were from the configured discussion group" in detail
def test_discussion_resolution_detail_mentions_linked_channel_forwards() -> None:
detail = build_discussion_resolution_detail(
DiscussionResolutionStats(
updates_seen=6,
discussion_group_updates=5,
automatic_forward_updates=3,
linked_channel_forward_updates=2,
),
expected_count=3,
matched_count=2,
)
assert "matched 2/3" in detail
assert "saw 2 automatic forwards from the linked channel" in detail
def test_discussion_resolution_detail_mentions_manual_linking_for_old_posts() -> None:
detail = build_discussion_resolution_detail(
DiscussionResolutionStats(
updates_seen=0,
discussion_group_updates=0,
automatic_forward_updates=0,
linked_channel_forward_updates=0,
),
expected_count=3,
matched_count=0,
)
assert "they must be linked manually" in detail
def test_apply_discussion_group_pinning_skips_service_message_cleanup_when_disabled(monkeypatch) -> None:
calls = []
class FakeTelegramAPI:
def __init__(self, bot_token: str):
self.bot_token = bot_token
def pin_chat_message(self, chat_id: str, message_id: int, disable_notification: bool = True) -> bool:
calls.append(("pin", chat_id, message_id, disable_notification))
return True
def unpin_chat_message(self, chat_id: str, message_id: int) -> bool:
calls.append(("unpin", chat_id, message_id))
return True
def fail_if_delete_called(**kwargs):
raise AssertionError("delete_pin_service_message should not be called")
monkeypatch.setattr("linkki_poster.broadcast_ops.TelegramAPI", FakeTelegramAPI)
monkeypatch.setattr("linkki_poster.broadcast_ops.delete_pin_service_message", fail_if_delete_called)
binding = PublishBinding(
global_message_ids=[],
global_attached_to_language=None,
languages={
Language.FI: LanguageBinding(
language=Language.FI,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
primary_message_id=101,
primary_message_url="https://t.me/c/123/101",
discussion_message_id=201,
),
Language.SV: LanguageBinding(
language=Language.SV,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=102, mode="message")],
primary_message_id=102,
primary_message_url="https://t.me/c/123/102",
discussion_message_id=202,
),
},
discussion_message_ids={101: 201, 102: 202},
)
result = apply_discussion_group_pinning(
Config(bot_token="token", chat_id="-1001", channel_username="channel", discussion_group_id="-2001"),
binding,
[Language.FI, Language.SV],
"first",
delete_service_messages=False,
)
assert result.binding == binding
assert result.pinning_failed is False
assert calls == [
("pin", "-2001", 201, True),
("unpin", "-2001", 202),
]
def test_prune_binding_after_deletions_keeps_remaining_channel_links() -> None:
binding = PublishBinding(
global_message_ids=[],
global_attached_to_language=None,
languages={
Language.FI: LanguageBinding(
language=Language.FI,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
primary_message_id=101,
primary_message_url="https://t.me/c/123/101",
discussion_message_id=201,
),
Language.SV: LanguageBinding(
language=Language.SV,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=102, mode="message")],
primary_message_id=102,
primary_message_url="https://t.me/c/123/102",
discussion_message_id=202,
),
},
discussion_message_ids={101: 201, 102: 202},
)
remaining = prune_binding_after_deletions(
binding,
deleted_channel_ids=set(),
deleted_discussion_channel_ids={101},
deleted_discussion_message_ids={201},
chat_id="-100123",
channel_username="",
)
assert remaining is not None
assert remaining.languages[Language.FI].text_segments[0].message_id == 101
assert remaining.languages[Language.SV].text_segments[0].message_id == 102
assert remaining.discussion_message_ids == {102: 202}
def test_apply_discussion_group_pinning_reports_permission_failures(monkeypatch) -> None:
class FakeTelegramAPI:
def __init__(self, bot_token: str):
self.bot_token = bot_token
def pin_chat_message(self, chat_id: str, message_id: int, disable_notification: bool = True) -> bool:
raise TelegramApiError("Bad Request: not enough rights to manage pinned messages in the chat")
def unpin_chat_message(self, chat_id: str, message_id: int) -> bool:
raise TelegramApiError("Bad Request: not enough rights to manage pinned messages in the chat")
monkeypatch.setattr("linkki_poster.broadcast_ops.TelegramAPI", FakeTelegramAPI)
binding = PublishBinding(
global_message_ids=[],
global_attached_to_language=None,
languages={
Language.FI: LanguageBinding(
language=Language.FI,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
primary_message_id=101,
primary_message_url="https://t.me/c/123/101",
discussion_message_id=201,
),
Language.SV: LanguageBinding(
language=Language.SV,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=102, mode="message")],
primary_message_id=102,
primary_message_url="https://t.me/c/123/102",
discussion_message_id=202,
),
},
discussion_message_ids={101: 201, 102: 202},
)
result = apply_discussion_group_pinning(
Config(bot_token="token", chat_id="-1001", channel_username="channel", discussion_group_id="-2001"),
binding,
[Language.FI, Language.SV],
"first",
)
assert result.binding == binding
assert result.pinning_failed is True
+53
View File
@@ -0,0 +1,53 @@
from pathlib import Path
from linkki_poster.cli import apply_language_order, load_config, load_config_strict
from linkki_poster.models import Language, LanguageAssets
def test_load_config_strict_reads_required_fields(tmp_path: Path) -> None:
config_path = tmp_path / "telegram_config.json"
config_path.write_text('{"bot_token":"token","chat_id":"-100123"}', encoding="utf-8")
config = load_config_strict(tmp_path)
assert config.bot_token == "token"
assert config.chat_id == "-100123"
assert config.channel_username == ""
def test_load_config_strict_fails_when_missing(tmp_path: Path) -> None:
try:
load_config_strict(tmp_path)
except FileNotFoundError:
return
raise AssertionError("Expected FileNotFoundError")
def test_load_config_strict_fails_when_required_field_missing(tmp_path: Path) -> None:
config_path = tmp_path / "telegram_config.json"
config_path.write_text('{"bot_token":"token"}', encoding="utf-8")
try:
load_config_strict(tmp_path)
except ValueError:
return
raise AssertionError("Expected ValueError")
def test_apply_language_order_uses_requested_prefix_and_keeps_rest(tmp_path: Path) -> None:
fi = LanguageAssets(Language.FI, tmp_path / "fi.md", "fi", [])
sv = LanguageAssets(Language.SV, tmp_path / "sv.md", "sv", [])
en = LanguageAssets(Language.EN, tmp_path / "en.md", "en", [])
ordered = apply_language_order([fi, sv, en], "en,fi")
assert [item.language for item in ordered] == [Language.EN, Language.FI, Language.SV]
def test_load_config_interactive_prompts_missing_values(tmp_path: Path) -> None:
answers = iter(["token_from_prompt", "-100123", "publicname"])
config = load_config(tmp_path, non_interactive=False, input_fn=lambda _: next(answers))
assert config.bot_token == "token_from_prompt"
assert config.chat_id == "-100123"
assert config.channel_username == "publicname"
@@ -0,0 +1,78 @@
import json
import os
from pathlib import Path
from webapp.auth import load_users
from webapp.config import ConfigFileError, load_webapp_config
from webapp.i18n import load_translation_catalog
from webapp.telegram_targets import load_telegram_target_catalog
def test_load_users_reports_invalid_json_with_path(tmp_path: Path) -> None:
users_file = tmp_path / "users.json"
users_file.write_text("{broken", encoding="utf-8")
try:
load_users(users_file)
except ConfigFileError as error:
assert str(users_file) in str(error)
else:
raise AssertionError("Expected ConfigFileError")
def test_load_telegram_target_catalog_reports_invalid_json_with_path(tmp_path: Path) -> None:
config_dir = tmp_path / "config"
config_dir.mkdir()
config_file = config_dir / "telegram_config.json"
config_file.write_text("{broken", encoding="utf-8")
try:
load_telegram_target_catalog(config_dir)
except ConfigFileError as error:
assert str(config_file) in str(error)
else:
raise AssertionError("Expected ConfigFileError")
def test_load_webapp_config_reports_invalid_json_with_path(tmp_path: Path) -> None:
config_dir = tmp_path / "config"
config_dir.mkdir()
config_file = config_dir / "app_config.json"
config_file.write_text("{broken", encoding="utf-8")
previous = os.environ.get("LINKKI_DATA_DIR")
os.environ["LINKKI_DATA_DIR"] = str(tmp_path)
try:
try:
load_webapp_config()
except ConfigFileError as error:
assert str(config_file) in str(error)
else:
raise AssertionError("Expected ConfigFileError")
finally:
if previous is None:
os.environ.pop("LINKKI_DATA_DIR", None)
else:
os.environ["LINKKI_DATA_DIR"] = previous
def test_load_translation_catalog_reports_invalid_json_with_path(tmp_path: Path) -> None:
translations_dir = tmp_path / "translations"
translations_dir.mkdir()
config_file = translations_dir / "catalog.json"
config_file.write_text('{"a":{"en":"line1\nline2"}}', encoding="utf-8")
from webapp import i18n as i18n_module
original = i18n_module.TRANSLATION_CATALOG_FILE
i18n_module.TRANSLATION_CATALOG_FILE = config_file
try:
try:
load_translation_catalog()
except ConfigFileError as error:
assert str(config_file) in str(error)
assert "raw line breaks" in str(error)
else:
raise AssertionError("Expected ConfigFileError")
finally:
i18n_module.TRANSLATION_CATALOG_FILE = original
+125
View File
@@ -0,0 +1,125 @@
from linkki_poster.formatting import (
CAPTION_LIMIT,
estimate_caption_length,
parse_markdown_template,
render_preview_html,
render_template,
validate_markdown_syntax,
)
from linkki_poster.models import Language
def test_markdown_subset_and_placeholder_rendering() -> None:
raw = (
"# title\n"
"**bold** _italic_ ++under++ ~~strike~~ ||spoil|| `code` [site](https://example.com)\n"
"Go [English](en) and [Finnish](fi)\n"
)
parsed = parse_markdown_template(raw)
unresolved = render_template(parsed, {})
assert "<b>TITLE</b>" in unresolved
assert "<b>bold</b>" in unresolved
assert "<i>italic</i>" in unresolved
assert "<u>under</u>" in unresolved
assert "<s>strike</s>" in unresolved
assert "<tg-spoiler>spoil</tg-spoiler>" in unresolved
assert "<code>code</code>" in unresolved
assert '<a href="https://example.com">site</a>' in unresolved
assert "English" in unresolved
assert "Finnish" in unresolved
assert 'href="https://t.me/' not in unresolved
resolved = render_template(
parsed,
{
Language.EN: "https://t.me/testchan/123",
Language.FI: "https://t.me/testchan/456",
},
)
assert '<a href="https://t.me/testchan/123">English</a>' in resolved
assert '<a href="https://t.me/testchan/456">Finnish</a>' in resolved
def test_caption_length_estimate_uses_worst_case_links() -> None:
parsed = parse_markdown_template("[EN](en) " * 80)
assert estimate_caption_length(parsed, "channel") > CAPTION_LIMIT
def test_multiline_spoiler_is_supported() -> None:
parsed = parse_markdown_template("||line1\n\nline2||")
rendered = render_template(parsed, {})
assert "<tg-spoiler>line1\n\nline2</tg-spoiler>" in rendered
def test_validate_markdown_syntax_finds_unclosed_markers() -> None:
issues = validate_markdown_syntax("Can we do **[bold links](fi)")
messages = [issue.message for issue in issues]
assert any("**" in message for message in messages)
def test_lists_and_blockquotes_are_rendered_for_telegram_html() -> None:
parsed = parse_markdown_template(
"- item\n"
" - nested\n"
"1. first\n"
" 1. nested first\n"
"> quoted\n"
"> text\n"
)
rendered = render_template(parsed, {})
assert "• item" in rendered
assert "\u00a0\u00a0\u00a0\u00a0• nested" in rendered
assert "1. first" in rendered
assert "\u00a0\u00a0\u00a0\u00a01. nested first" in rendered
assert "<blockquote>quoted\ntext</blockquote>" in rendered
def test_escaped_list_markers_and_heading_markers_stay_literal() -> None:
parsed = parse_markdown_template("\\* not a list\n1\\. not numbered\n\\# not a heading")
rendered = render_template(parsed, {})
assert "* not a list" in rendered
assert "1. not numbered" in rendered
assert "# not a heading" in rendered
def test_escaped_backticks_and_link_brackets_stay_literal() -> None:
parsed = parse_markdown_template(
r"test \`code` test" "\n"
r"\```" "\n"
"code\n"
"```\n"
r"\[label\]\(url\)"
)
rendered = render_template(parsed, {})
assert r"test `code` test" in rendered
assert "<code>code</code>" not in rendered
assert "<pre><code>" not in rendered
assert "[label](url)" in rendered
def test_escaped_cross_language_link_stays_literal() -> None:
parsed = parse_markdown_template(r"\[English](en)")
rendered = render_template(parsed, {Language.EN: "https://t.me/test/123"})
assert "[English](en)" in rendered
assert 'href="https://t.me/test/123"' not in rendered
def test_preview_renders_fake_links_for_available_targets() -> None:
preview = render_preview_html("[English](en) [Finnish](fi)", {Language.EN})
assert '<a href="#en">English</a>' in preview
assert "Finnish" in preview
assert 'href="#fi"' not in preview
def test_apostrophes_are_not_escaped_in_headings_or_plain_text() -> None:
heading = render_template(parse_markdown_template("#THERE'S"), {})
plain_heading_char = render_template(parse_markdown_template("#'"), {})
bold = render_template(parse_markdown_template("**THERE'S**"), {})
assert "<b>THERE'S</b>" in heading
assert "<b>'</b>" in plain_heading_char
assert "<b>THERE'S</b>" in bold
assert "&#x27;" not in heading
assert "&#x27;" not in plain_heading_char
assert "&#x27;" not in bold
+46
View File
@@ -0,0 +1,46 @@
import json
from pathlib import Path
from webapp.i18n import load_translations
def test_load_translations_uses_english_fallback_for_missing_values() -> None:
translations = load_translations("fi")
assert translations["nav.instructions"]
assert translations["app.title"] == "Linkki Botti"
def test_load_translations_prefers_requested_language_value() -> None:
translations = load_translations("sv")
assert translations["nav.instructions"] != "Instructions"
def test_translation_catalog_has_no_common_utf8_mojibake_in_finnish_or_swedish() -> None:
catalog = json.loads(
(Path(__file__).resolve().parents[1] / "webapp/translations/catalog.json").read_text(
encoding="utf-8"
)
)
def visit(value: object) -> list[str]:
if isinstance(value, str):
return [value]
if isinstance(value, list):
result: list[str] = []
for item in value:
result.extend(visit(item))
return result
if isinstance(value, dict):
result: list[str] = []
for nested in value.values():
result.extend(visit(nested))
return result
return []
suspicious_markers = ("\u00c3", "\u00c2", "\ufffd")
for entry in catalog.values():
for language in ("fi", "sv"):
for text in visit(entry.get(language, "")):
assert not any(marker in text for marker in suspicious_markers), text
+25
View File
@@ -0,0 +1,25 @@
from pathlib import Path
from linkki_poster.models import Language, LanguageAssets
from linkki_poster.ordering import parse_order_input, prompt_language_order
def test_parse_order_input_accepts_expected_formats() -> None:
assert parse_order_input("12", 2) == [1, 2]
assert parse_order_input("2 1", 2) == [2, 1]
assert parse_order_input("1,2", 2) == [1, 2]
def test_parse_order_input_rejects_invalid_orders() -> None:
assert parse_order_input("11", 2) is None
assert parse_order_input("13", 2) is None
assert parse_order_input("1", 2) is None
def test_prompt_language_order_accepts_empty_as_default(tmp_path: Path) -> None:
fi = LanguageAssets(Language.FI, tmp_path / "fi.md", "fi", [])
en = LanguageAssets(Language.EN, tmp_path / "en.md", "en", [])
ordered = prompt_language_order([fi, en], input_fn=lambda _: "")
assert [item.language for item in ordered] == [Language.FI, Language.EN]
+10
View File
@@ -0,0 +1,10 @@
from linkki_poster.progress import LogProgressDisplay
def test_log_progress_display_keeps_only_latest_step_state() -> None:
progress = LogProgressDisplay()
step = progress.add_step("Post English text")
progress.update(step, "in_progress")
progress.update(step, "done", "split part 1/2")
assert progress.get_output() == "[x] Post English text - split part 1/2"
+60
View File
@@ -0,0 +1,60 @@
from pathlib import Path
from linkki_poster.models import Language
from linkki_poster.scanner import scan_directory
def write_file(path: Path, text: str = "x") -> None:
path.write_text(text, encoding="utf-8")
def test_global_prefix_first_family_wins(tmp_path: Path) -> None:
write_file(tmp_path / "Kuva1.jpg")
write_file(tmp_path / "Image1.jpg")
write_file(tmp_path / "Suomi.md", "hello")
result = scan_directory(tmp_path)
assert [path.name for path in result.global_images] == ["Kuva1.jpg"]
def test_numeric_suffix_rule_and_case_insensitive_matching(tmp_path: Path) -> None:
write_file(tmp_path / "KuvaA.jpg")
write_file(tmp_path / "Kuva2.jpg")
write_file(tmp_path / "sUoMi.MD", "hello")
write_file(tmp_path / "sUoMi10.PNG")
write_file(tmp_path / "sUoMi2.jpg")
result = scan_directory(tmp_path)
assert [path.name for path in result.global_images] == ["Kuva2.jpg"]
assert len(result.languages) == 1
assert result.languages[0].language == Language.FI
assert [path.name for path in result.languages[0].images] == ["sUoMi2.jpg", "sUoMi10.PNG"]
def test_natural_sort_and_text_precedence(tmp_path: Path) -> None:
write_file(tmp_path / "English.md", "right")
write_file(tmp_path / "english10.png")
write_file(tmp_path / "english9.webp")
write_file(tmp_path / "english9.jpg")
write_file(tmp_path / "english.jpg")
result = scan_directory(tmp_path)
assert len(result.languages) == 1
assets = result.languages[0]
assert assets.text_file.name == "English.md"
assert [path.name for path in assets.images] == [
"english.jpg",
"english9.jpg",
"english9.webp",
"english10.png",
]
def test_empty_text_file_is_skipped_as_missing_language(tmp_path: Path) -> None:
write_file(tmp_path / "Suomi.md", "Hei")
write_file(tmp_path / "Svenska.md", " \n\t")
write_file(tmp_path / "English.md", "")
result = scan_directory(tmp_path)
assert [assets.language for assets in result.languages] == [Language.FI]
+50
View File
@@ -0,0 +1,50 @@
import asyncio
from datetime import datetime, timedelta
from pathlib import Path
from fastapi import FastAPI
from webapp import app as app_module
def test_startup_recovery_runs_overdue_scheduled_posts_without_waiting_for_retry() -> None:
now = datetime.now().astimezone()
item = {
"status": "scheduled",
"scheduled_for": (now - timedelta(minutes=5)).isoformat(),
"scheduled_next_attempt_at": (now + timedelta(minutes=30)).isoformat(),
}
assert app_module.is_scheduled_publish_due(item, recover_missed_jobs=True) is True
assert app_module.is_scheduled_publish_due(item, recover_missed_jobs=False) is False
def test_manual_publish_retries_once_after_a_failed_attempt(monkeypatch) -> None:
app = FastAPI()
directory_name = "retry-once"
app.state.operations = {
directory_name: {
"running": True,
"completed": False,
"output": "",
"result": None,
"error": None,
}
}
app.state.operation_tasks = {directory_name: object()}
app.state.scheduler_lock = asyncio.Lock()
attempts = []
async def fake_run_publish_action(post_dir: Path, config, progress):
attempts.append(post_dir)
return {"status": "failed", "output": "still unavailable"}
monkeypatch.setattr(app_module, "run_publish_action_with_progress", fake_run_publish_action)
asyncio.run(app_module.run_publish_operation(app, directory_name, Path("/tmp/retry-once"), None))
assert attempts == [Path("/tmp/retry-once"), Path("/tmp/retry-once")]
operation = app.state.operations[directory_name]
assert operation["completed"] is True
assert operation["result"] == "failed"
assert directory_name not in app.state.operation_tasks
+588
View File
@@ -0,0 +1,588 @@
from datetime import datetime, timedelta
from pathlib import Path
from linkki_poster.models import Language, LanguageBinding, PublishBinding, TextSegmentBinding
from webapp.storage import (
append_status_output,
clear_status_history,
create_broadcast,
is_telegram_delete_expired,
list_broadcasts,
load_metadata,
mark_publish_success,
mark_publish_success_with_result,
mark_scheduled_publish_failure,
mark_scheduled,
move_broadcast,
normalize_saved_text,
scheduled_retry_delay,
save_broadcast_changes,
telegram_delete_deadline,
normalize_discussion_pinning_mode,
unschedule,
)
def test_title_change_renames_directory_and_updates_slug(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "First title")
post_dir = tmp_path / directory_name
result = save_broadcast_changes(
post_dir,
{
"title": {
"base_revision": 0,
"value": "Renamed title",
},
"texts": {},
"images": {},
},
)
renamed_dir = result["post_dir"]
assert renamed_dir.name == "renamed-title"
assert renamed_dir.exists()
metadata = load_metadata(renamed_dir)
assert metadata["title"] == "Renamed title"
assert metadata["slug"] == "renamed-title"
def test_linked_broadcast_allows_title_and_text_changes_but_rejects_other_locked_fields(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Linked")
post_dir = tmp_path / directory_name
binding = PublishBinding(
global_message_ids=[],
global_attached_to_language=None,
languages={
Language.FI: LanguageBinding(
language=Language.FI,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
primary_message_id=101,
primary_message_url="https://t.me/c/123/101",
)
},
discussion_message_ids={},
)
mark_publish_success(post_dir, binding, "ok")
result = save_broadcast_changes(
post_dir,
{
"title": {"base_revision": 0, "value": "Renamed linked"},
"language_order": {"base_revision": 0, "value": ["en", "fi", "sv"]},
"images": {
"global": {"base_revision": 0, "value": []},
},
"texts": {
"fi": {"base_revision": 0, "value": "Updated"},
},
},
)
renamed_dir = result["post_dir"]
assert renamed_dir.name == "renamed-linked"
assert result["results"]["title"]["status"] == "saved"
assert result["results"]["language_order"]["status"] == "rejected"
assert result["results"]["images"]["status"] == "rejected"
assert result["results"]["fi.text"]["status"] == "saved"
metadata = load_metadata(renamed_dir)
assert metadata["title"] == "Renamed linked"
assert metadata["status"] == "modified"
assert metadata["dirty_text_languages"] == ["fi"]
def test_normalize_saved_text_ignores_trailing_whitespace() -> None:
assert normalize_saved_text("Line 1 \nLine 2\t\t\n\n") == "Line 1\nLine 2"
def test_unschedule_restores_modified_when_linked_and_dirty(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Scheduled update")
post_dir = tmp_path / directory_name
binding = PublishBinding(
global_message_ids=[],
global_attached_to_language=None,
languages={
Language.FI: LanguageBinding(
language=Language.FI,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
primary_message_id=101,
primary_message_url="https://t.me/c/123/101",
)
},
discussion_message_ids={},
)
mark_publish_success(post_dir, binding, "ok")
save_broadcast_changes(
post_dir,
{
"texts": {
"fi": {"base_revision": 0, "value": "Updated"},
},
},
)
mark_scheduled(post_dir, "2026-03-02T12:00:00+02:00")
unschedule(post_dir)
metadata = load_metadata(post_dir)
assert metadata["status"] == "modified"
assert metadata["scheduled_for"] is None
def test_create_broadcast_uses_slug_only_directory_name(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Test Broadcast")
assert directory_name == "test-broadcast"
assert (tmp_path / "test-broadcast").exists()
def test_create_broadcast_defaults_pinning_mode_to_first(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Pin first")
metadata = load_metadata(tmp_path / directory_name)
assert metadata["discussion_pinning_mode"] == "first"
assert metadata["delete_pin_service_messages"] is True
def test_normalize_discussion_pinning_mode_accepts_none() -> None:
assert normalize_discussion_pinning_mode("none") == "none"
def test_append_status_output_keeps_previous_entries() -> None:
first = append_status_output("", "First log line", "published", "2026-04-17T10:00:00+00:00")
combined = append_status_output(first, "Second log line", "updated", "2026-04-17T11:00:00+00:00")
assert "[2026-04-17T10:00:00+00:00] published" in combined
assert "First log line" in combined
assert "[2026-04-17T11:00:00+00:00] updated" in combined
assert "Second log line" in combined
def test_load_metadata_recovers_from_broken_post_json_and_manual_directory_name(tmp_path: Path) -> None:
post_dir = tmp_path / "manual broadcast_name"
post_dir.mkdir()
(post_dir / "post.json").write_text("{broken", encoding="utf-8")
metadata = load_metadata(post_dir)
assert metadata["title"] == "Manual broadcast name"
assert metadata["slug"] == "manual-broadcast-name"
assert metadata["status"] == "draft"
def test_schedule_draft_can_be_saved_without_scheduling(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Saved schedule")
post_dir = tmp_path / directory_name
result = save_broadcast_changes(
post_dir,
{
"schedule_draft_for": {
"base_revision": 0,
"value": "2026-03-30T14:45",
},
"texts": {},
"images": {},
},
)
assert result["results"]["schedule_draft_for"]["status"] == "saved"
metadata = load_metadata(post_dir)
assert metadata["schedule_draft_for"] == "2026-03-30T14:45"
assert metadata["scheduled_for"] is None
assert metadata["status"] == "draft"
mark_scheduled(post_dir, "2026-03-30T14:45:00+02:00", "2026-03-30T14:45")
unschedule(post_dir)
metadata = load_metadata(post_dir)
assert metadata["schedule_draft_for"] == "2026-03-30T14:45"
def test_scheduled_publish_failure_keeps_the_schedule_and_records_backoff(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Retry scheduled")
post_dir = tmp_path / directory_name
mark_scheduled(post_dir, "2026-03-30T14:45:00+02:00")
mark_scheduled_publish_failure(post_dir, "Temporary Telegram connection failure")
metadata = load_metadata(post_dir)
assert metadata["status"] == "scheduled"
assert metadata["scheduled_for"] == "2026-03-30T14:45:00+02:00"
assert metadata["scheduled_retry_count"] == 1
assert metadata["scheduled_next_attempt_at"] is not None
assert metadata["last_result"] == "scheduled_publish_retry"
assert "Temporary Telegram connection failure" in metadata["last_output"]
def test_scheduled_retry_backoff_caps_at_thirty_minutes() -> None:
assert scheduled_retry_delay(1) == timedelta(minutes=1)
assert scheduled_retry_delay(2) == timedelta(minutes=5)
assert scheduled_retry_delay(3) == timedelta(minutes=15)
assert scheduled_retry_delay(4) == timedelta(minutes=30)
assert scheduled_retry_delay(99) == timedelta(minutes=30)
def test_telegram_target_ids_are_saved_for_unlinked_broadcast(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Telegram target test")
post_dir = tmp_path / directory_name
result = save_broadcast_changes(
post_dir,
{
"telegram_bot_id": {
"base_revision": 0,
"value": "123456",
},
"telegram_chat_id": {
"base_revision": 0,
"value": "-100123",
},
"texts": {},
"images": {},
},
)
assert result["results"]["telegram_bot_id"]["status"] == "saved"
assert result["results"]["telegram_chat_id"]["status"] == "saved"
metadata = load_metadata(post_dir)
assert metadata["telegram_bot_id"] == "123456"
assert metadata["telegram_chat_id"] == "-100123"
def test_linked_broadcast_rejects_telegram_target_id_change(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Target lock")
post_dir = tmp_path / directory_name
binding = PublishBinding(
global_message_ids=[],
global_attached_to_language=None,
languages={
Language.FI: LanguageBinding(
language=Language.FI,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
primary_message_id=101,
primary_message_url="https://t.me/c/123/101",
)
},
discussion_message_ids={},
)
mark_publish_success(post_dir, binding, "ok")
result = save_broadcast_changes(
post_dir,
{
"telegram_bot_id": {"base_revision": 0, "value": "999999"},
"telegram_chat_id": {"base_revision": 0, "value": "-100999"},
"texts": {},
"images": {},
},
)
assert result["results"]["telegram_bot_id"]["status"] == "rejected"
assert result["results"]["telegram_chat_id"]["status"] == "rejected"
def test_scheduled_broadcast_rejects_telegram_target_id_change(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Scheduled target lock")
post_dir = tmp_path / directory_name
mark_scheduled(post_dir, "2026-03-02T12:00:00+02:00")
result = save_broadcast_changes(
post_dir,
{
"telegram_bot_id": {"base_revision": 0, "value": "999999"},
"telegram_chat_id": {"base_revision": 0, "value": "-100999"},
"texts": {},
"images": {},
},
)
assert result["results"]["telegram_bot_id"]["status"] == "rejected"
assert result["results"]["telegram_chat_id"]["status"] == "rejected"
def test_publish_success_sets_telegram_published_at_and_delete_deadline(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Delete window")
post_dir = tmp_path / directory_name
binding = PublishBinding(
global_message_ids=[],
global_attached_to_language=None,
languages={
Language.FI: LanguageBinding(
language=Language.FI,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
primary_message_id=101,
primary_message_url="https://t.me/c/123/101",
)
},
discussion_message_ids={},
)
mark_publish_success(post_dir, binding, "ok")
metadata = load_metadata(post_dir)
assert metadata["telegram_published_at"] is not None
deadline = telegram_delete_deadline(metadata)
assert deadline is not None
assert deadline > datetime.fromisoformat(metadata["telegram_published_at"])
def test_delete_expiry_uses_original_publish_time_not_last_update(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Delete expiry")
post_dir = tmp_path / directory_name
binding = PublishBinding(
global_message_ids=[],
global_attached_to_language=None,
languages={
Language.FI: LanguageBinding(
language=Language.FI,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
primary_message_id=101,
primary_message_url="https://t.me/c/123/101",
)
},
discussion_message_ids={},
)
mark_publish_success(post_dir, binding, "ok")
metadata = load_metadata(post_dir)
original_publish_time = metadata["telegram_published_at"]
save_broadcast_changes(
post_dir,
{
"texts": {
"fi": {"base_revision": 0, "value": "Updated"},
},
},
)
metadata = load_metadata(post_dir)
assert metadata["telegram_published_at"] == original_publish_time
assert is_telegram_delete_expired(
metadata,
now=datetime.fromisoformat(original_publish_time) + timedelta(hours=48, seconds=1),
)
def test_clear_status_history_keeps_status_but_removes_attempt_log(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "History clear")
post_dir = tmp_path / directory_name
binding = PublishBinding(
global_message_ids=[],
global_attached_to_language=None,
languages={
Language.FI: LanguageBinding(
language=Language.FI,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
primary_message_id=101,
primary_message_url="https://t.me/c/123/101",
)
},
discussion_message_ids={},
)
def test_linked_announcement_pinning_change_marks_modified_and_unschedule_preserves_it(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Pinning update")
post_dir = tmp_path / directory_name
binding = PublishBinding(
global_message_ids=[],
global_attached_to_language=None,
languages={
Language.FI: LanguageBinding(
language=Language.FI,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
primary_message_id=101,
primary_message_url="https://t.me/c/123/101",
discussion_message_id=201,
)
},
discussion_message_ids={101: 201},
)
mark_publish_success(post_dir, binding, "ok")
result = save_broadcast_changes(
post_dir,
{
"discussion_pinning_mode": {"base_revision": 0, "value": "all"},
"texts": {},
"images": {},
},
)
assert result["results"]["discussion_pinning_mode"]["status"] == "saved"
metadata = load_metadata(post_dir)
assert metadata["status"] == "modified"
assert metadata["dirty_discussion_pinning"] is True
mark_scheduled(post_dir, "2026-03-02T12:00:00+02:00")
unschedule(post_dir)
metadata = load_metadata(post_dir)
assert metadata["status"] == "modified"
mark_publish_success(post_dir, binding, "posted output")
clear_status_history(post_dir)
metadata = load_metadata(post_dir)
assert metadata["status"] == "published"
assert metadata["last_attempt_at"] is None
assert metadata["last_output"] == ""
assert metadata["last_result"] is None
def test_linked_announcement_pinning_mode_none_is_saved(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Pinning none")
post_dir = tmp_path / directory_name
binding = PublishBinding(
global_message_ids=[],
global_attached_to_language=None,
languages={
Language.FI: LanguageBinding(
language=Language.FI,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
primary_message_id=101,
primary_message_url="https://t.me/c/123/101",
discussion_message_id=201,
)
},
discussion_message_ids={101: 201},
)
mark_publish_success(post_dir, binding, "ok")
result = save_broadcast_changes(
post_dir,
{
"discussion_pinning_mode": {"base_revision": 0, "value": "none"},
"texts": {},
"images": {},
},
)
assert result["results"]["discussion_pinning_mode"]["status"] == "saved"
metadata = load_metadata(post_dir)
assert metadata["discussion_pinning_mode"] == "none"
assert metadata["status"] == "modified"
assert metadata["dirty_discussion_pinning"] is True
def test_linked_announcement_delete_service_message_toggle_marks_modified(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Service cleanup update")
post_dir = tmp_path / directory_name
binding = PublishBinding(
global_message_ids=[],
global_attached_to_language=None,
languages={
Language.FI: LanguageBinding(
language=Language.FI,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
primary_message_id=101,
primary_message_url="https://t.me/c/123/101",
discussion_message_id=201,
)
},
discussion_message_ids={101: 201},
)
mark_publish_success(post_dir, binding, "ok")
result = save_broadcast_changes(
post_dir,
{
"delete_pin_service_messages": {"base_revision": 0, "value": False},
"texts": {},
"images": {},
},
)
assert result["results"]["delete_pin_service_messages"]["status"] == "saved"
metadata = load_metadata(post_dir)
assert metadata["delete_pin_service_messages"] is False
assert metadata["status"] == "modified"
assert metadata["dirty_discussion_pinning"] is True
def test_publish_success_can_persist_effective_pinning_fallback(tmp_path: Path) -> None:
directory_name = create_broadcast(tmp_path, "Fallback pin mode")
post_dir = tmp_path / directory_name
binding = PublishBinding(
global_message_ids=[],
global_attached_to_language=None,
languages={
Language.FI: LanguageBinding(
language=Language.FI,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=101, mode="message")],
primary_message_id=101,
primary_message_url="https://t.me/c/123/101",
discussion_message_id=201,
)
},
discussion_message_ids={101: 201},
)
save_broadcast_changes(
post_dir,
{
"discussion_pinning_mode": {"base_revision": 0, "value": "all"},
"texts": {},
"images": {},
},
)
mark_publish_success_with_result(
post_dir,
binding,
"ok",
"updated",
effective_discussion_pinning_mode="last",
)
metadata = load_metadata(post_dir)
assert metadata["discussion_pinning_mode"] == "last"
assert metadata["dirty_discussion_pinning"] is False
def test_list_broadcasts_uses_manual_sort_order_instead_of_updated_time(tmp_path: Path) -> None:
first = create_broadcast(tmp_path, "First")
second = create_broadcast(tmp_path, "Second")
save_broadcast_changes(
tmp_path / first,
{
"texts": {
"fi": {"base_revision": 0, "value": "Updated later"},
},
},
)
ordered = list_broadcasts(tmp_path)
assert [item["directory"] for item in ordered] == [second, first]
def test_new_broadcasts_are_inserted_at_top_of_sidebar_order(tmp_path: Path) -> None:
first = create_broadcast(tmp_path, "First")
second = create_broadcast(tmp_path, "Second")
third = create_broadcast(tmp_path, "Third")
ordered = list_broadcasts(tmp_path)
assert [item["directory"] for item in ordered] == [third, second, first]
def test_move_broadcast_swaps_manual_order(tmp_path: Path) -> None:
first = create_broadcast(tmp_path, "First")
second = create_broadcast(tmp_path, "Second")
third = create_broadcast(tmp_path, "Third")
move_broadcast(tmp_path, first, "up")
ordered = list_broadcasts(tmp_path)
assert [item["directory"] for item in ordered] == [third, first, second]
@@ -0,0 +1,97 @@
import json
from pathlib import Path
from webapp.telegram_targets import (
default_target_selection,
load_telegram_target_catalog,
resolve_target_config,
)
def test_multi_bot_catalog_picks_first_marked_defaults(tmp_path: Path) -> None:
config_dir = tmp_path / "config"
config_dir.mkdir()
(config_dir / "telegram_config.json").write_text(
json.dumps(
{
"bots": [
{
"display_name": "Bot A",
"token": "token-a",
"default": True,
"channels": [
{"display_name": "Channel A1", "channel_id": "-1001", "default": True},
{"display_name": "Channel A2", "channel_id": "-1002", "default": True},
],
},
{
"display_name": "Bot B",
"token": "token-b",
"default": True,
"channels": [
{"display_name": "Channel B1", "channel_id": "-2001", "default": True},
],
},
]
}
),
encoding="utf-8",
)
catalog = load_telegram_target_catalog(config_dir)
assert default_target_selection(catalog) == ("token-a", "-1001")
def test_resolve_target_config_uses_selected_bot_id_and_chat_id(tmp_path: Path) -> None:
config_dir = tmp_path / "config"
config_dir.mkdir()
(config_dir / "telegram_config.json").write_text(
json.dumps(
{
"bots": [
{
"display_name": "Bot A",
"token": "token-a",
"channels": [
{
"display_name": "Channel A1",
"channel_id": "-1001",
"channel_username": "public_a",
"discussion_group_id": "-2001",
},
],
}
]
}
),
encoding="utf-8",
)
catalog = load_telegram_target_catalog(config_dir)
config = resolve_target_config(catalog, "token-a", "-1001")
assert config.bot_token == "token-a"
assert config.chat_id == "-1001"
assert config.channel_username == "public_a"
assert config.discussion_group_id == "-2001"
def test_telegram_config_requires_top_level_bots_list(tmp_path: Path) -> None:
config_dir = tmp_path / "config"
config_dir.mkdir()
(config_dir / "telegram_config.json").write_text(
json.dumps(
{
"bot_token": "legacy-token",
}
),
encoding="utf-8",
)
try:
load_telegram_target_catalog(config_dir)
except Exception as error:
assert "top-level 'bots' list" in str(error)
else:
raise AssertionError("Expected invalid config format to be rejected.")
+250
View File
@@ -0,0 +1,250 @@
from pathlib import Path
from linkki_poster.models import Config, Language, LanguageAssets
from linkki_poster.broadcast_ops import order_scan_languages
from linkki_poster.models import ScanResult
from linkki_poster.telegram_api import TelegramApiError
from linkki_poster.workflow import (
extract_internal_chat_id,
post_assets,
resolve_link_target,
split_markdown_text_for_messages,
)
class FakeTelegramAPI:
def __init__(self, fail_caption_too_long: bool = False) -> None:
self.next_message_id = 100
self.calls: list[tuple[str, dict]] = []
self.fail_caption_too_long = fail_caption_too_long
def send_media_group(
self,
chat_id: str,
image_paths: list[Path],
caption: str | None = None,
parse_mode: str | None = None,
) -> list[dict]:
self.calls.append(
(
"send_media_group",
{
"chat_id": chat_id,
"images": [path.name for path in image_paths],
"caption": caption,
"parse_mode": parse_mode,
},
)
)
if caption is not None and self.fail_caption_too_long:
raise TelegramApiError("Bad Request: caption is too long")
base = self.next_message_id
self.next_message_id += len(image_paths)
return [{"message_id": base + index} for index, _ in enumerate(image_paths)]
def send_message(self, chat_id: str, text: str, parse_mode: str | None = None) -> dict:
self.calls.append(
(
"send_message",
{
"chat_id": chat_id,
"text": text,
"parse_mode": parse_mode,
},
)
)
message_id = self.next_message_id
self.next_message_id += 1
return {"message_id": message_id}
def edit_message_text(self, chat_id: str, message_id: int, text: str, parse_mode: str | None = None) -> dict:
self.calls.append(
(
"edit_message_text",
{
"chat_id": chat_id,
"message_id": message_id,
"text": text,
"parse_mode": parse_mode,
},
)
)
return {"message_id": message_id}
def edit_message_caption(
self,
chat_id: str,
message_id: int,
caption: str,
parse_mode: str | None = None,
) -> dict:
self.calls.append(
(
"edit_message_caption",
{
"chat_id": chat_id,
"message_id": message_id,
"caption": caption,
"parse_mode": parse_mode,
},
)
)
return {"message_id": message_id}
def delete_message(self, chat_id: str, message_id: int) -> bool:
self.calls.append(
(
"delete_message",
{
"chat_id": chat_id,
"message_id": message_id,
},
)
)
return True
def test_post_assets_attaches_globals_to_first_language_without_images(tmp_path: Path) -> None:
config = Config(
bot_token="token",
chat_id="-100123",
channel_username="",
)
fi = LanguageAssets(
language=Language.FI,
text_file=tmp_path / "Finnish.md",
text_raw="Read [English](en)",
images=[],
)
en = LanguageAssets(
language=Language.EN,
text_file=tmp_path / "English.md",
text_raw="Hello",
images=[tmp_path / "English1.jpg"],
)
global_images = [tmp_path / "bild1.jpg", tmp_path / "bild2.jpg"]
api = FakeTelegramAPI()
refs = post_assets(api=api, config=config, global_images=global_images, ordered_languages=[fi, en])
assert api.calls[0][0] == "send_media_group"
assert api.calls[0][1]["images"] == ["bild1.jpg", "bild2.jpg"]
assert api.calls[0][1]["caption"] is not None
send_message_calls = [call for call in api.calls if call[0] == "send_message"]
assert len(send_message_calls) == 0
edit_caption_calls = [call for call in api.calls if call[0] == "edit_message_caption"]
assert len(edit_caption_calls) >= 1
assert 'href="https://t.me/c/123/' in edit_caption_calls[-1][1]["caption"]
assert len(refs) == 2
assert refs[0].language == Language.FI
assert refs[1].language == Language.EN
assert refs[0].text_mode == "caption"
def test_post_assets_posts_globals_first_when_first_language_has_images(tmp_path: Path) -> None:
config = Config(bot_token="token", chat_id="-100123", channel_username="")
fi = LanguageAssets(
language=Language.FI,
text_file=tmp_path / "Finnish.md",
text_raw="Hei",
images=[tmp_path / "suomi1.jpg"],
)
en = LanguageAssets(
language=Language.EN,
text_file=tmp_path / "English.md",
text_raw="Hello",
images=[],
)
global_images = [tmp_path / "bild1.jpg", tmp_path / "bild2.jpg"]
api = FakeTelegramAPI()
post_assets(api=api, config=config, global_images=global_images, ordered_languages=[fi, en])
assert api.calls[0][0] == "send_media_group"
assert api.calls[0][1]["images"] == ["bild1.jpg", "bild2.jpg"]
assert api.calls[0][1]["caption"] is None
def test_long_text_with_images_is_not_caption(tmp_path: Path) -> None:
config = Config(bot_token="token", chat_id="-100123", channel_username="")
long_text = "x" * 1200
en = LanguageAssets(
language=Language.EN,
text_file=tmp_path / "English.md",
text_raw=long_text,
images=[tmp_path / "english1.jpg"],
)
api = FakeTelegramAPI()
refs = post_assets(api=api, config=config, global_images=[], ordered_languages=[en])
first_call = api.calls[0]
assert first_call[0] == "send_media_group"
assert first_call[1]["caption"] is None
second_call = api.calls[1]
assert second_call[0] == "send_message"
assert refs[0].text_mode == "message"
def test_caption_too_long_error_falls_back_to_media_then_message(tmp_path: Path) -> None:
config = Config(bot_token="token", chat_id="-100123", channel_username="")
en = LanguageAssets(
language=Language.EN,
text_file=tmp_path / "English.md",
text_raw="short text",
images=[tmp_path / "english1.jpg"],
)
api = FakeTelegramAPI(fail_caption_too_long=True)
refs = post_assets(api=api, config=config, global_images=[], ordered_languages=[en])
assert api.calls[0][0] == "send_media_group"
assert api.calls[0][1]["caption"] == "short text"
assert api.calls[1][0] == "send_media_group"
assert api.calls[1][1]["caption"] is None
assert api.calls[2][0] == "send_message"
assert refs[0].text_mode == "message"
def test_split_prefers_double_newline_boundaries() -> None:
text = ("a" * 2500) + "\n\n" + ("b" * 2500)
chunks = split_markdown_text_for_messages(text, link_target="chan", message_limit=4096)
assert len(chunks) == 2
assert chunks[0].endswith("\n\n")
assert chunks[1].startswith("b")
def test_split_does_not_force_extra_paragraph_split_when_mid_split_is_needed() -> None:
text = ("x" * 5000) + "\n\n" + ("tail" * 10)
chunks = split_markdown_text_for_messages(text, link_target="chan", message_limit=4096)
assert len(chunks) == 2
assert chunks[0].endswith("x")
def test_resolve_link_target_supports_private_channel_style() -> None:
assert resolve_link_target("-1003896755764", "") == "c/3896755764"
assert extract_internal_chat_id("-1003896755764") == "3896755764"
def test_resolve_link_target_prefers_public_username() -> None:
assert resolve_link_target("-1003896755764", "mychannel") == "mychannel"
def test_order_scan_languages_respects_explicit_allowlist() -> None:
scan_result = ScanResult(
global_images=[],
languages=[
LanguageAssets(language=Language.FI, text_file=Path("Suomi.md"), text_raw="Hei", images=[]),
LanguageAssets(language=Language.SV, text_file=Path("Svenska.md"), text_raw="Hej", images=[]),
LanguageAssets(language=Language.EN, text_file=Path("English.md"), text_raw="Hello", images=[]),
],
)
ordered = order_scan_languages(scan_result, [Language.FI, Language.SV])
assert [assets.language for assets in ordered] == [Language.FI, Language.SV]
+1
View File
@@ -0,0 +1 @@
"""Web app package for Linkki."""
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
import base64
import getpass
import hashlib
import hmac
import json
import os
from pathlib import Path
from webapp.config import ConfigFileError
PBKDF2_ALGORITHM = "sha256"
PBKDF2_ITERATIONS = 600000
SALT_BYTES = 16
SUPPORTED_TRANSLATION_LANGUAGES = ["en", "fi", "sv"]
SUPPORTED_UI_LANGUAGES = ["auto", "fi", "sv", "en"]
PERMISSION_KEYS = [
"edit_content",
"edit_targets",
"save_announcements",
"open_telegram",
"post_now",
"schedule",
"unschedule",
"unlink_telegram",
"delete_telegram",
"delete_storage",
]
def load_users(users_file: Path) -> dict[str, dict]:
if not users_file.exists():
return {}
try:
payload = json.loads(users_file.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise ConfigFileError(users_file, f"Invalid JSON: {error.msg}") from error
users = payload.get("users", [])
result: dict[str, dict] = {}
changed = False
for user in users:
username = str(user.get("username", "")).strip()
password_hash = str(user.get("password_hash", "")).strip()
if username and password_hash:
permissions_payload, permissions_changed = normalize_permissions_payload(user.get("permissions", {}))
if permissions_changed:
user["permissions"] = permissions_payload
changed = True
language = normalize_ui_language(user.get("language", "en"))
if user.get("language") != language:
user["language"] = language
changed = True
result[username] = {
"password_hash": password_hash,
"permissions": load_permissions(permissions_payload),
"language": language,
}
if changed:
users_file.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
return result
def load_permissions(payload: object) -> dict[str, bool]:
source = payload if isinstance(payload, dict) else {}
permissions: dict[str, bool] = {}
for key in PERMISSION_KEYS:
permissions[key] = as_permission_bool(source.get(key, False))
return permissions
def normalize_permissions_payload(payload: object) -> tuple[dict[str, object], bool]:
source = dict(payload) if isinstance(payload, dict) else {}
changed = not isinstance(payload, dict)
normalized = {key: source[key] for key in source if key in PERMISSION_KEYS}
if len(normalized) != len(source):
changed = True
for key in PERMISSION_KEYS:
normalized_value = as_permission_bool(source.get(key, False))
if source.get(key) != normalized_value:
changed = True
normalized[key] = normalized_value
return normalized, changed
def as_permission_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, int):
return value != 0
if isinstance(value, str):
lowered = value.strip().lower()
return lowered in {"1", "true", "yes", "on"}
return False
def normalize_ui_language(value: object) -> str:
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in SUPPORTED_UI_LANGUAGES:
return lowered
return "en"
def normalize_translation_language(value: object) -> str:
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in SUPPORTED_TRANSLATION_LANGUAGES:
return lowered
return "en"
def set_user_language(users_file: Path, username: str, language: str) -> None:
normalized = normalize_ui_language(language)
try:
payload = json.loads(users_file.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise ConfigFileError(users_file, f"Invalid JSON: {error.msg}") from error
users = payload.get("users", [])
updated = False
for user in users:
if str(user.get("username", "")).strip() == username:
user["language"] = normalized
updated = True
break
if not updated:
raise ValueError(f"Unknown user: {username}")
users_file.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
def hash_password(password: str) -> str:
salt = os.urandom(SALT_BYTES)
digest = hashlib.pbkdf2_hmac(PBKDF2_ALGORITHM, password.encode("utf-8"), salt, PBKDF2_ITERATIONS)
return "$".join(
[
"pbkdf2_sha256",
str(PBKDF2_ITERATIONS),
base64.urlsafe_b64encode(salt).decode("ascii"),
base64.urlsafe_b64encode(digest).decode("ascii"),
]
)
def verify_password(password: str, password_hash: str) -> bool:
algorithm, iterations_text, salt_text, digest_text = password_hash.split("$", 3)
if algorithm != "pbkdf2_sha256":
return False
iterations = int(iterations_text)
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
expected_digest = base64.urlsafe_b64decode(digest_text.encode("ascii"))
actual_digest = hashlib.pbkdf2_hmac(PBKDF2_ALGORITHM, password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(actual_digest, expected_digest)
def hash_password_cli() -> None:
password = getpass.getpass("Password: ")
password_repeat = getpass.getpass("Repeat password: ")
if password != password_repeat:
raise ValueError("Passwords did not match.")
print(hash_password(password))
if __name__ == "__main__":
hash_password_cli()
+57
View File
@@ -0,0 +1,57 @@
import json
import os
from dataclasses import dataclass
from pathlib import Path
DEFAULT_TIMEZONE = "UTC"
DEFAULT_POLL_SECONDS = 60
class ConfigFileError(RuntimeError):
def __init__(self, path: Path, message: str):
super().__init__(f"{path}: {message}")
self.path = path
self.message = message
@dataclass(frozen=True)
class WebAppConfig:
data_dir: Path
posts_dir: Path
config_dir: Path
users_file: Path
timezone: str
session_secret: str
scheduler_poll_seconds: int
def load_webapp_config() -> WebAppConfig:
data_dir = Path(os.environ.get("LINKKI_DATA_DIR", "data")).resolve()
config_dir = data_dir / "config"
posts_dir = data_dir / "posts"
config_dir.mkdir(parents=True, exist_ok=True)
posts_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / "app_config.json"
config_data: dict[str, object] = {}
if config_path.exists():
try:
config_data = json.loads(config_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise ConfigFileError(config_path, f"Invalid JSON: {error.msg}") from error
timezone = str(config_data.get("timezone", DEFAULT_TIMEZONE))
session_secret = str(config_data.get("session_secret", "change-me"))
poll_seconds = int(config_data.get("scheduler_poll_seconds", DEFAULT_POLL_SECONDS))
users_filename = str(config_data.get("users_file", "users.json"))
return WebAppConfig(
data_dir=data_dir,
posts_dir=posts_dir,
config_dir=config_dir,
users_file=config_dir / users_filename,
timezone=timezone,
session_secret=session_secret,
scheduler_poll_seconds=poll_seconds,
)
+42
View File
@@ -0,0 +1,42 @@
import json
from pathlib import Path
from webapp.auth import SUPPORTED_UI_LANGUAGES, normalize_translation_language
from webapp.config import ConfigFileError
TRANSLATIONS_DIR = Path(__file__).resolve().parent / "translations"
TRANSLATION_CATALOG_FILE = TRANSLATIONS_DIR / "catalog.json"
def load_translations(language: str) -> dict:
normalized = normalize_translation_language(language)
catalog = load_translation_catalog()
merged: dict[str, object] = {}
for key, translations in catalog.items():
if not isinstance(translations, dict):
continue
english_value = translations.get("en")
localized_value = translations.get(normalized)
if localized_value not in (None, ""):
merged[key] = localized_value
elif english_value is not None:
merged[key] = english_value
return merged
def load_translation_catalog() -> dict:
if not TRANSLATION_CATALOG_FILE.exists():
return {}
try:
payload = json.loads(TRANSLATION_CATALOG_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
detail = f"Invalid JSON: {error.msg}"
if "Invalid control character" in error.msg:
detail += " JSON strings cannot contain raw line breaks; use \\n instead."
raise ConfigFileError(TRANSLATION_CATALOG_FILE, detail) from error
return payload if isinstance(payload, dict) else {}
def language_options() -> list[dict[str, str]]:
return [{"code": code, "label_key": f"ui_language.{code}"} for code in SUPPORTED_UI_LANGUAGES]
+876
View File
@@ -0,0 +1,876 @@
:root {
color-scheme: light dark;
--bg: Canvas;
--fg: CanvasText;
--panel: color-mix(in srgb, Canvas 92%, CanvasText 8%);
--border: color-mix(in srgb, CanvasText 20%, Canvas 80%);
--green: #2f8f4e;
--red: #b33c3c;
--yellow: #b58a00;
--blue: #4b7bd0;
}
body {
margin: 0;
font-family: sans-serif;
background: var(--bg);
color: var(--fg);
overflow: hidden;
}
.layout {
display: grid;
grid-template-columns: 290px 1fr;
height: 100vh;
overflow: hidden;
}
.sidebar,
.content {
padding: 1rem;
min-height: 0;
}
.sidebar {
border-right: 1px solid var(--border);
background: var(--panel);
overflow-y: auto;
}
.content {
min-width: 0;
overflow-y: auto;
}
.sidebar-actions,
.create-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.sidebar-actions {
align-items: center;
justify-content: space-between;
}
.sidebar-actions form {
margin: 0;
}
.sidebar-link {
text-decoration: none;
color: inherit;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
}
.sidebar-link.active {
outline: 2px solid var(--blue);
}
.language-form {
margin-bottom: 1rem;
}
.language-form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.create-form input {
flex: 1 1 auto;
min-width: 0;
}
.create-form button {
flex: 0 0 auto;
}
.create-form input,
input,
textarea,
select,
button {
font: inherit;
}
.announcement-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.announcement-entry {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.4rem;
align-items: stretch;
}
.announcement-item {
display: block;
padding: 0.75rem;
border: 1px solid var(--border);
text-decoration: none;
color: inherit;
border-radius: 0.35rem;
background: color-mix(in srgb, var(--panel) 94%, CanvasText 6%);
}
.announcement-item span,
.announcement-item small {
display: block;
}
.announcement-title-row {
display: flex !important;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
}
.status-primary-marker {
flex: 0 0 auto;
}
.status-primary-marker-pinned {
width: 0.8rem;
height: 0.8rem;
display: inline-block;
background-color: currentColor;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8 3a1 1 0 0 0-1 1v2.2l2 2V11l-4 3v2h6v6l1-1 1-5h6v-2l-4-3V8.2l2-2V4a1 1 0 0 0-1-1H8Z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8 3a1 1 0 0 0-1 1v2.2l2 2V11l-4 3v2h6v6l1-1 1-5h6v-2l-4-3V8.2l2-2V4a1 1 0 0 0-1-1H8Z'/%3E%3C/svg%3E");
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: contain;
mask-size: contain;
}
.status-primary-marker-pinned.status-published {
color: var(--green);
}
.status-primary-marker-pinned.status-failed {
color: var(--red);
}
.status-primary-marker-pinned.status-scheduled {
color: var(--yellow);
}
.status-primary-marker-pinned.status-modified {
color: var(--blue);
}
.announcement-title {
flex: 1 1 auto;
min-width: 0;
}
.announcement-meta {
margin-top: 0.35rem;
opacity: 0.8;
}
.announcement-item.active {
outline: 2px solid var(--blue);
}
.announcement-item:not(.active) {
opacity: 0.78;
color: color-mix(in srgb, var(--fg) 82%, transparent 18%);
}
.announcement-item.disabled-nav {
opacity: 0.6;
pointer-events: none;
}
.announcement-order-controls {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.announcement-order-controls form {
margin: 0;
}
.announcement-order-controls button {
width: 2rem;
height: 2rem;
padding: 0;
}
.status-symbol {
font-size: 1rem;
font-weight: 700;
line-height: 1;
}
.status-symbol.status-published {
color: var(--green);
}
.status-symbol.status-failed {
color: var(--red);
}
.status-symbol.status-scheduled {
color: var(--yellow);
}
.status-symbol.status-modified {
color: var(--blue);
}
.status-symbol.status-symbol-incomplete {
color: var(--red);
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.editor-header h1 {
margin: 0 0 0.2rem;
}
.editor-header p {
margin: 0;
opacity: 0.8;
}
.editor-tabs {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
}
.editor-tab-list {
display: flex;
gap: 0.2rem;
flex-wrap: wrap;
margin-bottom: -1px;
padding-left: 0.45rem;
position: relative;
z-index: 1;
}
.editor-tab-button {
position: relative;
padding: 0.5rem 0.9rem;
border: 1px solid color-mix(in srgb, var(--border) 92%, CanvasText 8%);
border-bottom: none;
border-radius: 0.6rem 0.6rem 0 0;
background: color-mix(in srgb, var(--panel) 88%, Canvas 12%);
color: color-mix(in srgb, var(--fg) 82%, transparent 18%);
transform: translateY(0.3rem);
box-shadow: inset 0 -0.2rem 0 color-mix(in srgb, CanvasText 6%, transparent);
cursor: pointer;
}
.editor-tab-button.active {
background: var(--panel);
color: var(--fg);
transform: translateY(0);
box-shadow: none;
border-color: var(--border);
z-index: 2;
}
.editor-tab-button.tab-missing {
color: var(--red);
}
.editor-tab-panel[hidden] {
display: none;
}
.editor-tab-panel {
border: 1px solid var(--border);
border-radius: 0 0.75rem 0.75rem 0.75rem;
background: var(--panel);
padding: 1rem;
}
.editor-tab-panel.tab-panel-disabled {
opacity: 0.72;
}
.meta-panel {
display: grid;
grid-template-columns: minmax(240px, 2fr) minmax(220px, 1.1fr) minmax(180px, 1fr) minmax(220px, 1.1fr);
gap: 0.75rem;
align-items: start;
}
.meta-title-field {
grid-column: 1;
grid-row: 1;
}
.meta-global-link-preview {
grid-column: 1;
grid-row: 2;
}
.warning-text {
grid-column: 1 / -1;
margin: 0 0 0.25rem;
color: var(--red);
font-weight: 600;
}
.meta-panel label,
.telegram-target-fields,
.meta-stack {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.telegram-target-fields {
grid-column: 2;
grid-row: 1 / span 2;
gap: 0.5rem;
}
.meta-stack {
grid-column: 3;
grid-row: 1 / span 2;
align-self: stretch;
justify-content: space-between;
}
.meta-schedule-field {
grid-column: 4;
grid-row: 1;
}
.meta-delete-service-field {
grid-column: 4;
grid-row: 2;
align-self: start;
}
.meta-panel label.meta-checkbox {
flex-direction: row;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.meta-panel label.meta-checkbox input[type="checkbox"] {
align-self: center;
}
.top-actions,
.image-controls,
.image-paste-controls,
.format-toolbar {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.top-actions,
.image-controls,
.image-paste-controls {
margin-top: 0.5rem;
margin-bottom: 0.75rem;
}
.image-import-modal {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: color-mix(in srgb, CanvasText 24%, transparent);
z-index: 20;
}
.image-import-modal[hidden] {
display: none;
}
.image-import-dialog {
width: min(560px, 100%);
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--border);
background: var(--panel);
box-shadow: 0 18px 48px color-mix(in srgb, CanvasText 24%, transparent);
}
.image-import-header,
.image-import-actions,
.telegram-cell-editor,
.telegram-cell-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.image-import-header,
.image-import-actions {
justify-content: space-between;
}
.image-import-header h2,
.image-import-help {
margin: 0;
}
.image-import-dialog label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.top-actions {
align-items: flex-start;
}
.top-actions button,
.top-actions .button-link {
padding: 0.3rem 0.55rem;
line-height: 1.2;
min-height: 0;
white-space: pre-line;
}
.button-link {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
color: inherit;
text-decoration: none;
background: ButtonFace;
}
.permission-disabled {
background-image:
linear-gradient(
45deg,
transparent calc(50% - 1px),
color-mix(in srgb, var(--red) 72%, transparent) calc(50% - 1px),
color-mix(in srgb, var(--red) 72%, transparent) calc(50% + 1px),
transparent calc(50% + 1px)
),
linear-gradient(
-45deg,
transparent calc(50% - 1px),
color-mix(in srgb, var(--red) 72%, transparent) calc(50% - 1px),
color-mix(in srgb, var(--red) 72%, transparent) calc(50% + 1px),
transparent calc(50% + 1px)
);
background-repeat: no-repeat;
background-size: 100% 100%;
border-color: color-mix(in srgb, var(--red) 60%, var(--border) 40%);
}
textarea.permission-disabled,
input.permission-disabled,
select.permission-disabled,
button.permission-disabled,
.button-link.permission-disabled {
background-origin: border-box;
}
.upload-label {
align-self: center;
font-size: 0.95rem;
}
.images-panel > h2 {
margin: 0 0 1rem;
}
.status-box,
.images-panel,
.language-panel,
.instructions-panel,
.preview-panel,
.pinning-panel {
border: 1px solid var(--border);
padding: 1rem;
margin-bottom: 1rem;
}
.instruction-section + .instruction-section {
margin-top: 1rem;
}
.language-panel.disabled {
opacity: 0.85;
}
.language-panel.disabled .language-body {
display: none;
}
.image-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.image-card {
display: flex;
flex-direction: column;
width: 140px;
border: 1px solid var(--border);
padding: 0.5rem;
background: var(--panel);
}
.image-card img {
max-width: 100%;
max-height: 140px;
width: auto;
height: auto;
object-fit: contain;
display: block;
margin: 0 auto 0.5rem;
}
.move-controls {
display: flex;
gap: 0.5rem;
margin: auto 0 0.5rem;
}
.move-controls button {
flex: 1;
}
.image-card button {
width: 100%;
}
.status-box {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.status-box-content {
flex: 1 1 24rem;
min-width: 0;
white-space: pre-wrap;
}
.status-box > button {
margin-left: auto;
align-self: flex-end;
flex: 0 0 auto;
}
.telegram-binding-panel h2,
.telegram-binding-help {
margin: 0 0 0.75rem;
}
.telegram-binding-body {
overflow-x: auto;
}
.telegram-mapping-table {
display: inline-table;
width: max-content;
border-collapse: collapse;
table-layout: fixed;
margin-bottom: 0.75rem;
}
.telegram-mapping-row {
border-top: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
}
.telegram-mapping-table th,
.telegram-mapping-table td {
padding: 0.55rem 0.65rem;
text-align: left;
vertical-align: middle;
border-right: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
}
.telegram-mapping-table th:last-child,
.telegram-mapping-table td:last-child {
border-right: none;
}
.telegram-mapping-label {
white-space: nowrap;
width: 1%;
}
.telegram-mapping-table thead th {
font-weight: 700;
border-bottom: 1px solid var(--border);
}
.telegram-mapping-col-link {
width: 12.5rem;
}
.telegram-mapping-cell {
min-width: 0;
}
.telegram-cell-editor,
.telegram-cell-actions {
min-width: 0;
flex-wrap: nowrap;
}
.telegram-cell-editor input {
flex: 1 1 auto;
min-width: 0;
}
.telegram-cell-actions .button-link,
.telegram-cell-actions button,
.telegram-cell-editor button {
white-space: nowrap;
}
.telegram-delete-button {
margin-top: 0.25rem;
}
.pinning-controls {
margin-bottom: 1rem;
}
.unpin-scheduler {
display: flex;
gap: 0.75rem;
align-items: end;
flex-wrap: wrap;
}
.unpin-scheduler label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.preview-panel {
margin-bottom: 0;
background: color-mix(in srgb, var(--panel) 96%, CanvasText 4%);
}
.editor-preview-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
}
.preview-content a {
color: var(--blue);
}
.preview-content {
white-space: pre-wrap;
line-height: 1.45;
}
.preview-content pre {
padding: 0.75rem;
overflow-x: auto;
border: 1px solid var(--border);
background: color-mix(in srgb, Canvas 92%, CanvasText 8%);
border-radius: 0.35rem;
}
.preview-content code {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
background: color-mix(in srgb, Canvas 88%, CanvasText 12%);
border: 1px solid color-mix(in srgb, var(--border) 85%, CanvasText 15%);
border-radius: 0.25rem;
padding: 0.08rem 0.28rem;
}
.preview-content pre code {
background: transparent;
border: 0;
border-radius: 0;
padding: 0;
}
.preview-content tg-spoiler {
color: color-mix(in srgb, var(--fg) 70%, var(--bg) 30%);
background: color-mix(in srgb, var(--fg) 10%, var(--bg) 90%);
border: 1px dashed color-mix(in srgb, var(--border) 65%, var(--fg) 35%);
border-radius: 0.25rem;
padding: 0.08rem 0.28rem;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.preview-content blockquote {
margin: 0.75rem 0;
padding-left: 0.75rem;
border-left: 3px solid var(--border);
opacity: 0.9;
}
@media (max-width: 1200px) {
.meta-panel {
grid-template-columns: minmax(240px, 1fr) minmax(220px, 1fr);
}
.meta-title-field,
.meta-global-link-preview,
.telegram-target-fields,
.meta-stack,
.meta-schedule-field,
.meta-delete-service-field {
grid-column: auto;
grid-row: auto;
}
}
@media (max-width: 760px) {
.meta-panel {
grid-template-columns: 1fr;
}
.editor-preview-layout {
grid-template-columns: 1fr;
}
}
body.busy {
cursor: progress;
}
.language-toolbar {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.75rem;
}
.language-heading {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.language-title-toggle {
display: inline-flex;
align-items: center;
gap: 0.55rem;
width: fit-content;
cursor: pointer;
}
.language-title-toggle h2 {
margin: 0;
}
.language-subtoggle {
margin-left: 1.9rem;
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
white-space: nowrap;
}
.secondary-button {
padding: 0.35rem 0.7rem;
}
.editor-input-panel {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.format-toolbar button {
padding: 0.3rem 0.55rem;
}
textarea {
width: 100%;
min-height: 240px;
resize: none;
overflow-y: hidden;
}
.danger {
border-color: var(--red);
}
.login-page {
display: grid;
place-items: center;
min-height: 100vh;
}
.login-card {
width: 320px;
border: 1px solid var(--border);
padding: 1rem;
background: var(--panel);
}
.login-card form,
.login-card label {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.login-language-form {
margin-bottom: 1rem;
}
.error {
color: var(--red);
}
@media (max-width: 900px) {
body {
overflow: auto;
}
.layout {
grid-template-columns: 1fr;
height: auto;
overflow: visible;
}
.sidebar,
.content {
overflow: visible;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--border);
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because it is too large Load Diff
+184
View File
@@ -0,0 +1,184 @@
import json
from dataclasses import dataclass
from pathlib import Path
from linkki_poster.models import Config
from webapp.config import ConfigFileError
@dataclass(frozen=True)
class TelegramChatTarget:
chat_id: str
name: str
channel_username: str
discussion_group_id: str
is_default: bool
@dataclass(frozen=True)
class TelegramBotTarget:
bot_id: str
name: str
token: str
chats: list[TelegramChatTarget]
is_default: bool
@dataclass(frozen=True)
class TelegramTargetCatalog:
bots: list[TelegramBotTarget]
default_bot_id: str
def load_telegram_target_catalog(config_dir: Path) -> TelegramTargetCatalog:
settings_file = config_dir / "telegram_config.json"
if not settings_file.exists():
return TelegramTargetCatalog(bots=[], default_bot_id="")
try:
payload = json.loads(settings_file.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise ConfigFileError(settings_file, f"Invalid JSON: {error.msg}") from error
if not isinstance(payload, dict) or not isinstance(payload.get("bots"), list):
raise ConfigFileError(settings_file, "Expected a top-level 'bots' list.")
bots = _load_multi_bot_catalog(payload)
default_bot_id = ""
for bot in bots:
if bot.is_default:
default_bot_id = bot.bot_id
break
return TelegramTargetCatalog(bots=bots, default_bot_id=default_bot_id)
def resolve_target_config(catalog: TelegramTargetCatalog, bot_id: str, chat_id: str) -> Config:
bot = next((item for item in catalog.bots if item.bot_id == bot_id), None)
if bot is None:
raise ValueError("Choose a Telegram bot before posting or scheduling.")
chat = next((item for item in bot.chats if item.chat_id == chat_id), None)
if chat is None:
raise ValueError("Choose a Telegram chat before posting or scheduling.")
return Config(
bot_token=bot.token,
chat_id=chat.chat_id,
channel_username=chat.channel_username,
discussion_group_id=chat.discussion_group_id,
)
def resolve_poster_config(config_dir: Path, bot_id: str, chat_id: str) -> Config:
return resolve_target_config(load_telegram_target_catalog(config_dir), bot_id, chat_id)
def default_target_selection(catalog: TelegramTargetCatalog) -> tuple[str, str]:
if not catalog.default_bot_id:
return "", ""
bot = next((item for item in catalog.bots if item.bot_id == catalog.default_bot_id), None)
if bot is None:
return "", ""
for chat in bot.chats:
if chat.is_default:
return bot.bot_id, chat.chat_id
return bot.bot_id, ""
def serialise_catalog_for_client(catalog: TelegramTargetCatalog) -> dict:
return {
"default_bot_id": catalog.default_bot_id,
"bots": [
{
"id": bot.bot_id,
"name": bot.name,
"default": bot.is_default,
"default_chat_id": next((chat.chat_id for chat in bot.chats if chat.is_default), ""),
"chats": [
{
"chat_id": chat.chat_id,
"name": chat.name,
"has_discussion_group": bool(chat.discussion_group_id),
"channel_username": chat.channel_username,
"channel_internal_id": extract_internal_chat_id(chat.chat_id),
"discussion_group_internal_id": extract_internal_chat_id(chat.discussion_group_id),
"default": chat.is_default,
}
for chat in bot.chats
],
}
for bot in catalog.bots
],
}
def resolve_target_labels(catalog: TelegramTargetCatalog, bot_id: str, chat_id: str) -> tuple[str, str]:
bot_label = bot_id
chat_label = chat_id
for bot in catalog.bots:
if bot.bot_id != bot_id:
continue
bot_label = bot.name
for chat in bot.chats:
if chat.chat_id == chat_id:
chat_label = chat.name
break
break
return bot_label, chat_label
def extract_internal_chat_id(chat_id: str) -> str:
chat_id = str(chat_id).strip()
if chat_id.startswith("-100") and len(chat_id) > 4:
return chat_id[4:]
return chat_id.lstrip("-")
def _load_multi_bot_catalog(payload: dict) -> list[TelegramBotTarget]:
bots: list[TelegramBotTarget] = []
for bot_payload in payload.get("bots", []):
if not isinstance(bot_payload, dict):
continue
name = str(bot_payload.get("display_name", "")).strip()
token = str(bot_payload.get("token", "")).strip()
bot_id = str(bot_payload.get("id", "")).strip() or token.split(":", 1)[0].strip()
if not name or not token or not bot_id:
continue
chats = _load_chats(bot_payload.get("channels", []))
if not chats:
continue
bots.append(
TelegramBotTarget(
bot_id=bot_id,
name=name,
token=token,
chats=chats,
is_default=bool(bot_payload.get("default", False)),
)
)
return bots
def _load_chats(raw_chats: object) -> list[TelegramChatTarget]:
chats: list[TelegramChatTarget] = []
if not isinstance(raw_chats, list):
return chats
for chat_payload in raw_chats:
if not isinstance(chat_payload, dict):
continue
name = str(chat_payload.get("display_name", "")).strip()
chat_id = str(chat_payload.get("channel_id", "")).strip()
if not name or not chat_id:
continue
chats.append(
TelegramChatTarget(
chat_id=chat_id,
name=name,
channel_username=str(chat_payload.get("channel_username", "")).strip(),
discussion_group_id=str(chat_payload.get("discussion_group_id", "")).strip(),
is_default=bool(chat_payload.get("default", False)),
)
)
return chats
+121
View File
@@ -0,0 +1,121 @@
<!doctype html>
<html lang="{{ effective_ui_language }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ translations["app.title"] }}</title>
<link rel="icon" type="image/png" href="{{ static_asset_url('favicon.png') }}">
<link rel="stylesheet" href="{{ static_asset_url('app.css') }}">
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-actions">
<a class="sidebar-link {% if current_path == '/' %}active{% endif %}" href="/">{{ translations["nav.instructions"] }}</a>
<form method="post" action="/logout">
<button type="submit">{{ translations["nav.sign_out"] }}</button>
</form>
</div>
<form method="post" action="/language" class="language-form">
<input type="hidden" name="return_to" value="{{ current_path }}">
<label>
<span>{{ translations["nav.interface_language"] }}</span>
<select name="ui_language" onchange="this.form.submit()">
{% for option in language_options %}
<option value="{{ option.code }}" {% if option.code == ui_language %}selected{% endif %}>{{ translations[option.label_key] }}</option>
{% endfor %}
</select>
</label>
</form>
<form method="post" action="/announcements" class="create-form">
<input type="hidden" name="return_to" value="{{ current_path }}">
<input type="text" name="title" placeholder="{{ translations['create.placeholder'] }}" required class="{% if not permissions.save_announcements %}permission-disabled{% endif %}" {% if not permissions.save_announcements %}disabled title="{{ translations['create.permission_denied'] }}"{% endif %}>
<button type="submit" class="{% if not permissions.save_announcements %}permission-disabled{% endif %}" {% if not permissions.save_announcements %}disabled title="{{ translations['create.permission_denied'] }}"{% endif %}>{{ translations["create.button"] }}</button>
</form>
{% if create_error %}
<p class="error">{{ create_error }}</p>
{% endif %}
<nav class="announcement-list">
{% for item in announcements %}
<div class="announcement-entry">
<a class="announcement-item {% if broadcast and item.directory == broadcast.directory %}active{% endif %}" href="/announcements/{{ item.directory }}">
<span class="announcement-title-row">
{% if item.sidebar_has_pins %}
<span class="status-primary-marker status-{{ item.sidebar_pin_status }} status-primary-marker-pinned" aria-hidden="true"></span>
{% endif %}
{% if item.status_symbol %}
<span class="status-symbol status-{{ item.status }} {% if item.sidebar_links_incomplete %}status-symbol-incomplete{% endif %}">{{ item.status_symbol }}</span>
{% endif %}
<span class="announcement-title">{{ item.title }}</span>
</span>
<small>{{ item.target_summary }}</small>
{% for meta_line in item.sidebar_meta_lines %}
<small class="announcement-meta">{{ meta_line }}</small>
{% endfor %}
</a>
<div class="announcement-order-controls">
<form method="post" action="/announcements/{{ item.directory }}/move">
<input type="hidden" name="direction" value="up">
<input type="hidden" name="return_to" value="{{ current_path }}">
<button type="submit" class="{% if not permissions.save_announcements %}permission-disabled{% endif %}" {% if not item.can_move_up or not permissions.save_announcements %}disabled{% endif %}>&uarr;</button>
</form>
<form method="post" action="/announcements/{{ item.directory }}/move">
<input type="hidden" name="direction" value="down">
<input type="hidden" name="return_to" value="{{ current_path }}">
<button type="submit" class="{% if not permissions.save_announcements %}permission-disabled{% endif %}" {% if not item.can_move_down or not permissions.save_announcements %}disabled{% endif %}>&darr;</button>
</form>
</div>
</div>
{% endfor %}
</nav>
</aside>
<main class="content">
{% block content %}{% endblock %}
</main>
</div>
<script>
(function () {
const key = "linkki.sidebar.scrollTop";
const sidebar = document.querySelector(".sidebar");
if (!sidebar) return;
const restore = () => {
const raw = window.sessionStorage.getItem(key);
if (!raw) return;
const scrollTop = Number(raw);
if (!Number.isFinite(scrollTop)) return;
sidebar.scrollTop = scrollTop;
window.requestAnimationFrame(() => {
sidebar.scrollTop = scrollTop;
});
};
const persist = () => {
window.sessionStorage.setItem(key, String(sidebar.scrollTop));
};
restore();
sidebar.addEventListener("scroll", persist, { passive: true });
window.addEventListener("beforeunload", persist);
document.addEventListener("click", (event) => {
const link = event.target.closest("a");
if (link && link.closest(".sidebar")) {
persist();
}
});
document.addEventListener("submit", (event) => {
const form = event.target;
if (form instanceof HTMLFormElement && form.closest(".sidebar")) {
persist();
}
});
})();
</script>
</body>
</html>
@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block content %}
<section id="editor-root"
data-broadcast='{{ broadcast | tojson }}'
data-permissions='{{ permissions | tojson }}'
data-timezone='{{ timezone }}'
data-translations='{{ translations | tojson }}'>
<header class="editor-header">
<div>
<h1>{{ broadcast.metadata.title }}</h1>
<p>{{ broadcast.directory }}</p>
</div>
<div class="top-actions">
<button type="button" id="save-button">{{ translations["editor.save"] }}</button>
<button type="button" id="publish-button">{{ translations["editor.post_now"] }}</button>
<button type="button" id="schedule-button">{{ translations["editor.schedule"] }}</button>
<button type="button" id="delete-storage-button" class="danger">{{ translations["editor.delete_storage"] }}</button>
</div>
</header>
<section class="editor-tabs">
<div class="editor-tab-list" role="tablist" aria-label="{{ translations['editor.settings_tabs'] }}">
<button type="button" class="editor-tab-button active" id="editor-tab-main" data-tab="main" role="tab" aria-selected="true">{{ translations["editor.tab_main"] }}</button>
<button type="button" class="editor-tab-button" id="editor-tab-telegram" data-tab="telegram" role="tab" aria-selected="false">{{ translations["editor.tab_telegram"] }}</button>
<button type="button" class="editor-tab-button" id="editor-tab-pinning" data-tab="pinning" role="tab" aria-selected="false">{{ translations["editor.tab_pinning"] }}</button>
<button type="button" class="editor-tab-button" id="editor-tab-logs" data-tab="logs" role="tab" aria-selected="false">{{ translations["editor.tab_logs"] }}</button>
</div>
<section class="editor-tab-panel active" id="editor-tab-panel-main" data-tab-panel="main" role="tabpanel" aria-labelledby="editor-tab-main">
<section class="meta-panel">
<label class="meta-title-field">{{ translations["editor.title"] }}
<input type="text" id="title-input" value="{{ broadcast.metadata.title }}">
</label>
<label class="meta-checkbox meta-global-link-preview">
<input type="checkbox" id="disable-link-previews-global">
<span>{{ translations["editor.disable_link_previews_global"] }}</span>
</label>
<div class="telegram-target-fields">
<label>{{ translations["editor.telegram_bot"] }}
<select id="telegram-bot-select"></select>
</label>
<label>{{ translations["editor.telegram_chat"] }}
<select id="telegram-chat-select"></select>
</label>
</div>
<div class="meta-stack">
<label>{{ translations["editor.language_order"] }}
<select id="language-order-select">
<option value="fi,sv,en">{{ translations["editor.language_order.fi_sv_en"] }}</option>
<option value="fi,en,sv">{{ translations["editor.language_order.fi_en_sv"] }}</option>
<option value="sv,fi,en">{{ translations["editor.language_order.sv_fi_en"] }}</option>
<option value="sv,en,fi">{{ translations["editor.language_order.sv_en_fi"] }}</option>
<option value="en,fi,sv">{{ translations["editor.language_order.en_fi_sv"] }}</option>
<option value="en,sv,fi">{{ translations["editor.language_order.en_sv_fi"] }}</option>
</select>
</label>
</div>
<label class="meta-schedule-field">{{ translations["editor.schedule_label"] }}
<input type="datetime-local" id="schedule-input">
<small>{{ translations["editor.schedule_timezone"] }}: {{ timezone }}</small>
</label>
</section>
</section>
<section class="editor-tab-panel" id="editor-tab-panel-telegram" data-tab-panel="telegram" role="tabpanel" aria-labelledby="editor-tab-telegram" hidden>
<section id="telegram-binding-panel" class="telegram-binding-panel"></section>
<button type="button" id="delete-telegram-button" class="danger telegram-delete-button">{{ translations["editor.delete_telegram"] }}</button>
</section>
<section class="editor-tab-panel" id="editor-tab-panel-pinning" data-tab-panel="pinning" role="tabpanel" aria-labelledby="editor-tab-pinning" hidden>
<section class="pinning-panel">
<div class="meta-stack pinning-controls">
<label>{{ translations["editor.discussion_pinning"] }}
<select id="discussion-pinning-select">
<option value="none">{{ translations["editor.discussion_pinning.none"] }}</option>
<option value="first">{{ translations["editor.discussion_pinning.first"] }}</option>
<option value="last">{{ translations["editor.discussion_pinning.last"] }}</option>
<option value="all">{{ translations["editor.discussion_pinning.all"] }}</option>
</select>
</label>
<label class="meta-checkbox meta-delete-service-field">
<input type="checkbox" id="delete-pin-service-messages">
<span>{{ translations["editor.delete_pin_service_messages"] }}</span>
</label>
</div>
<div class="unpin-scheduler">
<label>{{ translations["editor.unpin_schedule_label"] }}
<input type="datetime-local" id="unpin-schedule-input">
</label>
<button type="button" id="unpin-schedule-button">{{ translations["editor.schedule_unpin"] }}</button>
</div>
</section>
</section>
<section class="editor-tab-panel" id="editor-tab-panel-logs" data-tab-panel="logs" role="tabpanel" aria-labelledby="editor-tab-logs" hidden>
<section class="status-box" id="status-box">
<div class="status-box-content" id="status-box-content"></div>
<button type="button" id="clear-status-button">{{ translations["editor.clear_status"] }}</button>
</section>
</section>
</section>
<section class="images-panel">
<h2>{{ translations["editor.global_images"] }}</h2>
<div class="image-controls">
<span class="upload-label">{{ translations["editor.add_global_images"] }}</span>
<input type="file" id="upload-global" accept="image/*" multiple>
</div>
<div class="image-paste-controls">
<button type="button" id="open-global-image-import-button" class="secondary-button">{{ translations["editor.open_image_import"] }}</button>
</div>
<div class="image-list" data-scope="global"></div>
</section>
<section id="languages-container"></section>
</section>
<div id="image-import-modal" class="image-import-modal" hidden>
<div class="image-import-dialog" role="dialog" aria-modal="true" aria-labelledby="image-import-title">
<div class="image-import-header">
<h2 id="image-import-title">{{ translations["editor.image_import_title"] }}</h2>
<button type="button" id="image-import-close-button" class="secondary-button">{{ translations["editor.close_image_import"] }}</button>
</div>
<p class="image-import-help">{{ translations["editor.image_import_help"] }}</p>
<label>{{ translations["editor.image_import_format"] }}
<select id="image-import-format-select">
<option value="auto">{{ translations["editor.image_import_format.auto"] }}</option>
<option value="png">{{ translations["editor.image_import_format.png"] }}</option>
<option value="jpeg">{{ translations["editor.image_import_format.jpeg"] }}</option>
</select>
</label>
<label>{{ translations["editor.image_import_url"] }}
<input type="url" id="image-import-url-input" placeholder="{{ translations['editor.image_import_url_placeholder'] }}">
</label>
<div class="image-import-actions">
<button type="button" id="image-import-submit-url-button">{{ translations["editor.import_image_url_submit"] }}</button>
<button type="button" id="image-import-cancel-button" class="secondary-button">{{ translations["editor.cancel_image_import"] }}</button>
</div>
</div>
</div>
<script src="{{ static_asset_url('app.js') }}"></script>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block content %}
<section class="instructions-panel">
<h1>{{ translations["instructions.title"] }}</h1>
<section class="instruction-section">
<h2>{{ translations["instructions.quick_start_title"] }}</h2>
<ul>
{% for item in translations["instructions.quick_start_items"] %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</section>
<section class="instruction-section">
<h2>{{ translations["instructions.states_title"] }}</h2>
<ul>
{% for item in translations["instructions.states_items"] %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</section>
<section class="instruction-section">
<h2>{{ translations["instructions.formatting_title"] }}</h2>
<ul>
{% for item in translations["instructions.formatting_items"] %}
<li><strong>{{ item.label }}</strong>: <code>{{ item.example }}</code></li>
{% endfor %}
</ul>
</section>
<section class="instruction-section">
<h2>{{ translations["instructions.posted_title"] }}</h2>
<ul>
{% for item in translations["instructions.posted_items"] %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</section>
</section>
{% endblock %}
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="{{ effective_ui_language }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ translations["app.title"] }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="login-page">
<main class="login-card">
<h1>{{ translations["login.title"] }}</h1>
<form method="post" action="/login-language" class="login-language-form">
<label>{{ translations["nav.interface_language"] }}
<select name="ui_language" onchange="this.form.submit()">
{% for option in login_language_options %}
<option value="{{ option.code }}" {% if option.code == login_ui_language %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
</form>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/login">
<label>{{ translations["login.username"] }} <input type="text" name="username" required autofocus></label>
<label>{{ translations["login.password"] }} <input type="password" name="password" required></label>
<button type="submit">{{ translations["login.sign_in"] }}</button>
</form>
</main>
</body>
</html>
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
import uvicorn
if __name__ == "__main__":
uvicorn.run("webapp.app:app", host="0.0.0.0", port=8080, reload=False)