Add Linkki broadcast image
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 39s
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 39s
This commit is contained in:
@@ -82,6 +82,23 @@ jobs:
|
|||||||
PYTHON_VERSION=3
|
PYTHON_VERSION=3
|
||||||
fingerprint_command: |
|
fingerprint_command: |
|
||||||
{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
|
{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
|
||||||
|
- name: linkki-tiedotus
|
||||||
|
context: linkki-tiedotus
|
||||||
|
base_image: python:3.12-slim
|
||||||
|
primary_tag: git.ajpanton.se/ajp_anton/linkki-tiedotus:1
|
||||||
|
oci_labels: |
|
||||||
|
org.opencontainers.image.title=Linkki Telegram Broadcast Editor
|
||||||
|
org.opencontainers.image.description=Web editor and scheduler for multilingual Telegram channel broadcasts.
|
||||||
|
org.opencontainers.image.documentation=https://git.ajpanton.se/ajp_anton/docker-images#linkki-tiedotus
|
||||||
|
org.opencontainers.image.source=https://git.ajpanton.se/ajp_anton/docker-images
|
||||||
|
org.opencontainers.image.url=https://git.ajpanton.se/ajp_anton/-/packages/container/linkki-tiedotus
|
||||||
|
tags: |
|
||||||
|
git.ajpanton.se/ajp_anton/linkki-tiedotus:1
|
||||||
|
git.ajpanton.se/ajp_anton/linkki-tiedotus:latest
|
||||||
|
build_args: |
|
||||||
|
PYTHON_VERSION=3.12-slim
|
||||||
|
fingerprint_command: |
|
||||||
|
{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ a few commonly useful database/runtime extensions.
|
|||||||
| `git.ajpanton.se/ajp_anton/postgres-custom:18` | PostgreSQL 18 with selected extension packages installed and `pg_cron`/VectorChord preloaded. |
|
| `git.ajpanton.se/ajp_anton/postgres-custom:18` | PostgreSQL 18 with selected extension packages installed and `pg_cron`/VectorChord preloaded. |
|
||||||
| `git.ajpanton.se/ajp_anton/php8-pgsql:8` | PHP 8 FPM on Alpine with PostgreSQL client runtime support and the `pdo_pgsql` PHP extension enabled. |
|
| `git.ajpanton.se/ajp_anton/php8-pgsql:8` | PHP 8 FPM on Alpine with PostgreSQL client runtime support and the `pdo_pgsql` PHP extension enabled. |
|
||||||
| `git.ajpanton.se/ajp_anton/python-tools:3` | Python 3 task runner with ExifTool and a web control panel for mounted task scripts. |
|
| `git.ajpanton.se/ajp_anton/python-tools:3` | Python 3 task runner with ExifTool and a web control panel for mounted task scripts. |
|
||||||
|
| `git.ajpanton.se/ajp_anton/linkki-tiedotus:1` | Web editor and scheduler for multilingual Telegram channel broadcasts. |
|
||||||
|
|
||||||
## Included Components
|
## Included Components
|
||||||
|
|
||||||
@@ -99,6 +100,25 @@ Intended use:
|
|||||||
application documentation are in `python-tools/`.
|
application documentation are in `python-tools/`.
|
||||||
- Keep host systems clean by avoiding host-level package installs.
|
- Keep host systems clean by avoiding host-level package installs.
|
||||||
|
|
||||||
|
### `linkki-tiedotus`
|
||||||
|
|
||||||
|
Base image:
|
||||||
|
|
||||||
|
- `python:3.12-slim`
|
||||||
|
- Moving upstream Python 3.12 patch-version tag. Base-image updates are picked
|
||||||
|
up on the next rebuild.
|
||||||
|
|
||||||
|
Included application:
|
||||||
|
|
||||||
|
- FastAPI web editor for drafting, scheduling, publishing, updating, and
|
||||||
|
deleting multilingual Telegram channel broadcasts.
|
||||||
|
- A persistent `/data` directory for configuration, user records, posts,
|
||||||
|
uploaded images, schedules, and publish history.
|
||||||
|
- The default command starts the application on port `8080`.
|
||||||
|
|
||||||
|
The image contains no configuration or broadcast data. Mount `/data` from a
|
||||||
|
persistent host directory and create its configuration from `linkki-tiedotus/config-examples/`.
|
||||||
|
|
||||||
## Tags
|
## Tags
|
||||||
|
|
||||||
Moving deployment tags:
|
Moving deployment tags:
|
||||||
@@ -106,6 +126,7 @@ Moving deployment tags:
|
|||||||
- `git.ajpanton.se/ajp_anton/postgres-custom:18`
|
- `git.ajpanton.se/ajp_anton/postgres-custom:18`
|
||||||
- `git.ajpanton.se/ajp_anton/php8-pgsql:8`
|
- `git.ajpanton.se/ajp_anton/php8-pgsql:8`
|
||||||
- `git.ajpanton.se/ajp_anton/python-tools:3`
|
- `git.ajpanton.se/ajp_anton/python-tools:3`
|
||||||
|
- `git.ajpanton.se/ajp_anton/linkki-tiedotus:1`
|
||||||
|
|
||||||
Descriptive tags:
|
Descriptive tags:
|
||||||
|
|
||||||
@@ -117,6 +138,7 @@ Convenience tags:
|
|||||||
- `git.ajpanton.se/ajp_anton/postgres-custom:latest`
|
- `git.ajpanton.se/ajp_anton/postgres-custom:latest`
|
||||||
- `git.ajpanton.se/ajp_anton/php8-pgsql:latest`
|
- `git.ajpanton.se/ajp_anton/php8-pgsql:latest`
|
||||||
- `git.ajpanton.se/ajp_anton/python-tools:latest`
|
- `git.ajpanton.se/ajp_anton/python-tools:latest`
|
||||||
|
- `git.ajpanton.se/ajp_anton/linkki-tiedotus:latest`
|
||||||
|
|
||||||
Tags are mutable. A weekly candidate build only repoints a deployment tag when
|
Tags are mutable. A weekly candidate build only repoints a deployment tag when
|
||||||
its content fingerprint changed: base image, build context, build arguments,
|
its content fingerprint changed: base image, build context, build arguments,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache
|
||||||
|
.venv
|
||||||
|
.tmp
|
||||||
|
data
|
||||||
|
tmp_test_runs
|
||||||
|
testposts
|
||||||
|
.codex
|
||||||
|
.vscode
|
||||||
|
telegram_config.json
|
||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from json import JSONDecodeError
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from linkki_poster.formatting import validate_markdown_syntax
|
||||||
|
from linkki_poster.models import Config, LANGUAGE_DISPLAY_NAMES, Language, LanguageAssets, ScanResult
|
||||||
|
from linkki_poster.ordering import prompt_language_order
|
||||||
|
from linkki_poster.progress import ProgressDisplay
|
||||||
|
from linkki_poster.scanner import scan_directory
|
||||||
|
from linkki_poster.telegram_api import TelegramAPI
|
||||||
|
from linkki_poster.workflow import post_assets
|
||||||
|
|
||||||
|
|
||||||
|
LANGUAGE_ORDER_ALIASES: dict[str, Language] = {
|
||||||
|
"fi": Language.FI,
|
||||||
|
"finnish": Language.FI,
|
||||||
|
"suomi": Language.FI,
|
||||||
|
"sv": Language.SV,
|
||||||
|
"swedish": Language.SV,
|
||||||
|
"svenska": Language.SV,
|
||||||
|
"ruotsi": Language.SV,
|
||||||
|
"en": Language.EN,
|
||||||
|
"english": Language.EN,
|
||||||
|
"engelska": Language.EN,
|
||||||
|
"englanti": Language.EN,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_cli(script_dir: Path | None = None, argv: list[str] | None = None, input_fn=input) -> None:
|
||||||
|
effective_script_dir = script_dir or Path(__file__).resolve().parent.parent
|
||||||
|
args = parse_args(argv)
|
||||||
|
target_directory = get_target_directory(
|
||||||
|
script_dir=effective_script_dir,
|
||||||
|
raw_directory=args.directory,
|
||||||
|
non_interactive=args.non_interactive,
|
||||||
|
input_fn=input_fn,
|
||||||
|
)
|
||||||
|
|
||||||
|
initial_scan_result = scan_directory(target_directory)
|
||||||
|
ordered_languages = determine_language_order(
|
||||||
|
initial_scan_result.languages,
|
||||||
|
args.order,
|
||||||
|
args.non_interactive,
|
||||||
|
input_fn,
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
scan_result = scan_directory(target_directory)
|
||||||
|
effective_scan_result = ScanResult(
|
||||||
|
global_images=scan_result.global_images,
|
||||||
|
languages=reapply_language_order(scan_result.languages, ordered_languages),
|
||||||
|
)
|
||||||
|
|
||||||
|
print_scan_summary(effective_scan_result)
|
||||||
|
if not effective_scan_result.global_images and not effective_scan_result.languages:
|
||||||
|
print("Nothing to post.")
|
||||||
|
return
|
||||||
|
|
||||||
|
syntax_issues = collect_syntax_issues(effective_scan_result.languages)
|
||||||
|
if not syntax_issues:
|
||||||
|
break
|
||||||
|
|
||||||
|
print_syntax_issues(syntax_issues)
|
||||||
|
if args.allow_invalid_syntax:
|
||||||
|
break
|
||||||
|
if args.non_interactive:
|
||||||
|
raise ValueError("Broken markdown syntax detected.")
|
||||||
|
|
||||||
|
action = prompt_syntax_action(input_fn)
|
||||||
|
if action == "post":
|
||||||
|
break
|
||||||
|
|
||||||
|
config = load_config(
|
||||||
|
script_dir=effective_script_dir,
|
||||||
|
non_interactive=args.non_interactive,
|
||||||
|
input_fn=input_fn,
|
||||||
|
)
|
||||||
|
api = TelegramAPI(config.bot_token)
|
||||||
|
progress = ProgressDisplay()
|
||||||
|
|
||||||
|
posted_refs = post_assets(
|
||||||
|
api=api,
|
||||||
|
config=config,
|
||||||
|
global_images=effective_scan_result.global_images,
|
||||||
|
ordered_languages=effective_scan_result.languages,
|
||||||
|
progress=progress,
|
||||||
|
)
|
||||||
|
if posted_refs:
|
||||||
|
print("Posted language messages:")
|
||||||
|
for posted_ref in posted_refs:
|
||||||
|
print(f"- {LANGUAGE_DISPLAY_NAMES[posted_ref.language]}: {posted_ref.message_url}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Post a multilingual Telegram broadcast from one asset directory.")
|
||||||
|
parser.add_argument(
|
||||||
|
"directory",
|
||||||
|
nargs="?",
|
||||||
|
help="Directory to scan. Relative paths are resolved from the script directory.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--order",
|
||||||
|
help="Optional language order, for example: fi,sv,en or finnish,english.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--allow-invalid-syntax",
|
||||||
|
action="store_true",
|
||||||
|
help="Post even if supported markdown syntax appears broken.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--non-interactive",
|
||||||
|
action="store_true",
|
||||||
|
help="Never prompt. Fail immediately when required input is missing or invalid.",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def get_target_directory(
|
||||||
|
script_dir: Path,
|
||||||
|
raw_directory: str | None,
|
||||||
|
non_interactive: bool,
|
||||||
|
input_fn=input,
|
||||||
|
) -> Path:
|
||||||
|
if raw_directory is not None:
|
||||||
|
return resolve_target_directory(script_dir, raw_directory)
|
||||||
|
if non_interactive:
|
||||||
|
raise ValueError("Directory argument is required in non-interactive mode.")
|
||||||
|
return prompt_target_directory(script_dir, input_fn)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_target_directory(script_dir: Path, raw_value: str) -> Path:
|
||||||
|
candidate = Path(raw_value).expanduser()
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
candidate = (script_dir / candidate).resolve()
|
||||||
|
if not candidate.is_dir():
|
||||||
|
raise FileNotFoundError(f"Directory not found: {candidate}")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_target_directory(script_dir: Path, input_fn=input) -> Path:
|
||||||
|
while True:
|
||||||
|
raw_value = input_fn("Directory to scan: ").strip()
|
||||||
|
if raw_value == "":
|
||||||
|
print("Value cannot be empty.")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return resolve_target_directory(script_dir, raw_value)
|
||||||
|
except FileNotFoundError as error:
|
||||||
|
print(error)
|
||||||
|
|
||||||
|
|
||||||
|
def determine_language_order(
|
||||||
|
languages: list[LanguageAssets],
|
||||||
|
raw_order: str | None,
|
||||||
|
non_interactive: bool,
|
||||||
|
input_fn=input,
|
||||||
|
) -> list[LanguageAssets]:
|
||||||
|
if raw_order is not None:
|
||||||
|
return apply_language_order(languages, raw_order)
|
||||||
|
if non_interactive:
|
||||||
|
return languages
|
||||||
|
return prompt_language_order(languages, input_fn=input_fn)
|
||||||
|
|
||||||
|
|
||||||
|
def reapply_language_order(
|
||||||
|
current_languages: list[LanguageAssets],
|
||||||
|
ordered_template: list[LanguageAssets],
|
||||||
|
) -> list[LanguageAssets]:
|
||||||
|
current_by_language = {language_assets.language: language_assets for language_assets in current_languages}
|
||||||
|
ordered: list[LanguageAssets] = []
|
||||||
|
seen: set[Language] = set()
|
||||||
|
|
||||||
|
for language_assets in ordered_template:
|
||||||
|
current = current_by_language.get(language_assets.language)
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
ordered.append(current)
|
||||||
|
seen.add(current.language)
|
||||||
|
|
||||||
|
for language_assets in current_languages:
|
||||||
|
if language_assets.language not in seen:
|
||||||
|
ordered.append(language_assets)
|
||||||
|
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def apply_language_order(languages: list[LanguageAssets], raw_order: str) -> list[LanguageAssets]:
|
||||||
|
requested_languages = parse_language_order(raw_order)
|
||||||
|
language_by_code = {language_assets.language: language_assets for language_assets in languages}
|
||||||
|
seen: set[Language] = set()
|
||||||
|
ordered: list[LanguageAssets] = []
|
||||||
|
|
||||||
|
for language in requested_languages:
|
||||||
|
if language in seen:
|
||||||
|
raise ValueError(f"Duplicate language in --order: {language.value}")
|
||||||
|
seen.add(language)
|
||||||
|
if language in language_by_code:
|
||||||
|
ordered.append(language_by_code[language])
|
||||||
|
|
||||||
|
remaining = [language_assets for language_assets in languages if language_assets.language not in seen]
|
||||||
|
return [*ordered, *remaining]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_language_order(raw_order: str) -> list[Language]:
|
||||||
|
tokens = [token.strip().lower() for token in raw_order.replace(";", ",").split(",") if token.strip()]
|
||||||
|
if not tokens:
|
||||||
|
raise ValueError("Language order cannot be empty.")
|
||||||
|
parsed_languages: list[Language] = []
|
||||||
|
for token in tokens:
|
||||||
|
language = LANGUAGE_ORDER_ALIASES.get(token)
|
||||||
|
if language is None:
|
||||||
|
raise ValueError(f"Unknown language in --order: {token}")
|
||||||
|
parsed_languages.append(language)
|
||||||
|
return parsed_languages
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(script_dir: Path, non_interactive: bool, input_fn=input) -> Config:
|
||||||
|
config_path = script_dir / "telegram_config.json"
|
||||||
|
config_data: dict[str, str] = {}
|
||||||
|
|
||||||
|
if config_path.exists():
|
||||||
|
raw_text = config_path.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
loaded = json.loads(raw_text)
|
||||||
|
except JSONDecodeError as error:
|
||||||
|
if non_interactive:
|
||||||
|
raise ValueError(f"Invalid JSON in {config_path}: {error.msg}") from error
|
||||||
|
print(f"Invalid JSON in {config_path}: {error.msg}")
|
||||||
|
loaded = {}
|
||||||
|
if isinstance(loaded, dict):
|
||||||
|
config_data = {str(key): str(value) for key, value in loaded.items()}
|
||||||
|
elif non_interactive:
|
||||||
|
raise FileNotFoundError(f"Missing config file: {config_path}")
|
||||||
|
|
||||||
|
bot_token = resolve_config_value(
|
||||||
|
config_data=config_data,
|
||||||
|
key="bot_token",
|
||||||
|
prompt="Bot token: ",
|
||||||
|
non_interactive=non_interactive,
|
||||||
|
input_fn=input_fn,
|
||||||
|
)
|
||||||
|
chat_id = resolve_config_value(
|
||||||
|
config_data=config_data,
|
||||||
|
key="chat_id",
|
||||||
|
prompt="Chat ID: ",
|
||||||
|
non_interactive=non_interactive,
|
||||||
|
input_fn=input_fn,
|
||||||
|
)
|
||||||
|
channel_username = resolve_optional_config_value(
|
||||||
|
config_data=config_data,
|
||||||
|
key="channel_username",
|
||||||
|
prompt="Channel username (optional, for public links): ",
|
||||||
|
non_interactive=non_interactive,
|
||||||
|
input_fn=input_fn,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Config(bot_token=bot_token, chat_id=chat_id, channel_username=channel_username)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config_strict(script_dir: Path) -> Config:
|
||||||
|
return load_config(script_dir=script_dir, non_interactive=True)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_config_value(
|
||||||
|
config_data: dict[str, str],
|
||||||
|
key: str,
|
||||||
|
prompt: str,
|
||||||
|
non_interactive: bool,
|
||||||
|
input_fn=input,
|
||||||
|
) -> str:
|
||||||
|
existing = config_data.get(key, "").strip()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
if non_interactive:
|
||||||
|
raise ValueError(f"Config field '{key}' is required.")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
entered = input_fn(prompt).strip()
|
||||||
|
if entered:
|
||||||
|
return entered
|
||||||
|
print("Value cannot be empty.")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_optional_config_value(
|
||||||
|
config_data: dict[str, str],
|
||||||
|
key: str,
|
||||||
|
prompt: str,
|
||||||
|
non_interactive: bool,
|
||||||
|
input_fn=input,
|
||||||
|
) -> str:
|
||||||
|
existing = config_data.get(key)
|
||||||
|
if existing is not None:
|
||||||
|
return existing.strip()
|
||||||
|
if non_interactive:
|
||||||
|
return ""
|
||||||
|
return input_fn(prompt).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def collect_syntax_issues(languages: list[LanguageAssets]) -> dict[Language, list[str]]:
|
||||||
|
issues_by_language: dict[Language, list[str]] = {}
|
||||||
|
for language_assets in languages:
|
||||||
|
issues = validate_markdown_syntax(language_assets.text_raw)
|
||||||
|
if issues:
|
||||||
|
issues_by_language[language_assets.language] = [
|
||||||
|
f"{language_assets.text_file.name}: {issue.message}" for issue in issues
|
||||||
|
]
|
||||||
|
return issues_by_language
|
||||||
|
|
||||||
|
|
||||||
|
def print_syntax_issues(issues_by_language: dict[Language, list[str]]) -> None:
|
||||||
|
print("Potential markdown syntax issues detected:")
|
||||||
|
for language in (Language.FI, Language.SV, Language.EN):
|
||||||
|
issues = issues_by_language.get(language)
|
||||||
|
if not issues:
|
||||||
|
continue
|
||||||
|
print(f"- {LANGUAGE_DISPLAY_NAMES[language]}")
|
||||||
|
for issue in issues:
|
||||||
|
print(f" - {issue}")
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_syntax_action(input_fn=input) -> str:
|
||||||
|
while True:
|
||||||
|
answer = input_fn("Post anyway or retry after fixing files? [p/r]: ").strip().lower()
|
||||||
|
if answer in {"p", "post"}:
|
||||||
|
return "post"
|
||||||
|
if answer in {"r", "retry"}:
|
||||||
|
return "retry"
|
||||||
|
print("Enter 'p' to post anyway or 'r' to retry.")
|
||||||
|
|
||||||
|
|
||||||
|
def print_scan_summary(scan_result: ScanResult) -> None:
|
||||||
|
print("Scan summary:")
|
||||||
|
if scan_result.global_images:
|
||||||
|
print("Global images:")
|
||||||
|
for image_path in scan_result.global_images:
|
||||||
|
print(f"- {image_path.name}")
|
||||||
|
else:
|
||||||
|
print("Global images: none")
|
||||||
|
|
||||||
|
if not scan_result.languages:
|
||||||
|
print("Languages: none (no language text files found)")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Languages:")
|
||||||
|
for index, language_assets in enumerate(scan_result.languages, start=1):
|
||||||
|
print(f"{index}. {LANGUAGE_DISPLAY_NAMES[language_assets.language]}")
|
||||||
|
print(f" Text: {language_assets.text_file.name}")
|
||||||
|
if language_assets.images:
|
||||||
|
print(" Images:")
|
||||||
|
for image_path in language_assets.images:
|
||||||
|
print(f" - {image_path.name}")
|
||||||
|
else:
|
||||||
|
print(" Images: none")
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import html
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from linkki_poster.models import Language, PlaceholderToken
|
||||||
|
|
||||||
|
|
||||||
|
CAPTION_LIMIT = 1024
|
||||||
|
_FAKE_MESSAGE_ID = "9" * 20
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParsedTemplate:
|
||||||
|
html_template: str
|
||||||
|
placeholders: list[PlaceholderToken]
|
||||||
|
markers: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SyntaxIssue:
|
||||||
|
marker: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_markdown_template(raw_text: str) -> ParsedTemplate:
|
||||||
|
text_with_markers, placeholders, markers = extract_language_placeholders(raw_text)
|
||||||
|
html_template = markdown_subset_to_html(text_with_markers)
|
||||||
|
return ParsedTemplate(html_template=html_template, placeholders=placeholders, markers=markers)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_language_placeholders(raw_text: str) -> tuple[str, list[PlaceholderToken], list[str]]:
|
||||||
|
pattern = re.compile(
|
||||||
|
r"(?<!\\)\[(?P<label>(?:\\.|[^\]\\])*)\](?<!\\)\((?P<target>fi|sv|en)\)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
placeholders: list[PlaceholderToken] = []
|
||||||
|
markers: list[str] = []
|
||||||
|
chunks: list[str] = []
|
||||||
|
start_index = 0
|
||||||
|
|
||||||
|
for placeholder_index, match in enumerate(pattern.finditer(raw_text)):
|
||||||
|
marker = f"ZZPHTOKEN{placeholder_index}ZZ"
|
||||||
|
target_language = Language(match.group("target").lower())
|
||||||
|
placeholders.append(
|
||||||
|
PlaceholderToken(
|
||||||
|
label=match.group("label"),
|
||||||
|
target_lang=target_language,
|
||||||
|
source_span=(match.start(), match.end()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
markers.append(marker)
|
||||||
|
chunks.append(raw_text[start_index : match.start()])
|
||||||
|
chunks.append(marker)
|
||||||
|
start_index = match.end()
|
||||||
|
|
||||||
|
chunks.append(raw_text[start_index:])
|
||||||
|
return "".join(chunks), placeholders, markers
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(parsed_template: ParsedTemplate, language_links: dict[Language, str]) -> str:
|
||||||
|
rendered = parsed_template.html_template
|
||||||
|
for marker, placeholder in zip(parsed_template.markers, parsed_template.placeholders):
|
||||||
|
target_url = language_links.get(placeholder.target_lang)
|
||||||
|
if target_url is None:
|
||||||
|
replacement = html.escape(placeholder.label, quote=False)
|
||||||
|
else:
|
||||||
|
escaped_url = html.escape(target_url, quote=True)
|
||||||
|
escaped_label = html.escape(placeholder.label, quote=False)
|
||||||
|
replacement = f'<a href="{escaped_url}">{escaped_label}</a>'
|
||||||
|
rendered = rendered.replace(marker, replacement)
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
def render_preview_html(raw_text: str, available_targets: set[Language]) -> str:
|
||||||
|
parsed_template = parse_markdown_template(raw_text)
|
||||||
|
fake_links = {language: f"#{language.value}" for language in available_targets}
|
||||||
|
return render_template(parsed_template, fake_links)
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_caption_length(parsed_template: ParsedTemplate, normalized_channel_username: str) -> int:
|
||||||
|
fake_url = f"https://t.me/{normalized_channel_username}/{_FAKE_MESSAGE_ID}"
|
||||||
|
fake_links = {
|
||||||
|
Language.FI: fake_url,
|
||||||
|
Language.SV: fake_url,
|
||||||
|
Language.EN: fake_url,
|
||||||
|
}
|
||||||
|
rendered = render_template(parsed_template, fake_links)
|
||||||
|
return len(rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_subset_to_html(markdown_text: str) -> str:
|
||||||
|
text_with_escape_tokens, escape_token_values = _stash_escaped_literals(markdown_text)
|
||||||
|
text_with_code_tokens, code_token_values = _stash_code_segments(text_with_escape_tokens)
|
||||||
|
escaped_text = html.escape(text_with_code_tokens, quote=False)
|
||||||
|
escaped_text = _map_blockquotes(escaped_text)
|
||||||
|
escaped_text = _map_headings(escaped_text)
|
||||||
|
escaped_text = _map_lists(escaped_text)
|
||||||
|
escaped_text = _map_standard_links(escaped_text)
|
||||||
|
escaped_text = _map_style_pairs(escaped_text, r"\|\|([\s\S]+?)\|\|", "tg-spoiler")
|
||||||
|
escaped_text = _map_style_pairs(escaped_text, r"\*\*(.+?)\*\*", "b")
|
||||||
|
escaped_text = _map_style_pairs(escaped_text, r"__(.+?)__", "b")
|
||||||
|
escaped_text = _map_style_pairs(escaped_text, r"\+\+(.+?)\+\+", "u")
|
||||||
|
escaped_text = _map_style_pairs(escaped_text, r"~~(.+?)~~", "s")
|
||||||
|
escaped_text = _map_style_pairs(escaped_text, r"(?<!\*)\*([^*\n]+)\*(?!\*)", "i")
|
||||||
|
escaped_text = _map_style_pairs(escaped_text, r"(?<!_)_([^_\n]+)_(?!_)", "i")
|
||||||
|
for token, value in escape_token_values.items():
|
||||||
|
escaped_text = escaped_text.replace(token, value)
|
||||||
|
for token, value in code_token_values.items():
|
||||||
|
escaped_text = escaped_text.replace(token, value)
|
||||||
|
return escaped_text
|
||||||
|
|
||||||
|
|
||||||
|
def _stash_code_segments(markdown_text: str) -> tuple[str, dict[str, str]]:
|
||||||
|
token_values: dict[str, str] = {}
|
||||||
|
|
||||||
|
def replace_code_block(match: re.Match[str]) -> str:
|
||||||
|
token = f"ZZCODEBLOCK{len(token_values)}ZZ"
|
||||||
|
code = html.escape(match.group(1), quote=False)
|
||||||
|
token_values[token] = f"<pre><code>{code}</code></pre>"
|
||||||
|
return token
|
||||||
|
|
||||||
|
code_block_pattern = re.compile(r"```(?:[^\n`]*)\n?(.*?)```", re.DOTALL)
|
||||||
|
text = code_block_pattern.sub(replace_code_block, markdown_text)
|
||||||
|
|
||||||
|
def replace_inline_code(match: re.Match[str]) -> str:
|
||||||
|
token = f"ZZINLINECODE{len(token_values)}ZZ"
|
||||||
|
code = html.escape(match.group(1), quote=False)
|
||||||
|
token_values[token] = f"<code>{code}</code>"
|
||||||
|
return token
|
||||||
|
|
||||||
|
inline_code_pattern = re.compile(r"`([^`\n]+)`")
|
||||||
|
text = inline_code_pattern.sub(replace_inline_code, text)
|
||||||
|
return text, token_values
|
||||||
|
|
||||||
|
|
||||||
|
def _stash_escaped_literals(markdown_text: str) -> tuple[str, dict[str, str]]:
|
||||||
|
token_values: dict[str, str] = {}
|
||||||
|
|
||||||
|
def replace_escape(match: re.Match[str]) -> str:
|
||||||
|
token = f"ZZESCAPED{len(token_values)}ZZ"
|
||||||
|
token_values[token] = html.escape(match.group(1), quote=False)
|
||||||
|
return token
|
||||||
|
|
||||||
|
text = re.sub(r"\\([\\`*_+\-~|.\[\]()#>])", replace_escape, markdown_text)
|
||||||
|
return text, token_values
|
||||||
|
|
||||||
|
|
||||||
|
def _map_headings(text: str) -> str:
|
||||||
|
mapped_lines: list[str] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
heading_match = re.match(r"^(#{1,6})\s*(.+)$", line)
|
||||||
|
if heading_match is None:
|
||||||
|
mapped_lines.append(line)
|
||||||
|
continue
|
||||||
|
mapped_lines.append(f"<b>{heading_match.group(2).upper()}</b>")
|
||||||
|
return "\n".join(mapped_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_lists(text: str) -> str:
|
||||||
|
mapped_lines: list[str] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
unordered_match = re.match(r"^(?P<indent>[ \t]*)(?P<marker>[-+*])\s+(?P<content>.+)$", line)
|
||||||
|
if unordered_match is not None:
|
||||||
|
level = _indent_level(unordered_match.group("indent"))
|
||||||
|
mapped_lines.append(f"{_list_indent(level)}\u2022 {unordered_match.group('content')}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
ordered_match = re.match(r"^(?P<indent>[ \t]*)(?P<number>\d+)\.\s+(?P<content>.+)$", line)
|
||||||
|
if ordered_match is not None:
|
||||||
|
level = _indent_level(ordered_match.group("indent"))
|
||||||
|
mapped_lines.append(
|
||||||
|
f"{_list_indent(level)}{ordered_match.group('number')}. {ordered_match.group('content')}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
mapped_lines.append(line)
|
||||||
|
return "\n".join(mapped_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_blockquotes(text: str) -> str:
|
||||||
|
mapped_lines: list[str] = []
|
||||||
|
quote_lines: list[str] = []
|
||||||
|
|
||||||
|
def flush_quote_lines() -> None:
|
||||||
|
if not quote_lines:
|
||||||
|
return
|
||||||
|
mapped_lines.append(f"<blockquote>{'\n'.join(quote_lines)}</blockquote>")
|
||||||
|
quote_lines.clear()
|
||||||
|
|
||||||
|
for line in text.splitlines():
|
||||||
|
quote_match = re.match(r"^[ \t]*>\s?(.*)$", line)
|
||||||
|
if quote_match is None:
|
||||||
|
flush_quote_lines()
|
||||||
|
mapped_lines.append(line)
|
||||||
|
continue
|
||||||
|
quote_lines.append(quote_match.group(1))
|
||||||
|
|
||||||
|
flush_quote_lines()
|
||||||
|
return "\n".join(mapped_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _indent_level(indent: str) -> int:
|
||||||
|
expanded = indent.replace("\t", " ")
|
||||||
|
return len(expanded) // 2
|
||||||
|
|
||||||
|
|
||||||
|
def _list_indent(level: int) -> str:
|
||||||
|
return "\u00a0" * (level * 4)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_standard_links(text: str) -> str:
|
||||||
|
def repl(match: re.Match[str]) -> str:
|
||||||
|
label = match.group(1)
|
||||||
|
url = match.group(2)
|
||||||
|
return f'<a href="{html.escape(url, quote=True)}">{label}</a>'
|
||||||
|
|
||||||
|
return re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", repl, text)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_style_pairs(text: str, pattern: str, html_tag: str) -> str:
|
||||||
|
return re.sub(pattern, rf"<{html_tag}>\1</{html_tag}>", text)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_markdown_syntax(markdown_text: str) -> list[SyntaxIssue]:
|
||||||
|
issues: list[SyntaxIssue] = []
|
||||||
|
masked_text = _mask_escaped_markers(_mask_complete_code_segments(markdown_text))
|
||||||
|
|
||||||
|
if masked_text.count("```") % 2 != 0:
|
||||||
|
issues.append(SyntaxIssue(marker="```", message="Unclosed code block marker."))
|
||||||
|
|
||||||
|
if masked_text.count("`") % 2 != 0:
|
||||||
|
issues.append(SyntaxIssue(marker="`", message="Unclosed inline code marker."))
|
||||||
|
|
||||||
|
sanitized_text = _mask_escaped_markers(_remove_complete_code_segments(markdown_text))
|
||||||
|
for marker in ("**", "__", "++", "~~", "||"):
|
||||||
|
if sanitized_text.count(marker) % 2 != 0:
|
||||||
|
issues.append(SyntaxIssue(marker=marker, message=f"Unclosed or unmatched {marker} marker."))
|
||||||
|
|
||||||
|
bracket_balance = 0
|
||||||
|
for character in sanitized_text:
|
||||||
|
if character == "[":
|
||||||
|
bracket_balance += 1
|
||||||
|
elif character == "]" and bracket_balance > 0:
|
||||||
|
bracket_balance -= 1
|
||||||
|
if bracket_balance != 0:
|
||||||
|
issues.append(SyntaxIssue(marker="[]", message="Unmatched square brackets in link-like syntax."))
|
||||||
|
|
||||||
|
parenthesis_balance = 0
|
||||||
|
for character in sanitized_text:
|
||||||
|
if character == "(":
|
||||||
|
parenthesis_balance += 1
|
||||||
|
elif character == ")" and parenthesis_balance > 0:
|
||||||
|
parenthesis_balance -= 1
|
||||||
|
if parenthesis_balance != 0:
|
||||||
|
issues.append(SyntaxIssue(marker="()", message="Unmatched parentheses in link-like syntax."))
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_complete_code_segments(markdown_text: str) -> str:
|
||||||
|
text = re.sub(r"```(?:[^\n`]*)\n?(.*?)```", "", markdown_text, flags=re.DOTALL)
|
||||||
|
return re.sub(r"`([^`\n]+)`", "", text)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_complete_code_segments(markdown_text: str) -> str:
|
||||||
|
text = re.sub(r"```(?:[^\n`]*)\n?(.*?)```", "", markdown_text, flags=re.DOTALL)
|
||||||
|
return re.sub(r"`([^`\n]+)`", "", text)
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_escaped_markers(markdown_text: str) -> str:
|
||||||
|
return re.sub(r"\\([\\`*_+\-~|.\[\]()#>])", "", markdown_text)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
class Language(str, Enum):
|
||||||
|
FI = "fi"
|
||||||
|
SV = "sv"
|
||||||
|
EN = "en"
|
||||||
|
|
||||||
|
|
||||||
|
LANGUAGE_DISPLAY_NAMES: dict[Language, str] = {
|
||||||
|
Language.FI: "Finnish",
|
||||||
|
Language.SV: "Swedish",
|
||||||
|
Language.EN: "English",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
bot_token: str
|
||||||
|
chat_id: str
|
||||||
|
channel_username: str
|
||||||
|
discussion_group_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LanguageAssets:
|
||||||
|
language: Language
|
||||||
|
text_file: Path
|
||||||
|
text_raw: str
|
||||||
|
images: list[Path]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScanResult:
|
||||||
|
global_images: list[Path]
|
||||||
|
languages: list[LanguageAssets]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlaceholderToken:
|
||||||
|
label: str
|
||||||
|
target_lang: Language
|
||||||
|
source_span: tuple[int, int]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PostedMessageRef:
|
||||||
|
language: Language
|
||||||
|
text_message_id: int | None
|
||||||
|
caption_message_id: int | None
|
||||||
|
message_url: str
|
||||||
|
had_placeholders: bool
|
||||||
|
text_mode: Literal["message", "caption", "none"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TextSegmentBinding:
|
||||||
|
message_id: int
|
||||||
|
mode: Literal["message", "caption"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LanguageBinding:
|
||||||
|
language: Language
|
||||||
|
image_message_ids: list[int]
|
||||||
|
text_segments: list[TextSegmentBinding]
|
||||||
|
primary_message_id: int | None
|
||||||
|
primary_message_url: str
|
||||||
|
discussion_message_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PublishBinding:
|
||||||
|
global_message_ids: list[int]
|
||||||
|
global_attached_to_language: Language | None
|
||||||
|
languages: dict[Language, LanguageBinding]
|
||||||
|
discussion_message_ids: dict[int, int]
|
||||||
|
global_primary_message_id: int | None = None
|
||||||
|
global_discussion_message_id: int | None = None
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from linkki_poster.models import LANGUAGE_DISPLAY_NAMES, LanguageAssets
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_language_order(languages: list[LanguageAssets], input_fn=input) -> list[LanguageAssets]:
|
||||||
|
if len(languages) <= 1:
|
||||||
|
return languages
|
||||||
|
|
||||||
|
print("Language order:")
|
||||||
|
for index, language_assets in enumerate(languages, start=1):
|
||||||
|
print(f"{index}. {LANGUAGE_DISPLAY_NAMES[language_assets.language]}")
|
||||||
|
|
||||||
|
expected = list(range(1, len(languages) + 1))
|
||||||
|
while True:
|
||||||
|
raw = input_fn("Enter language order: ")
|
||||||
|
if raw.strip() == "":
|
||||||
|
return languages
|
||||||
|
parsed = parse_order_input(raw, len(languages))
|
||||||
|
if parsed is None:
|
||||||
|
print(f"Invalid order. Press Enter for default order {expected}, or enter digits {expected}.")
|
||||||
|
continue
|
||||||
|
return [languages[index - 1] for index in parsed]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_order_input(raw_text: str, language_count: int) -> list[int] | None:
|
||||||
|
digits = [int(item) for item in re.findall(r"\d", raw_text)]
|
||||||
|
if len(digits) != language_count:
|
||||||
|
return None
|
||||||
|
|
||||||
|
expected = set(range(1, language_count + 1))
|
||||||
|
if set(digits) != expected:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return digits
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProgressStep:
|
||||||
|
label: str
|
||||||
|
status: str = "pending"
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressDisplay:
|
||||||
|
def __init__(self, stream=None):
|
||||||
|
self.stream = stream or sys.stdout
|
||||||
|
self.steps: list[ProgressStep] = []
|
||||||
|
self._rendered_line_count = 0
|
||||||
|
self._supports_rewrite = hasattr(self.stream, "isatty") and self.stream.isatty()
|
||||||
|
|
||||||
|
def add_step(self, label: str, status: str = "pending", detail: str = "") -> int:
|
||||||
|
self.steps.append(ProgressStep(label=label, status=status, detail=detail))
|
||||||
|
self._render()
|
||||||
|
return len(self.steps) - 1
|
||||||
|
|
||||||
|
def update(self, step_index: int, status: str, detail: str = "") -> None:
|
||||||
|
step = self.steps[step_index]
|
||||||
|
step.status = status
|
||||||
|
step.detail = detail
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def insert_step(self, index: int, label: str, status: str = "pending", detail: str = "") -> int:
|
||||||
|
self.steps.insert(index, ProgressStep(label=label, status=status, detail=detail))
|
||||||
|
self._render()
|
||||||
|
return index
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
lines = [format_step(step) for step in self.steps]
|
||||||
|
output = "\n".join(lines)
|
||||||
|
if output:
|
||||||
|
output += "\n"
|
||||||
|
|
||||||
|
if self._supports_rewrite and self._rendered_line_count:
|
||||||
|
self.stream.write(f"\x1b[{self._rendered_line_count}A")
|
||||||
|
for _ in range(self._rendered_line_count):
|
||||||
|
self.stream.write("\x1b[2K\x1b[1B")
|
||||||
|
self.stream.write(f"\x1b[{self._rendered_line_count}A")
|
||||||
|
|
||||||
|
self.stream.write(output)
|
||||||
|
self.stream.flush()
|
||||||
|
self._rendered_line_count = len(lines)
|
||||||
|
|
||||||
|
|
||||||
|
class LogProgressDisplay:
|
||||||
|
def __init__(self, on_change=None):
|
||||||
|
self.steps: list[ProgressStep] = []
|
||||||
|
self.on_change = on_change
|
||||||
|
|
||||||
|
def add_step(self, label: str, status: str = "pending", detail: str = "") -> int:
|
||||||
|
self.steps.append(ProgressStep(label=label, status=status, detail=detail))
|
||||||
|
self._notify()
|
||||||
|
return len(self.steps) - 1
|
||||||
|
|
||||||
|
def update(self, step_index: int, status: str, detail: str = "") -> None:
|
||||||
|
step = self.steps[step_index]
|
||||||
|
step.status = status
|
||||||
|
step.detail = detail
|
||||||
|
self._notify()
|
||||||
|
|
||||||
|
def get_output(self) -> str:
|
||||||
|
return "\n".join(format_step(step) for step in self.steps)
|
||||||
|
|
||||||
|
def _notify(self) -> None:
|
||||||
|
if self.on_change is not None:
|
||||||
|
self.on_change(self.get_output())
|
||||||
|
|
||||||
|
|
||||||
|
def format_step(step: ProgressStep) -> str:
|
||||||
|
prefix_by_status = {
|
||||||
|
"pending": "[ ]",
|
||||||
|
"in_progress": "[>]",
|
||||||
|
"done": "[x]",
|
||||||
|
"failed": "[!]",
|
||||||
|
"skipped": "[-]",
|
||||||
|
}
|
||||||
|
prefix = prefix_by_status.get(step.status, "[?]")
|
||||||
|
if step.detail:
|
||||||
|
return f"{prefix} {step.label} - {step.detail}"
|
||||||
|
return f"{prefix} {step.label}"
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from linkki_poster.models import Language, LanguageAssets, ScanResult
|
||||||
|
|
||||||
|
|
||||||
|
KNOWN_IMAGE_EXTENSIONS = {
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png",
|
||||||
|
"webp",
|
||||||
|
"gif",
|
||||||
|
}
|
||||||
|
|
||||||
|
GLOBAL_IMAGE_PREFIXES = ["Kuva"]
|
||||||
|
|
||||||
|
LANGUAGE_TEXT_CANDIDATES: dict[Language, list[str]] = {
|
||||||
|
Language.FI: ["Suomi.md"],
|
||||||
|
Language.SV: ["Svenska.md"],
|
||||||
|
Language.EN: ["English.md"],
|
||||||
|
}
|
||||||
|
|
||||||
|
LANGUAGE_IMAGE_PREFIXES: dict[Language, list[str]] = {
|
||||||
|
Language.FI: ["Suomi"],
|
||||||
|
Language.SV: ["Svenska"],
|
||||||
|
Language.EN: ["English"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_directory(directory: Path) -> ScanResult:
|
||||||
|
files = sorted((entry for entry in directory.iterdir() if entry.is_file()), key=lambda path: path.name.lower())
|
||||||
|
casefold_name_index = _build_casefold_name_index(files)
|
||||||
|
|
||||||
|
global_images = select_first_matching_image_family(files, GLOBAL_IMAGE_PREFIXES)
|
||||||
|
|
||||||
|
language_assets: list[LanguageAssets] = []
|
||||||
|
for language in (Language.FI, Language.SV, Language.EN):
|
||||||
|
text_file = select_first_matching_text_file(casefold_name_index, LANGUAGE_TEXT_CANDIDATES[language])
|
||||||
|
if text_file is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
text_raw = text_file.read_text(encoding="utf-8")
|
||||||
|
if text_raw.strip() == "":
|
||||||
|
continue
|
||||||
|
|
||||||
|
language_images = select_first_matching_image_family(files, LANGUAGE_IMAGE_PREFIXES[language])
|
||||||
|
language_assets.append(
|
||||||
|
LanguageAssets(
|
||||||
|
language=language,
|
||||||
|
text_file=text_file,
|
||||||
|
text_raw=text_raw,
|
||||||
|
images=language_images,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScanResult(global_images=global_images, languages=language_assets)
|
||||||
|
|
||||||
|
|
||||||
|
def select_first_matching_text_file(casefold_name_index: dict[str, Path], candidate_names: list[str]) -> Path | None:
|
||||||
|
for candidate_name in candidate_names:
|
||||||
|
match = casefold_name_index.get(candidate_name.lower())
|
||||||
|
if match is not None:
|
||||||
|
return match
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def select_first_matching_image_family(files: list[Path], prefixes: list[str]) -> list[Path]:
|
||||||
|
for prefix in prefixes:
|
||||||
|
matches = collect_image_matches_for_prefix(files, prefix)
|
||||||
|
if matches:
|
||||||
|
return [match[0] for match in sorted(matches, key=image_sort_key)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def collect_image_matches_for_prefix(files: list[Path], prefix: str) -> list[tuple[Path, str, str]]:
|
||||||
|
pattern = re.compile(rf"^{re.escape(prefix)}(\d*)\.([^.]+)$", re.IGNORECASE)
|
||||||
|
matches: list[tuple[Path, str, str]] = []
|
||||||
|
for file_path in files:
|
||||||
|
match = pattern.match(file_path.name)
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
extension = match.group(2).lower()
|
||||||
|
if extension not in KNOWN_IMAGE_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
numeric_suffix = match.group(1)
|
||||||
|
matches.append((file_path, numeric_suffix, extension))
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def image_sort_key(match_item: tuple[Path, str, str]) -> tuple[int, int, str, str]:
|
||||||
|
file_path, numeric_suffix, extension = match_item
|
||||||
|
has_numeric_suffix = numeric_suffix != ""
|
||||||
|
numeric_value = int(numeric_suffix) if has_numeric_suffix else -1
|
||||||
|
return (
|
||||||
|
1 if has_numeric_suffix else 0,
|
||||||
|
numeric_value,
|
||||||
|
extension.lower(),
|
||||||
|
file_path.name.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_casefold_name_index(files: list[Path]) -> dict[str, Path]:
|
||||||
|
index: dict[str, Path] = {}
|
||||||
|
for file_path in files:
|
||||||
|
lowered = file_path.name.lower()
|
||||||
|
if lowered not in index:
|
||||||
|
index[lowered] = file_path
|
||||||
|
return index
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import json
|
||||||
|
from contextlib import ExitStack
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramApiError(RuntimeError):
|
||||||
|
def __init__(self, description: str, error_code: int | None = None):
|
||||||
|
super().__init__(description)
|
||||||
|
self.description = description
|
||||||
|
self.error_code = error_code
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramAPI:
|
||||||
|
def __init__(self, bot_token: str):
|
||||||
|
self.base_url = f"https://api.telegram.org/bot{bot_token}"
|
||||||
|
|
||||||
|
def send_media_group(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
image_paths: list[Path],
|
||||||
|
caption: str | None = None,
|
||||||
|
parse_mode: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
if not image_paths:
|
||||||
|
raise ValueError("send_media_group requires at least one image.")
|
||||||
|
|
||||||
|
media_items = []
|
||||||
|
for index, _ in enumerate(image_paths):
|
||||||
|
media_item = {
|
||||||
|
"type": "photo",
|
||||||
|
"media": f"attach://file{index}",
|
||||||
|
}
|
||||||
|
if index == 0 and caption is not None:
|
||||||
|
media_item["caption"] = caption
|
||||||
|
if parse_mode is not None:
|
||||||
|
media_item["parse_mode"] = parse_mode
|
||||||
|
media_items.append(media_item)
|
||||||
|
|
||||||
|
payload = {"chat_id": chat_id, "media": json.dumps(media_items, ensure_ascii=False)}
|
||||||
|
|
||||||
|
with ExitStack() as stack:
|
||||||
|
files = {}
|
||||||
|
for index, image_path in enumerate(image_paths):
|
||||||
|
files[f"file{index}"] = stack.enter_context(image_path.open("rb"))
|
||||||
|
|
||||||
|
return self._post("sendMediaGroup", data=payload, files=files, timeout=60)
|
||||||
|
|
||||||
|
def get_updates(self, offset: int | None = None, limit: int = 100, timeout: int = 0) -> list[dict]:
|
||||||
|
payload = {"limit": limit, "timeout": timeout}
|
||||||
|
if offset is not None:
|
||||||
|
payload["offset"] = offset
|
||||||
|
return self._post("getUpdates", data=payload, timeout=timeout + 10)
|
||||||
|
|
||||||
|
def send_message(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
text: str,
|
||||||
|
parse_mode: str | None = None,
|
||||||
|
disable_link_preview: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
payload = {"chat_id": chat_id, "text": text}
|
||||||
|
if parse_mode is not None:
|
||||||
|
payload["parse_mode"] = parse_mode
|
||||||
|
if disable_link_preview:
|
||||||
|
payload["link_preview_options"] = json.dumps({"is_disabled": True})
|
||||||
|
return self._post("sendMessage", data=payload, timeout=30)
|
||||||
|
|
||||||
|
def edit_message_text(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
message_id: int,
|
||||||
|
text: str,
|
||||||
|
parse_mode: str | None = None,
|
||||||
|
disable_link_preview: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
payload = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
|
if parse_mode is not None:
|
||||||
|
payload["parse_mode"] = parse_mode
|
||||||
|
if disable_link_preview:
|
||||||
|
payload["link_preview_options"] = json.dumps({"is_disabled": True})
|
||||||
|
return self._post("editMessageText", data=payload, timeout=30)
|
||||||
|
|
||||||
|
def edit_message_caption(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
message_id: int,
|
||||||
|
caption: str,
|
||||||
|
parse_mode: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
payload = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"caption": caption,
|
||||||
|
}
|
||||||
|
if parse_mode is not None:
|
||||||
|
payload["parse_mode"] = parse_mode
|
||||||
|
return self._post("editMessageCaption", data=payload, timeout=30)
|
||||||
|
|
||||||
|
def delete_message(self, chat_id: str, message_id: int) -> bool:
|
||||||
|
payload = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
}
|
||||||
|
return bool(self._post("deleteMessage", data=payload, timeout=30))
|
||||||
|
|
||||||
|
def pin_chat_message(self, chat_id: str, message_id: int, disable_notification: bool = True) -> bool:
|
||||||
|
payload = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
}
|
||||||
|
if disable_notification:
|
||||||
|
payload["disable_notification"] = "true"
|
||||||
|
return bool(self._post("pinChatMessage", data=payload, timeout=30))
|
||||||
|
|
||||||
|
def unpin_chat_message(self, chat_id: str, message_id: int) -> bool:
|
||||||
|
payload = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
}
|
||||||
|
return bool(self._post("unpinChatMessage", data=payload, timeout=30))
|
||||||
|
|
||||||
|
def _post(self, endpoint: str, data: dict, files: dict | None = None, timeout: int = 30):
|
||||||
|
response = requests.post(f"{self.base_url}/{endpoint}", data=data, files=files, timeout=timeout)
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.HTTPError as error:
|
||||||
|
description = _extract_error_description(response) or str(error)
|
||||||
|
error_code = _extract_error_code(response)
|
||||||
|
raise TelegramApiError(description=description, error_code=error_code) from error
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
if not payload.get("ok", False):
|
||||||
|
raise TelegramApiError(
|
||||||
|
description=str(payload.get("description", "Telegram API request failed.")),
|
||||||
|
error_code=_to_int_or_none(payload.get("error_code")),
|
||||||
|
)
|
||||||
|
return payload["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_error_description(response: requests.Response) -> str | None:
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
description = payload.get("description")
|
||||||
|
if description is None:
|
||||||
|
return None
|
||||||
|
return str(description)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_error_code(response: requests.Response) -> int | None:
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return _to_int_or_none(payload.get("error_code"))
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int_or_none(value) -> int | None:
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str) and value.isdigit():
|
||||||
|
return int(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_message_missing_error(error: TelegramApiError | str) -> bool:
|
||||||
|
description = error.description if isinstance(error, TelegramApiError) else str(error)
|
||||||
|
lowered = description.lower()
|
||||||
|
return "not found" in lowered or "message to delete not found" in lowered or "message to edit not found" in lowered
|
||||||
|
|
||||||
|
|
||||||
|
def is_message_not_modified_error(error: TelegramApiError | str) -> bool:
|
||||||
|
description = error.description if isinstance(error, TelegramApiError) else str(error)
|
||||||
|
return "message is not modified" in description.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def is_message_not_deletable_error(error: TelegramApiError | str) -> bool:
|
||||||
|
description = error.description if isinstance(error, TelegramApiError) else str(error)
|
||||||
|
return "can't be deleted" in description.lower()
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from linkki_poster.formatting import (
|
||||||
|
CAPTION_LIMIT,
|
||||||
|
ParsedTemplate,
|
||||||
|
estimate_caption_length,
|
||||||
|
parse_markdown_template,
|
||||||
|
render_template,
|
||||||
|
)
|
||||||
|
from linkki_poster.models import Config, LANGUAGE_DISPLAY_NAMES, Language, LanguageAssets, PostedMessageRef
|
||||||
|
from linkki_poster.progress import ProgressDisplay
|
||||||
|
from linkki_poster.telegram_api import TelegramAPI, TelegramApiError
|
||||||
|
|
||||||
|
|
||||||
|
MESSAGE_LIMIT = 4096
|
||||||
|
_FAKE_MESSAGE_ID = "9" * 20
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _TextSegmentState:
|
||||||
|
label: str
|
||||||
|
message_id: int | None
|
||||||
|
mode: Literal["message", "caption"]
|
||||||
|
parsed_template: ParsedTemplate
|
||||||
|
last_rendered: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _PostState:
|
||||||
|
posted_ref: PostedMessageRef
|
||||||
|
text_segments: list[_TextSegmentState]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _RuntimeChunkPlan:
|
||||||
|
raw_text: str
|
||||||
|
parsed_template: ParsedTemplate
|
||||||
|
label: str
|
||||||
|
has_placeholders: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _LanguageRuntimePlan:
|
||||||
|
language_assets: LanguageAssets
|
||||||
|
full_template: ParsedTemplate
|
||||||
|
full_rendered_text: str
|
||||||
|
has_text: bool
|
||||||
|
can_use_caption: bool
|
||||||
|
chunks: list[_RuntimeChunkPlan]
|
||||||
|
|
||||||
|
|
||||||
|
def post_assets(
|
||||||
|
api: TelegramAPI,
|
||||||
|
config: Config,
|
||||||
|
global_images: list[Path],
|
||||||
|
ordered_languages: list[LanguageAssets],
|
||||||
|
progress: ProgressDisplay | None = None,
|
||||||
|
) -> list[PostedMessageRef]:
|
||||||
|
reporter = progress or ProgressDisplay()
|
||||||
|
leading_global_images, effective_languages = resolve_global_image_strategy(global_images, ordered_languages)
|
||||||
|
normalized_username = normalize_channel_username(config.channel_username)
|
||||||
|
link_target = resolve_link_target(config.chat_id, normalized_username)
|
||||||
|
runtime_plans = build_runtime_plans(effective_languages, link_target)
|
||||||
|
|
||||||
|
posted_message_ids: list[int] = []
|
||||||
|
post_states: list[_PostState] = []
|
||||||
|
language_links: dict[Language, str] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if leading_global_images:
|
||||||
|
execute_image_post(
|
||||||
|
api=api,
|
||||||
|
chat_id=config.chat_id,
|
||||||
|
image_paths=leading_global_images,
|
||||||
|
label=build_image_label("global", len(leading_global_images)),
|
||||||
|
reporter=reporter,
|
||||||
|
posted_message_ids=posted_message_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
for runtime_plan in runtime_plans:
|
||||||
|
post_state = execute_language_plan(
|
||||||
|
api=api,
|
||||||
|
config=config,
|
||||||
|
runtime_plan=runtime_plan,
|
||||||
|
reporter=reporter,
|
||||||
|
posted_message_ids=posted_message_ids,
|
||||||
|
language_links=language_links,
|
||||||
|
link_target=link_target,
|
||||||
|
)
|
||||||
|
post_states.append(post_state)
|
||||||
|
language_links[post_state.posted_ref.language] = post_state.posted_ref.message_url
|
||||||
|
|
||||||
|
execute_final_backfill(api, config.chat_id, post_states, language_links, reporter)
|
||||||
|
return [post_state.posted_ref for post_state in post_states]
|
||||||
|
except Exception as error:
|
||||||
|
rollback_posted_messages(api, config.chat_id, posted_message_ids, reporter)
|
||||||
|
raise error
|
||||||
|
|
||||||
|
|
||||||
|
def build_runtime_plans(ordered_languages: list[LanguageAssets], link_target: str) -> list[_LanguageRuntimePlan]:
|
||||||
|
runtime_plans: list[_LanguageRuntimePlan] = []
|
||||||
|
for language_assets in ordered_languages:
|
||||||
|
full_template = parse_markdown_template(language_assets.text_raw)
|
||||||
|
full_rendered_text = render_template(full_template, {})
|
||||||
|
has_text = full_rendered_text.strip() != ""
|
||||||
|
split_chunks = split_markdown_text_for_messages(language_assets.text_raw, link_target) if has_text else []
|
||||||
|
chunk_plans = []
|
||||||
|
for index, chunk_text in enumerate(split_chunks, start=1):
|
||||||
|
parsed_chunk = parse_markdown_template(chunk_text)
|
||||||
|
chunk_plans.append(
|
||||||
|
_RuntimeChunkPlan(
|
||||||
|
raw_text=chunk_text,
|
||||||
|
parsed_template=parsed_chunk,
|
||||||
|
label=build_text_label(language_assets.language, index, len(split_chunks)),
|
||||||
|
has_placeholders=bool(parsed_chunk.placeholders),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
can_use_caption = (
|
||||||
|
bool(language_assets.images)
|
||||||
|
and has_text
|
||||||
|
and len(chunk_plans) == 1
|
||||||
|
and estimate_caption_length(full_template, link_target) <= CAPTION_LIMIT
|
||||||
|
)
|
||||||
|
runtime_plans.append(
|
||||||
|
_LanguageRuntimePlan(
|
||||||
|
language_assets=language_assets,
|
||||||
|
full_template=full_template,
|
||||||
|
full_rendered_text=full_rendered_text,
|
||||||
|
has_text=has_text,
|
||||||
|
can_use_caption=can_use_caption,
|
||||||
|
chunks=chunk_plans,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return runtime_plans
|
||||||
|
|
||||||
|
|
||||||
|
def execute_language_plan(
|
||||||
|
api: TelegramAPI,
|
||||||
|
config: Config,
|
||||||
|
runtime_plan: _LanguageRuntimePlan,
|
||||||
|
reporter: ProgressDisplay,
|
||||||
|
posted_message_ids: list[int],
|
||||||
|
language_links: dict[Language, str],
|
||||||
|
link_target: str,
|
||||||
|
) -> _PostState:
|
||||||
|
language_assets = runtime_plan.language_assets
|
||||||
|
language_name = LANGUAGE_DISPLAY_NAMES[language_assets.language]
|
||||||
|
text_segments: list[_TextSegmentState] = []
|
||||||
|
text_mode: Literal["message", "caption", "none"] = "none"
|
||||||
|
text_message_id: int | None = None
|
||||||
|
caption_message_id: int | None = None
|
||||||
|
|
||||||
|
if language_assets.images:
|
||||||
|
image_label = build_image_label(language_name, len(language_assets.images))
|
||||||
|
|
||||||
|
if runtime_plan.can_use_caption:
|
||||||
|
only_chunk = runtime_plan.chunks[0]
|
||||||
|
try:
|
||||||
|
message_ids = execute_image_post(
|
||||||
|
api=api,
|
||||||
|
chat_id=config.chat_id,
|
||||||
|
image_paths=language_assets.images,
|
||||||
|
label=image_label,
|
||||||
|
reporter=reporter,
|
||||||
|
posted_message_ids=posted_message_ids,
|
||||||
|
caption=render_template(runtime_plan.full_template, language_links),
|
||||||
|
)
|
||||||
|
caption_message_id = message_ids[0]
|
||||||
|
text_mode = "caption"
|
||||||
|
text_step_index = reporter.add_step(only_chunk.label, status="done", detail="included as caption")
|
||||||
|
text_segments.append(
|
||||||
|
_TextSegmentState(
|
||||||
|
label=only_chunk.label,
|
||||||
|
message_id=caption_message_id,
|
||||||
|
mode="caption",
|
||||||
|
parsed_template=runtime_plan.full_template,
|
||||||
|
last_rendered=render_template(runtime_plan.full_template, language_links),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except TelegramApiError as error:
|
||||||
|
if not is_caption_too_long_error(error):
|
||||||
|
raise
|
||||||
|
reporter.add_step(image_label, status="failed", detail="caption too long, retrying without caption")
|
||||||
|
message_ids = execute_image_post(
|
||||||
|
api=api,
|
||||||
|
chat_id=config.chat_id,
|
||||||
|
image_paths=language_assets.images,
|
||||||
|
label=image_label,
|
||||||
|
reporter=reporter,
|
||||||
|
posted_message_ids=posted_message_ids,
|
||||||
|
)
|
||||||
|
if runtime_plan.has_text:
|
||||||
|
segments, first_message_id = execute_text_chunks(
|
||||||
|
api=api,
|
||||||
|
chat_id=config.chat_id,
|
||||||
|
chunks=runtime_plan.chunks,
|
||||||
|
reporter=reporter,
|
||||||
|
posted_message_ids=posted_message_ids,
|
||||||
|
language_links=language_links,
|
||||||
|
)
|
||||||
|
text_segments.extend(segments)
|
||||||
|
text_message_id = first_message_id
|
||||||
|
text_mode = "message"
|
||||||
|
caption_message_id = None
|
||||||
|
else:
|
||||||
|
message_ids = execute_image_post(
|
||||||
|
api=api,
|
||||||
|
chat_id=config.chat_id,
|
||||||
|
image_paths=language_assets.images,
|
||||||
|
label=image_label,
|
||||||
|
reporter=reporter,
|
||||||
|
posted_message_ids=posted_message_ids,
|
||||||
|
)
|
||||||
|
if runtime_plan.has_text:
|
||||||
|
segments, first_message_id = execute_text_chunks(
|
||||||
|
api=api,
|
||||||
|
chat_id=config.chat_id,
|
||||||
|
chunks=runtime_plan.chunks,
|
||||||
|
reporter=reporter,
|
||||||
|
posted_message_ids=posted_message_ids,
|
||||||
|
language_links=language_links,
|
||||||
|
)
|
||||||
|
text_segments.extend(segments)
|
||||||
|
text_message_id = first_message_id
|
||||||
|
text_mode = "message"
|
||||||
|
elif runtime_plan.has_text:
|
||||||
|
segments, first_message_id = execute_text_chunks(
|
||||||
|
api=api,
|
||||||
|
chat_id=config.chat_id,
|
||||||
|
chunks=runtime_plan.chunks,
|
||||||
|
reporter=reporter,
|
||||||
|
posted_message_ids=posted_message_ids,
|
||||||
|
language_links=language_links,
|
||||||
|
)
|
||||||
|
text_segments.extend(segments)
|
||||||
|
text_message_id = first_message_id
|
||||||
|
text_mode = "message"
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Language '{language_assets.language.value}' has neither text nor images.")
|
||||||
|
|
||||||
|
primary_message_id = caption_message_id or text_message_id
|
||||||
|
if primary_message_id is None:
|
||||||
|
raise ValueError(f"Language '{language_name}' did not produce a primary message.")
|
||||||
|
|
||||||
|
message_url = build_message_url(link_target, primary_message_id)
|
||||||
|
posted_ref = PostedMessageRef(
|
||||||
|
language=language_assets.language,
|
||||||
|
text_message_id=text_message_id,
|
||||||
|
caption_message_id=caption_message_id,
|
||||||
|
message_url=message_url,
|
||||||
|
had_placeholders=any(bool(segment.parsed_template.placeholders) for segment in text_segments),
|
||||||
|
text_mode=text_mode,
|
||||||
|
)
|
||||||
|
return _PostState(posted_ref=posted_ref, text_segments=text_segments)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_image_post(
|
||||||
|
api: TelegramAPI,
|
||||||
|
chat_id: str,
|
||||||
|
image_paths: list[Path],
|
||||||
|
label: str,
|
||||||
|
reporter: ProgressDisplay,
|
||||||
|
posted_message_ids: list[int],
|
||||||
|
caption: str | None = None,
|
||||||
|
) -> list[int]:
|
||||||
|
step_index = reporter.add_step(label)
|
||||||
|
reporter.update(step_index, "in_progress")
|
||||||
|
response_items = api.send_media_group(
|
||||||
|
chat_id,
|
||||||
|
image_paths,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML" if caption is not None else None,
|
||||||
|
)
|
||||||
|
message_ids = [int(item["message_id"]) for item in response_items]
|
||||||
|
posted_message_ids.extend(message_ids)
|
||||||
|
detail = "with caption" if caption is not None else ""
|
||||||
|
reporter.update(step_index, "done", detail)
|
||||||
|
return message_ids
|
||||||
|
|
||||||
|
|
||||||
|
def execute_text_chunks(
|
||||||
|
api: TelegramAPI,
|
||||||
|
chat_id: str,
|
||||||
|
chunks: list[_RuntimeChunkPlan],
|
||||||
|
reporter: ProgressDisplay,
|
||||||
|
posted_message_ids: list[int],
|
||||||
|
language_links: dict[Language, str],
|
||||||
|
) -> tuple[list[_TextSegmentState], int]:
|
||||||
|
segments: list[_TextSegmentState] = []
|
||||||
|
first_message_id: int | None = None
|
||||||
|
|
||||||
|
for chunk in chunks:
|
||||||
|
post_step_index = reporter.add_step(chunk.label)
|
||||||
|
reporter.update(post_step_index, "in_progress")
|
||||||
|
rendered_text = render_template(chunk.parsed_template, language_links)
|
||||||
|
detail = ""
|
||||||
|
if len(chunks) > 1:
|
||||||
|
detail = chunk.label.replace("Post ", "").replace(" text part ", " split part ")
|
||||||
|
sent_message = api.send_message(chat_id, rendered_text, parse_mode="HTML")
|
||||||
|
message_id = int(sent_message["message_id"])
|
||||||
|
posted_message_ids.append(message_id)
|
||||||
|
reporter.update(post_step_index, "done", detail)
|
||||||
|
if first_message_id is None:
|
||||||
|
first_message_id = message_id
|
||||||
|
segments.append(
|
||||||
|
_TextSegmentState(
|
||||||
|
label=chunk.label,
|
||||||
|
message_id=message_id,
|
||||||
|
mode="message",
|
||||||
|
parsed_template=chunk.parsed_template,
|
||||||
|
last_rendered=rendered_text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if first_message_id is None:
|
||||||
|
raise ValueError("Expected at least one text message segment.")
|
||||||
|
return segments, first_message_id
|
||||||
|
|
||||||
|
|
||||||
|
def execute_final_backfill(
|
||||||
|
api: TelegramAPI,
|
||||||
|
chat_id: str,
|
||||||
|
post_states: list[_PostState],
|
||||||
|
language_links: dict[Language, str],
|
||||||
|
reporter: ProgressDisplay,
|
||||||
|
) -> None:
|
||||||
|
for post_state in post_states:
|
||||||
|
for text_segment in post_state.text_segments:
|
||||||
|
if not text_segment.parsed_template.placeholders:
|
||||||
|
continue
|
||||||
|
|
||||||
|
updated_text = render_template(text_segment.parsed_template, language_links)
|
||||||
|
if updated_text == text_segment.last_rendered:
|
||||||
|
continue
|
||||||
|
|
||||||
|
edit_step_index = reporter.add_step(build_edit_label(text_segment.label))
|
||||||
|
reporter.update(edit_step_index, "in_progress")
|
||||||
|
if text_segment.message_id is None:
|
||||||
|
raise ValueError("Missing message id for edit step.")
|
||||||
|
if text_segment.mode == "message":
|
||||||
|
api.edit_message_text(chat_id, text_segment.message_id, updated_text, parse_mode="HTML")
|
||||||
|
else:
|
||||||
|
api.edit_message_caption(chat_id, text_segment.message_id, updated_text, parse_mode="HTML")
|
||||||
|
reporter.update(edit_step_index, "done")
|
||||||
|
text_segment.last_rendered = updated_text
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_posted_messages(
|
||||||
|
api: TelegramAPI,
|
||||||
|
chat_id: str,
|
||||||
|
posted_message_ids: list[int],
|
||||||
|
reporter: ProgressDisplay,
|
||||||
|
) -> None:
|
||||||
|
for message_id in reversed(posted_message_ids):
|
||||||
|
step_index = reporter.add_step(f"Delete message {message_id}")
|
||||||
|
reporter.update(step_index, "in_progress")
|
||||||
|
try:
|
||||||
|
api.delete_message(chat_id, message_id)
|
||||||
|
except TelegramApiError as error:
|
||||||
|
reporter.update(step_index, "failed", error.description)
|
||||||
|
continue
|
||||||
|
reporter.update(step_index, "done")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_channel_username(channel_username: str) -> str:
|
||||||
|
return channel_username.strip().lstrip("@")
|
||||||
|
|
||||||
|
|
||||||
|
def build_message_url(link_target: str, message_id: int) -> str:
|
||||||
|
return f"https://t.me/{link_target}/{message_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_global_image_strategy(
|
||||||
|
global_images: list[Path],
|
||||||
|
ordered_languages: list[LanguageAssets],
|
||||||
|
) -> tuple[list[Path], list[LanguageAssets]]:
|
||||||
|
if not global_images:
|
||||||
|
return [], ordered_languages
|
||||||
|
|
||||||
|
if not ordered_languages:
|
||||||
|
return global_images, ordered_languages
|
||||||
|
|
||||||
|
first_language = ordered_languages[0]
|
||||||
|
if first_language.images:
|
||||||
|
return global_images, ordered_languages
|
||||||
|
|
||||||
|
with_attached_globals = LanguageAssets(
|
||||||
|
language=first_language.language,
|
||||||
|
text_file=first_language.text_file,
|
||||||
|
text_raw=first_language.text_raw,
|
||||||
|
images=global_images,
|
||||||
|
)
|
||||||
|
return [], [with_attached_globals, *ordered_languages[1:]]
|
||||||
|
|
||||||
|
|
||||||
|
def is_caption_too_long_error(error: TelegramApiError) -> bool:
|
||||||
|
return "caption is too long" in error.description.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def split_markdown_text_for_messages(
|
||||||
|
markdown_text: str,
|
||||||
|
link_target: str,
|
||||||
|
message_limit: int = MESSAGE_LIMIT,
|
||||||
|
) -> list[str]:
|
||||||
|
if markdown_text == "":
|
||||||
|
return []
|
||||||
|
|
||||||
|
chunks: list[str] = []
|
||||||
|
remaining = markdown_text
|
||||||
|
|
||||||
|
while remaining:
|
||||||
|
fit_index = find_max_fitting_prefix_index(remaining, link_target, message_limit)
|
||||||
|
if fit_index == len(remaining):
|
||||||
|
chunks.append(remaining)
|
||||||
|
break
|
||||||
|
|
||||||
|
split_index = choose_split_index(remaining, fit_index)
|
||||||
|
if split_index <= 0:
|
||||||
|
split_index = fit_index
|
||||||
|
|
||||||
|
chunks.append(remaining[:split_index])
|
||||||
|
remaining = remaining[split_index:]
|
||||||
|
|
||||||
|
return [chunk for chunk in chunks if chunk != ""]
|
||||||
|
|
||||||
|
|
||||||
|
def find_max_fitting_prefix_index(markdown_text: str, link_target: str, message_limit: int) -> int:
|
||||||
|
cached_lengths: dict[int, int] = {}
|
||||||
|
|
||||||
|
def measured_length(end_index: int) -> int:
|
||||||
|
if end_index not in cached_lengths:
|
||||||
|
cached_lengths[end_index] = estimate_rendered_message_length(markdown_text[:end_index], link_target)
|
||||||
|
return cached_lengths[end_index]
|
||||||
|
|
||||||
|
if measured_length(len(markdown_text)) <= message_limit:
|
||||||
|
return len(markdown_text)
|
||||||
|
|
||||||
|
low = 1
|
||||||
|
high = len(markdown_text)
|
||||||
|
best = 0
|
||||||
|
|
||||||
|
while low <= high:
|
||||||
|
middle = (low + high) // 2
|
||||||
|
if measured_length(middle) <= message_limit:
|
||||||
|
best = middle
|
||||||
|
low = middle + 1
|
||||||
|
else:
|
||||||
|
high = middle - 1
|
||||||
|
|
||||||
|
if best == 0:
|
||||||
|
raise ValueError("Unable to split message content into Telegram-compatible chunks.")
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def choose_split_index(markdown_text: str, max_index: int) -> int:
|
||||||
|
double_newline_match = find_last_break_end(markdown_text, re.compile(r"\n{2,}"), max_index)
|
||||||
|
if double_newline_match is not None:
|
||||||
|
return double_newline_match
|
||||||
|
|
||||||
|
single_newline_index = markdown_text.rfind("\n", 0, max_index)
|
||||||
|
if single_newline_index != -1:
|
||||||
|
return single_newline_index + 1
|
||||||
|
|
||||||
|
whitespace_index = find_last_whitespace_break(markdown_text, max_index)
|
||||||
|
if whitespace_index is not None:
|
||||||
|
return whitespace_index
|
||||||
|
|
||||||
|
return max_index
|
||||||
|
|
||||||
|
|
||||||
|
def find_last_break_end(markdown_text: str, pattern: re.Pattern[str], max_index: int) -> int | None:
|
||||||
|
last_end: int | None = None
|
||||||
|
for match in pattern.finditer(markdown_text):
|
||||||
|
if match.end() > max_index:
|
||||||
|
break
|
||||||
|
last_end = match.end()
|
||||||
|
return last_end
|
||||||
|
|
||||||
|
|
||||||
|
def find_last_whitespace_break(markdown_text: str, max_index: int) -> int | None:
|
||||||
|
for index in range(max_index - 1, -1, -1):
|
||||||
|
if markdown_text[index] in {" ", "\t"}:
|
||||||
|
return index + 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_rendered_message_length(markdown_text: str, link_target: str) -> int:
|
||||||
|
parsed_template = parse_markdown_template(markdown_text)
|
||||||
|
fake_url = f"https://t.me/{link_target}/{_FAKE_MESSAGE_ID}"
|
||||||
|
fake_links = {
|
||||||
|
Language.FI: fake_url,
|
||||||
|
Language.SV: fake_url,
|
||||||
|
Language.EN: fake_url,
|
||||||
|
}
|
||||||
|
rendered_text = render_template(parsed_template, fake_links)
|
||||||
|
return len(rendered_text)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_link_target(chat_id: str, normalized_username: str = "") -> str:
|
||||||
|
if normalized_username != "":
|
||||||
|
return normalized_username
|
||||||
|
|
||||||
|
internal_chat_id = extract_internal_chat_id(chat_id)
|
||||||
|
if internal_chat_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Cannot generate message links without channel_username or a channel-style chat_id (-100...)."
|
||||||
|
)
|
||||||
|
return f"c/{internal_chat_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_internal_chat_id(chat_id: str) -> str | None:
|
||||||
|
stripped = chat_id.strip()
|
||||||
|
if not stripped.startswith("-100"):
|
||||||
|
return None
|
||||||
|
suffix = stripped[4:]
|
||||||
|
if suffix == "" or not suffix.isdigit():
|
||||||
|
return None
|
||||||
|
return suffix
|
||||||
|
|
||||||
|
|
||||||
|
def build_image_label(target: str, image_count: int) -> str:
|
||||||
|
noun = "image" if image_count == 1 else "images"
|
||||||
|
return f"Post {image_count} {target} {noun}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_text_label(language: Language, chunk_index: int, chunk_count: int) -> str:
|
||||||
|
language_name = LANGUAGE_DISPLAY_NAMES[language]
|
||||||
|
if chunk_count == 1:
|
||||||
|
return f"Post {language_name} text"
|
||||||
|
return f"Post {language_name} text part {chunk_index}/{chunk_count}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_edit_label(text_label: str) -> str:
|
||||||
|
if text_label.startswith("Post "):
|
||||||
|
return "Edit " + text_label[5:]
|
||||||
|
return "Edit " + text_label
|
||||||
@@ -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:])
|
||||||
@@ -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()
|
||||||
@@ -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:])
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
requests
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
jinja2
|
||||||
|
python-multipart
|
||||||
|
itsdangerous
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 "'" not in heading
|
||||||
|
assert "'" not in plain_heading_char
|
||||||
|
assert "'" not in bold
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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"
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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.")
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Web app package for Linkki."""
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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]
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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 %}>↑</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 %}>↓</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
@@ -0,0 +1,5 @@
|
|||||||
|
import uvicorn
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("webapp.app:app", host="0.0.0.0", port=8080, reload=False)
|
||||||
Reference in New Issue
Block a user