Files
ajp_anton 8ec3cde4f2
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
Add Linkki broadcast image
2026-08-19 20:51:26 +00:00

1388 lines
60 KiB
Python

import asyncio
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from mimetypes import guess_type
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import unquote, urlparse
from urllib.request import Request as UrlRequest, urlopen
from zoneinfo import ZoneInfo
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from linkki_poster.broadcast_ops import (
apply_discussion_group_pinning,
build_primary_channel_message_ids,
delete_binding_from_telegram,
ensure_discussion_group_message_ids,
publish_directory,
snapshot_next_update_offset,
update_texts,
)
from linkki_poster.formatting import render_preview_html
from linkki_poster.models import Language, LanguageBinding, PublishBinding, TextSegmentBinding
from linkki_poster.telegram_api import is_message_not_deletable_error
from linkki_poster.progress import LogProgressDisplay
from linkki_poster.workflow import build_message_url, normalize_channel_username, resolve_link_target
from webapp.auth import SUPPORTED_TRANSLATION_LANGUAGES, load_users, normalize_ui_language, set_user_language, verify_password
from webapp.config import ConfigFileError, WebAppConfig, load_webapp_config
from webapp.i18n import language_options, load_translations
from webapp.storage import (
append_status_output,
clear_status_history,
create_broadcast,
delete_from_storage,
get_binding,
list_broadcasts,
load_broadcast,
load_metadata,
mark_publish_failure,
mark_scheduled_publish_failure,
mark_publish_success_with_result,
mark_scheduled,
move_broadcast,
normalize_discussion_pinning_mode,
now_iso,
save_metadata,
save_binding,
save_broadcast_changes,
serialize_binding,
set_scheduled_unpin_for,
stage_image_upload,
telegram_delete_deadline,
is_telegram_delete_expired,
unlink_telegram,
unschedule,
)
from webapp.telegram_targets import (
default_target_selection,
load_telegram_target_catalog,
resolve_poster_config,
resolve_target_labels,
serialise_catalog_for_client,
)
templates = Jinja2Templates(directory=str((Path(__file__).resolve().parent / "templates")))
STATIC_DIR = Path(__file__).resolve().parent / "static"
logger = logging.getLogger(__name__)
def static_asset_url(asset_name: str) -> str:
asset_path = STATIC_DIR / asset_name
version = int(asset_path.stat().st_mtime) if asset_path.exists() else 0
return f"/static/{asset_name}?v={version}"
templates.env.globals["static_asset_url"] = static_asset_url
def create_app() -> FastAPI:
config = load_webapp_config()
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler = asyncio.create_task(scheduler_loop(app))
yield
scheduler.cancel()
try:
await scheduler
except asyncio.CancelledError:
pass
app = FastAPI(lifespan=lifespan)
app.state.webapp_config = config
app.state.scheduler_lock = asyncio.Lock()
app.state.operations = {}
app.state.operation_tasks = {}
app.add_middleware(SessionMiddleware, secret_key=config.session_secret)
app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static")
@app.exception_handler(ConfigFileError)
async def config_file_error_handler(request: Request, error: ConfigFileError):
return PlainTextResponse(str(error), status_code=500)
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse(
request=request,
name="login.html",
context=build_login_context(request, None),
)
@app.post("/login", response_class=HTMLResponse)
async def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
users = load_users(config.users_file)
user_record = users.get(username)
if user_record is None or not verify_password(password, user_record["password_hash"]):
return templates.TemplateResponse(
request=request,
name="login.html",
context=build_login_context(request, "login.invalid_credentials"),
status_code=401,
)
selected_language = normalize_login_language(request.session.get("login_ui_language"))
if selected_language != "auto":
set_user_language(config.users_file, username, selected_language)
user_record["language"] = selected_language
request.session["username"] = username
request.session["ui_language"] = user_record["language"]
request.session.pop("login_ui_language", None)
return RedirectResponse("/", status_code=303)
@app.post("/login-language")
async def login_language(request: Request, ui_language: str = Form("auto")):
request.session["login_ui_language"] = normalize_login_language(ui_language)
return RedirectResponse("/login", status_code=303)
@app.post("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
require_auth(request)
announcements = list_broadcasts(config.posts_dir)
return templates.TemplateResponse(
request=request,
name="instructions.html",
context=build_template_context(request, None, announcements, config, current_path="/"),
)
@app.get("/instructions")
async def instructions_page(request: Request):
require_auth(request)
return RedirectResponse("/", status_code=303)
@app.post("/language")
async def change_language(request: Request, ui_language: str = Form(...), return_to: str = Form("/")):
current_user = get_current_user_record(request, config)
normalized_language = normalize_ui_language(ui_language)
set_user_language(config.users_file, current_user["username"], normalized_language)
request.session["ui_language"] = normalized_language
if not return_to.startswith("/"):
return_to = "/"
return RedirectResponse(return_to, status_code=303)
@app.post("/announcements", response_class=HTMLResponse)
async def create_broadcast_route(request: Request, title: str = Form("New announcement"), return_to: str = Form("/")):
require_permission(request, config, "save_announcements")
try:
default_bot_id, default_chat_id = default_target_selection(load_telegram_target_catalog(config.config_dir))
directory_name = create_broadcast(
config.posts_dir,
title or "New announcement",
default_bot_id=default_bot_id,
default_chat_id=default_chat_id,
)
except ValueError as error:
announcements = list_broadcasts(config.posts_dir)
create_error = str(error)
if return_to.startswith("/announcements/"):
directory_name = return_to.removeprefix("/announcements/").strip("/")
post_dir = config.posts_dir / directory_name
if post_dir.exists():
broadcast = load_broadcast(post_dir)
return templates.TemplateResponse(
request=request,
name="editor.html",
context=build_template_context(
request,
broadcast,
announcements,
config,
current_path=return_to,
create_error=create_error,
),
status_code=409,
)
return templates.TemplateResponse(
request=request,
name="instructions.html",
context=build_template_context(
request,
None,
announcements,
config,
current_path=return_to if return_to.startswith("/") else "/",
create_error=create_error,
),
status_code=409,
)
return RedirectResponse(f"/announcements/{directory_name}", status_code=303)
@app.post("/announcements/{directory_name}/move")
async def move_broadcast_route(
request: Request,
directory_name: str,
direction: str = Form(...),
return_to: str = Form("/"),
):
require_permission(request, config, "save_announcements")
move_broadcast(config.posts_dir, directory_name, direction)
if not return_to.startswith("/"):
return_to = "/"
return RedirectResponse(return_to, status_code=303)
@app.get("/announcements/{directory_name}", response_class=HTMLResponse)
async def broadcast_editor(request: Request, directory_name: str):
require_auth(request)
post_dir = config.posts_dir / directory_name
if not post_dir.exists():
raise HTTPException(status_code=404)
broadcast = load_broadcast(post_dir)
announcements = list_broadcasts(config.posts_dir)
return templates.TemplateResponse(
request=request,
name="editor.html",
context=build_template_context(
request,
broadcast,
announcements,
config,
current_path=f"/announcements/{directory_name}",
),
)
@app.get("/api/announcements/{directory_name}")
async def broadcast_editor_state(request: Request, directory_name: str):
require_api_auth(request)
post_dir = config.posts_dir / directory_name
if not post_dir.exists():
raise HTTPException(status_code=404)
broadcast = load_broadcast(post_dir)
return JSONResponse(build_editor_api_payload(request, config, broadcast))
@app.get("/announcements/{directory_name}/images/{filename}")
async def broadcast_image(request: Request, directory_name: str, filename: str):
require_auth(request)
image_path = config.posts_dir / directory_name / filename
if not image_path.exists() or not image_path.is_file():
raise HTTPException(status_code=404)
return FileResponse(image_path)
@app.post("/api/announcements/{directory_name}/save")
async def save_broadcast_route(request: Request, directory_name: str):
current_user = require_permission(request, config, "save_announcements", api=True)
post_dir = config.posts_dir / directory_name
payload = await request.json()
validate_save_permissions(payload, current_user["permissions"])
result = save_broadcast_changes(post_dir, payload)
broadcast = load_broadcast(result["post_dir"])
return JSONResponse(build_editor_api_payload(request, config, broadcast, save_result=result["results"]))
@app.post("/api/announcements/{directory_name}/upload-image")
async def upload_image_route(
request: Request,
directory_name: str,
scope: str = Form(...),
file: UploadFile = File(...),
):
require_permission(request, config, "edit_content", api=True)
post_dir = config.posts_dir / directory_name
content = await file.read()
staged = stage_image_upload(post_dir, scope, file.filename or "upload.bin", content)
return JSONResponse(staged)
@app.post("/api/announcements/{directory_name}/import-image-url")
async def import_image_url_route(request: Request, directory_name: str):
require_permission(request, config, "edit_content", api=True)
post_dir = config.posts_dir / directory_name
payload = await request.json()
raw_url = str(payload.get("url", "")).strip()
filename, content = download_image_from_url(raw_url)
staged = stage_image_upload(post_dir, str(payload.get("scope", "")).strip(), filename, content)
return JSONResponse(staged)
@app.post("/api/announcements/{directory_name}/set-telegram-mapping")
async def set_telegram_mapping_route(request: Request, directory_name: str):
require_permission(request, config, "edit_targets", api=True)
payload = await request.json()
post_dir = config.posts_dir / directory_name
broadcast = load_broadcast(post_dir)
metadata = broadcast["metadata"]
binding = get_binding(metadata)
raw_rows = payload.get("rows", [])
if not isinstance(raw_rows, list):
raise HTTPException(status_code=400, detail="Telegram mapping rows must be provided as a list.")
target_bot_id = str(payload.get("telegram_bot_id", metadata.get("telegram_bot_id", ""))).strip()
target_chat_id = str(payload.get("telegram_chat_id", metadata.get("telegram_chat_id", ""))).strip()
if not target_bot_id or not target_chat_id:
raise HTTPException(status_code=400, detail="Choose a Telegram bot and chat before saving mapping.")
poster_config = resolve_poster_config(config.config_dir, target_bot_id, target_chat_id)
updated_binding = build_binding_from_manual_rows(metadata, binding, raw_rows, poster_config)
metadata["telegram_bot_id"] = target_bot_id
metadata["telegram_chat_id"] = target_chat_id
metadata["field_revisions"]["telegram_bot_id"] = int(metadata["field_revisions"]["telegram_bot_id"]) + 1
metadata["field_revisions"]["telegram_chat_id"] = int(metadata["field_revisions"]["telegram_chat_id"]) + 1
metadata["telegram_binding"] = serialize_binding(updated_binding)
timestamp = now_iso()
if binding_has_channel_links(updated_binding):
metadata["status"] = "published"
metadata["telegram_published_at"] = metadata.get("telegram_published_at") or timestamp
else:
metadata["status"] = "draft"
metadata["telegram_published_at"] = None
metadata["updated_at"] = timestamp
save_metadata(post_dir, metadata)
return JSONResponse(build_editor_api_payload(request, config, load_broadcast(post_dir)))
@app.post("/api/preview")
async def preview_route(request: Request):
require_api_auth(request)
payload = await request.json()
raw_text = str(payload.get("text", ""))
available_targets = {
Language(language)
for language in payload.get("available_targets", [])
if language in {"fi", "sv", "en"}
}
return JSONResponse({"html": render_preview_html(raw_text, available_targets)})
@app.post("/api/announcements/{directory_name}/publish")
async def publish_route(request: Request, directory_name: str):
require_permission(request, config, "post_now", api=True)
post_dir = config.posts_dir / directory_name
broadcast = load_broadcast(post_dir)
metadata = broadcast["metadata"]
validate_selected_target(config, metadata)
if metadata["scheduled_for"]:
raise HTTPException(status_code=400, detail="Scheduled announcements must be unscheduled before posting now.")
if metadata["status"] == "published":
raise HTTPException(status_code=400, detail="Published announcements must be unlinked before posting again.")
if directory_name in request.app.state.operation_tasks:
operation = request.app.state.operations[directory_name]
return JSONResponse(build_editor_api_payload(request, config, broadcast, operation=operation), status_code=202)
operation = {
"action": "publish",
"running": True,
"completed": False,
"output": "",
"result": None,
"error": None,
}
request.app.state.operations[directory_name] = operation
task = asyncio.create_task(run_publish_operation(request.app, directory_name, post_dir, config))
request.app.state.operation_tasks[directory_name] = task
return JSONResponse(build_editor_api_payload(request, config, broadcast, operation=operation), status_code=202)
@app.get("/api/announcements/{directory_name}/operation")
async def operation_status_route(request: Request, directory_name: str):
require_api_auth(request)
post_dir = config.posts_dir / directory_name
broadcast = load_broadcast(post_dir) if post_dir.exists() else None
operation = request.app.state.operations.get(
directory_name,
{
"action": None,
"running": False,
"completed": False,
"output": "",
"result": None,
"error": None,
},
)
return JSONResponse(build_editor_api_payload(request, config, broadcast, operation=operation))
@app.post("/api/announcements/{directory_name}/schedule")
async def schedule_route(request: Request, directory_name: str):
require_permission(request, config, "schedule", api=True)
post_dir = config.posts_dir / directory_name
metadata = load_metadata(post_dir)
validate_selected_target(config, metadata)
if get_binding(metadata) is not None and not (
metadata["dirty_text_languages"] or metadata.get("dirty_discussion_pinning", False)
):
raise HTTPException(status_code=400, detail="Published announcements need saved text changes before scheduling modifications.")
payload = await request.json()
schedule_value = str(payload["scheduled_for"]).strip()
scheduled_iso = convert_local_schedule_to_iso(schedule_value, config.timezone)
mark_scheduled(post_dir, scheduled_iso, schedule_value)
broadcast = load_broadcast(post_dir)
return JSONResponse(build_editor_api_payload(request, config, broadcast))
@app.post("/api/announcements/{directory_name}/unschedule")
async def unschedule_route(request: Request, directory_name: str):
require_permission(request, config, "unschedule", api=True)
post_dir = config.posts_dir / directory_name
unschedule(post_dir)
broadcast = load_broadcast(post_dir)
return JSONResponse(build_editor_api_payload(request, config, broadcast))
@app.post("/api/announcements/{directory_name}/schedule-unpin")
async def schedule_unpin_route(request: Request, directory_name: str):
require_permission(request, config, "schedule", api=True)
require_permission(request, config, "edit_targets", api=True)
post_dir = config.posts_dir / directory_name
metadata = load_metadata(post_dir)
payload = await request.json()
schedule_value = str(payload["scheduled_for"]).strip()
scheduled_iso = convert_local_schedule_to_iso(schedule_value, config.timezone)
validate_selected_target(config, metadata)
poster_config = resolve_effective_poster_config(config, metadata)
if not poster_config.discussion_group_id:
raise HTTPException(status_code=400, detail="Selected Telegram chat does not have a discussion group.")
if get_binding(metadata) is None and metadata.get("status") != "scheduled":
raise HTTPException(status_code=400, detail="Announcement must be linked to Telegram or scheduled before scheduling an unpin.")
validate_scheduled_unpin_time(metadata, scheduled_iso)
set_scheduled_unpin_for(post_dir, scheduled_iso)
return JSONResponse(build_editor_api_payload(request, config, load_broadcast(post_dir)))
@app.post("/api/announcements/{directory_name}/cancel-unpin")
async def cancel_unpin_route(request: Request, directory_name: str):
require_permission(request, config, "unschedule", api=True)
require_permission(request, config, "edit_targets", api=True)
post_dir = config.posts_dir / directory_name
set_scheduled_unpin_for(post_dir, None)
return JSONResponse(build_editor_api_payload(request, config, load_broadcast(post_dir)))
@app.post("/api/announcements/{directory_name}/delete-telegram")
async def delete_telegram_route(request: Request, directory_name: str):
require_permission(request, config, "delete_telegram", api=True)
post_dir = config.posts_dir / directory_name
metadata = load_metadata(post_dir)
payload = await request.json()
binding = get_binding(metadata)
if binding is None:
raise HTTPException(status_code=400, detail="Announcement is not linked to Telegram.")
delete_expired = is_telegram_delete_expired(metadata)
progress = LogProgressDisplay()
try:
poster_config = resolve_delete_target_config(config, metadata, payload)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
result = delete_binding_from_telegram(poster_config, binding, progress)
if result.failures:
if result.binding is not None:
save_binding(post_dir, result.binding)
first_error = result.failures[0]
if delete_expired and is_message_not_deletable_error(first_error):
deadline = telegram_delete_deadline(metadata)
detail = (
"Telegram no longer allows the bot to delete these messages after 48 hours. "
f"They became non-deletable at {format_timestamp_for_timezone(deadline.isoformat(), config.timezone)}. "
"Delete them manually in Telegram if needed."
)
elif len(result.failures) == 1:
detail = first_error
else:
detail = f"{len(result.failures)} messages could not be deleted. First error: {first_error}"
return JSONResponse(
build_editor_api_payload(
request,
config,
load_broadcast(post_dir),
output=progress.get_output(),
warning=detail,
)
)
if result.binding is None:
unlink_telegram(post_dir)
else:
save_binding(post_dir, result.binding)
broadcast = load_broadcast(post_dir)
warning = None
if result.warnings:
warning = (
"Some Telegram messages were already missing and may have been deleted manually. "
"Local Telegram links were updated to match the remaining messages."
)
return JSONResponse(
build_editor_api_payload(
request,
config,
broadcast,
output=progress.get_output(),
warning=warning,
)
)
@app.post("/api/announcements/{directory_name}/unlink")
async def unlink_route(request: Request, directory_name: str):
require_permission(request, config, "unlink_telegram", api=True)
post_dir = config.posts_dir / directory_name
unlink_telegram(post_dir)
broadcast = load_broadcast(post_dir)
return JSONResponse(build_editor_api_payload(request, config, broadcast))
@app.post("/api/announcements/{directory_name}/delete-storage")
async def delete_storage_route(request: Request, directory_name: str):
require_permission(request, config, "delete_storage", api=True)
post_dir = config.posts_dir / directory_name
delete_from_storage(post_dir)
return JSONResponse({"deleted": True})
@app.post("/api/announcements/{directory_name}/clear-status")
async def clear_status_route(request: Request, directory_name: str):
require_permission(request, config, "save_announcements", api=True)
post_dir = config.posts_dir / directory_name
clear_status_history(post_dir)
broadcast = load_broadcast(post_dir)
return JSONResponse(build_editor_api_payload(request, config, broadcast))
return app
async def scheduler_loop(app: FastAPI) -> None:
config: WebAppConfig = app.state.webapp_config
recover_missed_jobs = True
while True:
async with app.state.scheduler_lock:
due_items = []
due_unpins = []
for item in list_broadcasts(config.posts_dir):
if is_scheduled_publish_due(item, recover_missed_jobs):
due_items.append((datetime.fromisoformat(item["scheduled_for"]), item))
metadata = load_metadata(config.posts_dir / item["directory"])
if metadata.get("scheduled_unpin_for"):
scheduled_unpin = datetime.fromisoformat(metadata["scheduled_unpin_for"])
if scheduled_unpin <= datetime.now(scheduled_unpin.tzinfo):
due_unpins.append((scheduled_unpin, item["directory"]))
for _, item in sorted(due_items, key=lambda item: (item[0], item[1]["directory"].lower())):
try:
await run_publish_action(config.posts_dir / item["directory"], config, scheduled_attempt=True)
except Exception:
logger.exception("Scheduled publish crashed for %s", item["directory"])
for _, directory_name in sorted(due_unpins, key=lambda item: (item[0], item[1].lower())):
try:
await run_unpin_action(config.posts_dir / directory_name, config)
except Exception:
logger.exception("Scheduled unpin crashed for %s", directory_name)
recover_missed_jobs = False
await asyncio.sleep(config.scheduler_poll_seconds)
def is_scheduled_publish_due(item: dict, recover_missed_jobs: bool) -> bool:
if item["status"] != "scheduled" or not item["scheduled_for"]:
return False
scheduled_time = datetime.fromisoformat(item["scheduled_for"])
if scheduled_time > datetime.now(scheduled_time.tzinfo):
return False
if recover_missed_jobs:
return True
next_attempt = item.get("scheduled_next_attempt_at")
if not next_attempt:
return True
retry_time = datetime.fromisoformat(next_attempt)
return retry_time <= datetime.now(retry_time.tzinfo)
async def run_publish_action(
post_dir: Path,
config: WebAppConfig,
scheduled_attempt: bool = False,
) -> dict:
return await perform_publish_action(
post_dir,
config,
LogProgressDisplay(),
scheduled_attempt=scheduled_attempt,
)
async def run_unpin_action(post_dir: Path, config: WebAppConfig) -> dict:
metadata = load_metadata(post_dir)
binding = get_binding(metadata)
if binding is None:
set_scheduled_unpin_for(post_dir, None)
return {"status": "skipped", "output": "Announcement is not linked to Telegram."}
poster_config = resolve_effective_poster_config(config, metadata)
progress = LogProgressDisplay()
ordered_languages = [
Language(language)
for language in metadata["language_order"]
if metadata["enabled_languages"].get(language, True)
]
pinning_result = apply_discussion_group_pinning(
poster_config,
binding,
ordered_languages,
"none",
delete_service_messages=False,
progress=progress,
)
set_scheduled_unpin_for(post_dir, None)
metadata = load_metadata(post_dir)
metadata["last_attempt_at"] = now_iso()
metadata["last_output"] = append_status_output(
metadata.get("last_output", ""),
progress.get_output(),
"updated",
metadata["last_attempt_at"],
)
metadata["last_result"] = "updated"
if metadata.get("discussion_pinning_mode") != "none":
metadata["discussion_pinning_mode"] = "none"
metadata["field_revisions"]["discussion_pinning_mode"] = int(metadata["field_revisions"]["discussion_pinning_mode"]) + 1
metadata["dirty_discussion_pinning"] = False
metadata["telegram_binding"] = serialize_binding(pinning_result.binding)
metadata["updated_at"] = metadata["last_attempt_at"]
save_metadata(post_dir, metadata)
return {"status": "updated", "output": progress.get_output()}
async def run_publish_operation(app: FastAPI, directory_name: str, post_dir: Path, config: WebAppConfig) -> None:
operation = app.state.operations[directory_name]
def on_change(output: str) -> None:
operation["output"] = output
progress = LogProgressDisplay(on_change=on_change)
try:
async with app.state.scheduler_lock:
result = await run_publish_action_with_progress(post_dir, config, progress)
if result["status"] == "failed":
retry_step = progress.add_step("Retry failed publish", status="in_progress")
result = await run_publish_action_with_progress(post_dir, config, progress)
progress.update(retry_step, "done" if result["status"] != "failed" else "failed")
result["output"] = progress.get_output()
operation["running"] = False
operation["completed"] = True
operation["result"] = result["status"]
operation["output"] = result["output"]
operation["error"] = None
except Exception as error:
operation["running"] = False
operation["completed"] = True
operation["result"] = "failed"
operation["error"] = str(error)
if progress.get_output():
operation["output"] = progress.get_output() + "\n" + str(error)
else:
operation["output"] = str(error)
finally:
app.state.operation_tasks.pop(directory_name, None)
async def run_publish_action_with_progress(post_dir: Path, config: WebAppConfig, progress: LogProgressDisplay) -> dict:
return await perform_publish_action(post_dir, config, progress)
async def perform_publish_action(
post_dir: Path,
config: WebAppConfig,
progress: LogProgressDisplay,
scheduled_attempt: bool = False,
) -> dict:
metadata = load_metadata(post_dir)
binding = get_binding(metadata)
published_binding = None
try:
poster_config = resolve_effective_poster_config(config, metadata)
disable_link_previews = {
Language(language): bool(metadata["disable_link_previews"].get(language, False))
for language in metadata["language_order"]
}
ordered_languages = [
Language(language)
for language in metadata["language_order"]
if metadata["enabled_languages"].get(language, True)
]
if binding is not None and (metadata["dirty_text_languages"] or metadata.get("dirty_discussion_pinning", False)):
updated_binding = binding
effective_pinning_mode = metadata["discussion_pinning_mode"]
if metadata["dirty_text_languages"]:
updated_binding = update_texts(
directory=post_dir,
config=poster_config,
binding=updated_binding,
changed_languages={Language(language) for language in metadata["dirty_text_languages"]},
disable_link_previews=disable_link_previews,
progress=progress,
)
if poster_config.discussion_group_id and metadata.get("dirty_discussion_pinning", False):
if has_complete_discussion_links(updated_binding, ordered_languages):
pinning_result = apply_discussion_group_pinning(
poster_config,
updated_binding,
ordered_languages,
metadata["discussion_pinning_mode"],
delete_service_messages=bool(metadata.get("delete_pin_service_messages", False)),
progress=progress,
)
updated_binding = pinning_result.binding
if pinning_result.pinning_failed and effective_pinning_mode != "last":
effective_pinning_mode = "last"
progress.add_step(
"Discussion pinning fallback",
status="done",
detail="Pinning failed, so the saved mode was changed to only last message.",
)
elif effective_pinning_mode not in {"none", "last"}:
effective_pinning_mode = "last"
progress.add_step(
"Discussion pinning unavailable",
status="done",
detail="Discussion-group links are incomplete, so the saved mode was changed to only last message until links are repaired.",
)
mark_publish_success_with_result(
post_dir,
updated_binding,
progress.get_output(),
"updated",
effective_discussion_pinning_mode=effective_pinning_mode,
)
return {"status": "updated", "output": progress.get_output()}
update_offset = 0
if poster_config.discussion_group_id:
update_offset = snapshot_next_update_offset(poster_config)
publish_result = publish_directory(
directory=post_dir,
config=poster_config,
ordered_languages=ordered_languages,
disable_link_previews=disable_link_previews,
use_captions=True,
progress=progress,
)
published_binding = publish_result.binding
effective_pinning_mode = metadata["discussion_pinning_mode"]
if poster_config.discussion_group_id:
published_binding, update_offset = ensure_discussion_group_message_ids(
poster_config,
published_binding,
ordered_languages,
update_offset=update_offset,
progress=progress,
required=False,
)
if not has_complete_discussion_links(published_binding, ordered_languages):
updated_binding = published_binding
if effective_pinning_mode not in {"none", "last"}:
effective_pinning_mode = "last"
progress.add_step(
"Discussion pinning unavailable",
status="done",
detail="Discussion-group links are incomplete, so the saved mode was changed to only last message until links are repaired.",
)
elif metadata["discussion_pinning_mode"] != "last":
pinning_result = apply_discussion_group_pinning(
poster_config,
published_binding,
ordered_languages,
metadata["discussion_pinning_mode"],
delete_service_messages=bool(metadata.get("delete_pin_service_messages", False)),
update_offset=update_offset,
progress=progress,
)
updated_binding = pinning_result.binding
if pinning_result.pinning_failed and effective_pinning_mode != "last":
effective_pinning_mode = "last"
progress.add_step(
"Discussion pinning fallback",
status="done",
detail="Pinning failed, so the saved mode was changed to only last message.",
)
else:
updated_binding = published_binding
else:
updated_binding = published_binding
mark_publish_success_with_result(
post_dir,
updated_binding,
progress.get_output(),
"published",
effective_discussion_pinning_mode=effective_pinning_mode,
)
return {"status": "published", "output": progress.get_output()}
except Exception as error:
during_update = (
binding is not None
or published_binding is not None
) and bool(
metadata["dirty_text_languages"] or metadata.get("dirty_discussion_pinning", False)
)
output = progress.get_output()
if output:
output += "\n"
output += str(error)
if binding is None and published_binding is not None:
if scheduled_attempt:
mark_scheduled_publish_failure(post_dir, output, partial_binding=published_binding)
return {"status": "failed", "output": output}
mark_publish_success_with_result(post_dir, published_binding, output, "published")
metadata_after_partial_publish = load_metadata(post_dir)
metadata_after_partial_publish["status"] = "modified"
metadata_after_partial_publish["last_result"] = "update_failed"
metadata_after_partial_publish["dirty_discussion_pinning"] = True
save_metadata(post_dir, metadata_after_partial_publish)
return {"status": "failed", "output": output}
if scheduled_attempt:
mark_scheduled_publish_failure(post_dir, output)
else:
mark_publish_failure(post_dir, output, during_update=during_update)
return {"status": "failed", "output": output}
def require_auth(request: Request) -> str:
username = request.session.get("username")
if not username:
raise HTTPException(status_code=303, headers={"Location": "/login"})
return username
def require_api_auth(request: Request) -> str:
username = request.session.get("username")
if not username:
raise HTTPException(status_code=401, detail="Authentication required.")
return username
def require_permission(request: Request, config: WebAppConfig, permission_key: str, api: bool = False) -> dict:
user_record = get_current_user_record(request, config, api=api)
permissions = user_record["permissions"]
if not permissions.get(permission_key, False):
if api:
raise HTTPException(status_code=403, detail=f"Permission denied: {permission_key}")
raise HTTPException(status_code=403, detail="Permission denied.")
return user_record
def validate_save_permissions(payload: dict, permissions: dict[str, bool]) -> None:
if any(payload.get(field) is not None for field in ("title", "language_order", "enabled_languages", "disable_link_previews")):
require_save_capability(permissions, "edit_content")
for field in ("texts", "images"):
value = payload.get(field, {})
if isinstance(value, dict) and value:
require_save_capability(permissions, "edit_content")
break
if any(
payload.get(field) is not None
for field in ("telegram_bot_id", "telegram_chat_id", "discussion_pinning_mode", "delete_pin_service_messages")
):
require_save_capability(permissions, "edit_targets")
def require_save_capability(permissions: dict[str, bool], permission_key: str) -> None:
if not permissions.get(permission_key, False):
raise HTTPException(status_code=403, detail=f"Permission denied: {permission_key}")
def get_current_user_record(request: Request, config: WebAppConfig, api: bool = False) -> dict:
username = request.session.get("username")
if not username:
if api:
raise HTTPException(status_code=401, detail="Authentication required.")
raise HTTPException(status_code=303, headers={"Location": "/login"})
users = load_users(config.users_file)
user_record = users.get(username)
if user_record is None:
request.session.clear()
if api:
raise HTTPException(status_code=401, detail="Authentication required.")
raise HTTPException(status_code=303, headers={"Location": "/login"})
request.session["ui_language"] = user_record["language"]
return {"username": username, **user_record}
def resolve_request_translations(request: Request, selected_language: str) -> tuple[str, dict]:
effective_language = resolve_effective_ui_language(request, selected_language)
return effective_language, load_translations(effective_language)
def build_template_context(
request: Request,
broadcast: dict | None,
announcements: list[dict],
config: WebAppConfig,
current_path: str,
create_error: str | None = None,
) -> dict:
current_user = get_current_user_record(request, config)
effective_language, translations = resolve_request_translations(request, current_user["language"])
return {
"request": request,
"broadcast": serialize_broadcast_for_client(broadcast, config) if broadcast else None,
"announcements": serialize_sidebar_broadcasts(announcements, config, translations),
"permissions": current_user["permissions"],
"telegram_targets": serialise_catalog_for_client(load_telegram_target_catalog(config.config_dir)),
"timezone": config.timezone,
"translations": translations,
"ui_language": current_user["language"],
"effective_ui_language": effective_language,
"language_options": language_options(),
"current_path": current_path,
"create_error": create_error,
}
def build_login_context(request: Request, error_key: str | None) -> dict:
selected_language = normalize_login_language(request.session.get("login_ui_language"))
effective_language, translations = resolve_request_translations(request, selected_language)
return {
"request": request,
"translations": translations,
"error": translations[error_key] if error_key else None,
"login_ui_language": selected_language,
"effective_ui_language": effective_language,
"login_language_options": [
{"code": "auto", "label": translations["ui_language.auto"]},
{"code": "fi", "label": translations["ui_language.fi"]},
{"code": "sv", "label": translations["ui_language.sv"]},
{"code": "en", "label": translations["ui_language.en"]},
],
}
def serialize_broadcast_for_client(broadcast: dict | None, config: WebAppConfig) -> dict | None:
if broadcast is None:
return None
metadata = broadcast["metadata"]
delete_deadline = telegram_delete_deadline(metadata)
target_catalog = load_telegram_target_catalog(config.config_dir)
return {
"directory": broadcast["directory"],
"metadata": metadata,
"telegram_delete_deadline": delete_deadline.isoformat() if delete_deadline else None,
"texts": broadcast["texts"],
"images": {
scope: [
{"name": image_name, "url": f"/announcements/{broadcast['directory']}/images/{image_name}"}
for image_name in image_names
]
for scope, image_names in broadcast["images"].items()
},
"telegram_targets": serialise_catalog_for_client(target_catalog),
"discussion_group_link_chat_id": discussion_group_link_chat_id(config, metadata),
"timezone": config.timezone,
}
def discussion_group_link_chat_id(config: WebAppConfig, metadata: dict) -> str:
try:
poster_config = resolve_effective_poster_config(config, metadata)
except ValueError:
return ""
discussion_group_id = str(poster_config.discussion_group_id).strip()
if discussion_group_id.startswith("-100") and len(discussion_group_id) > 4:
return discussion_group_id[4:]
return discussion_group_id.lstrip("-")
def validate_scheduled_unpin_time(metadata: dict, scheduled_unpin_for: str) -> None:
scheduled_publish_for = metadata.get("scheduled_for")
if not scheduled_publish_for:
return
scheduled_publish_time = datetime.fromisoformat(str(scheduled_publish_for))
scheduled_unpin_time = datetime.fromisoformat(str(scheduled_unpin_for))
minimum_unpin_time = scheduled_publish_time + timedelta(minutes=1)
if scheduled_unpin_time < minimum_unpin_time:
raise HTTPException(status_code=400, detail="Scheduled unpin must be at least 1 minute after the scheduled publish time.")
def convert_local_schedule_to_iso(raw_value: str, timezone_name: str) -> str:
naive = datetime.fromisoformat(raw_value)
aware = naive.replace(tzinfo=ZoneInfo(timezone_name))
return aware.isoformat(timespec="seconds")
def download_image_from_url(raw_url: str) -> tuple[str, bytes]:
parsed = urlparse(raw_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise HTTPException(status_code=400, detail="Provide a valid http or https image URL.")
request = UrlRequest(raw_url, headers={"User-Agent": "LinkkiBot/1.0"})
try:
with urlopen(request, timeout=15) as response:
content_type = response.headers.get_content_type()
if not content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="URL did not return an image.")
content = response.read()
except HTTPError as error:
raise HTTPException(status_code=400, detail=f"Image download failed with HTTP {error.code}.") from error
except URLError as error:
raise HTTPException(status_code=400, detail=f"Image download failed: {error.reason}") from error
filename = imported_image_filename(raw_url, content_type)
return filename, content
def imported_image_filename(raw_url: str, content_type: str) -> str:
parsed = urlparse(raw_url)
basename = Path(unquote(parsed.path)).name
extension = image_extension_for_import(basename, content_type)
stem = Path(basename).stem or "imported-image"
return f"{stem}.{extension}"
def image_extension_for_import(filename: str, content_type: str) -> str:
by_content_type = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
}
extension = by_content_type.get(content_type.lower())
if extension:
return extension
guessed_content_type, _ = guess_type(filename)
fallback = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
}.get((guessed_content_type or "").lower())
if fallback:
return fallback
raise HTTPException(status_code=400, detail="Unsupported image type.")
def serialize_sidebar_broadcasts(broadcasts: list[dict], config: WebAppConfig, translations: dict) -> list[dict]:
target_catalog = load_telegram_target_catalog(config.config_dir)
serialized: list[dict] = []
total_items = len(broadcasts)
for index, item in enumerate(broadcasts):
copied = dict(item)
copied["sidebar_meta_lines"] = []
copied["can_move_up"] = index > 0
copied["can_move_down"] = index < total_items - 1
copied["status_symbol"] = status_symbol_for_sidebar(item["status"])
copied["sidebar_links_incomplete"] = has_incomplete_sidebar_telegram_links(item)
copied["sidebar_has_pins"] = sidebar_has_pins(item)
copied["sidebar_pin_status"] = "scheduled" if item.get("scheduled_unpin_for") else item["status"]
copied["target_summary"] = build_target_summary(item, target_catalog, translations)
if item["status"] in {"published", "modified"} and item.get("last_attempt_at"):
label = translations["status.sidebar_updated"] if item.get("last_result") == "updated" else translations["status.sidebar_published"]
copied["sidebar_meta_lines"].append(
f"{label}: {format_timestamp_for_timezone(item['last_attempt_at'], config.timezone)}"
)
if item.get("scheduled_for"):
copied["sidebar_meta_lines"].append(
f"{translations['status.sidebar_scheduled']}: {format_timestamp_for_timezone(item['scheduled_for'], config.timezone)}"
)
serialized.append(copied)
return serialized
def status_symbol_for_sidebar(status: str) -> str:
return {
"published": "\u2714",
"failed": "\u2715",
"scheduled": "\u25f7",
"modified": "\u270e",
}.get(status, "")
def sidebar_binding_rows(item: dict) -> list[tuple[int | None, int | None]]:
binding_payload = item.get("telegram_binding")
if not isinstance(binding_payload, dict):
return []
rows: list[tuple[int | None, int | None]] = []
global_primary = parse_optional_binding_message_id(binding_payload.get("global_primary_message_id"))
global_discussion = parse_optional_binding_message_id(binding_payload.get("global_discussion_message_id"))
if global_primary is not None or global_discussion is not None:
rows.append((global_primary, global_discussion))
languages_payload = binding_payload.get("languages", {})
if isinstance(languages_payload, dict):
for language_payload in languages_payload.values():
if not isinstance(language_payload, dict):
continue
primary_message_id = parse_optional_binding_message_id(language_payload.get("primary_message_id"))
discussion_message_id = parse_optional_binding_message_id(language_payload.get("discussion_message_id"))
if primary_message_id is not None or discussion_message_id is not None:
rows.append((primary_message_id, discussion_message_id))
return rows
def has_incomplete_sidebar_telegram_links(item: dict) -> bool:
rows = sidebar_binding_rows(item)
return any(primary_message_id is None or discussion_message_id is None for primary_message_id, discussion_message_id in rows)
def sidebar_has_pins(item: dict) -> bool:
if not item.get("linked"):
return False
if has_incomplete_sidebar_telegram_links(item):
return False
return normalize_discussion_pinning_mode(item.get("discussion_pinning_mode")) != "none"
def build_target_summary(item: dict, target_catalog, translations: dict) -> str:
bot_id = str(item.get("telegram_bot_id", "")).strip()
chat_id = str(item.get("telegram_chat_id", "")).strip()
if not bot_id and not chat_id:
return translations["sidebar.no_target_selected"]
resolved_bot_name, resolved_chat_name = resolve_target_labels(target_catalog, bot_id, chat_id)
if resolved_bot_name and resolved_chat_name:
return f"{resolved_bot_name} / {resolved_chat_name}"
if resolved_bot_name:
return resolved_bot_name
if resolved_chat_name:
return resolved_chat_name
return translations["sidebar.no_target_selected"]
def build_editor_api_payload(
request: Request,
config: WebAppConfig,
broadcast: dict | None,
*,
operation: dict | None = None,
save_result: dict | None = None,
output: str | None = None,
warning: str | None = None,
) -> dict:
current_user = get_current_user_record(request, config, api=True)
effective_language = resolve_effective_ui_language(request, current_user["language"])
translations = load_translations(effective_language)
payload = {
"broadcast": serialize_broadcast_for_client(broadcast, config) if broadcast else None,
"announcements": serialize_sidebar_broadcasts(list_broadcasts(config.posts_dir), config, translations),
}
if operation is not None:
payload["operation"] = operation
if save_result is not None:
payload["save_result"] = save_result
if output is not None:
payload["output"] = output
if warning is not None:
payload["warning"] = warning
return payload
def ordered_binding_languages(metadata: dict) -> list[Language]:
ordered: list[Language] = []
for item in metadata.get("language_order", []):
try:
ordered.append(Language(str(item)))
except ValueError:
continue
return ordered
def has_complete_discussion_links(binding, ordered_languages: list[Language]) -> bool:
if binding is None:
return False
primary_channel_message_ids = build_primary_channel_message_ids(binding, ordered_languages)
return bool(primary_channel_message_ids) and all(
channel_message_id in binding.discussion_message_ids
for channel_message_id in primary_channel_message_ids
)
def binding_has_channel_links(binding: PublishBinding | None) -> bool:
if binding is None:
return False
return bool(
binding.global_primary_message_id
or any(language_binding.primary_message_id for language_binding in binding.languages.values())
)
def parse_binding_message_id(raw_value: object, label: str) -> int:
text = str(raw_value).strip()
if not text.lstrip("-").isdigit():
raise HTTPException(status_code=400, detail=f"Invalid {label}: {raw_value!r}")
return int(text)
def parse_optional_binding_message_id(raw_value: object) -> int | None:
if raw_value in (None, ""):
return None
return parse_binding_message_id(raw_value, "message id")
def build_binding_from_manual_rows(
metadata: dict,
existing_binding: PublishBinding | None,
rows: list[object],
poster_config,
) -> PublishBinding:
link_target = resolve_link_target(poster_config.chat_id, normalize_channel_username(poster_config.channel_username))
languages = dict(existing_binding.languages) if existing_binding is not None else {}
global_message_ids = list(existing_binding.global_message_ids) if existing_binding is not None else []
global_attached_to_language = existing_binding.global_attached_to_language if existing_binding is not None else None
discussion_message_ids: dict[int, int] = {}
global_primary_message_id = existing_binding.global_primary_message_id if existing_binding is not None else None
global_discussion_message_id = existing_binding.global_discussion_message_id if existing_binding is not None else None
for row in rows:
if not isinstance(row, dict):
continue
row_kind = str(row.get("kind", "")).strip()
channel_message_id = parse_optional_binding_message_id(row.get("channel_message_id"))
discussion_message_id = parse_optional_binding_message_id(row.get("discussion_message_id"))
if row_kind == "global":
global_message_ids = [channel_message_id] if channel_message_id else []
global_primary_message_id = channel_message_id or None
global_discussion_message_id = discussion_message_id if channel_message_id else None
global_attached_to_language = None if global_message_ids else global_attached_to_language
if channel_message_id and discussion_message_id:
discussion_message_ids[channel_message_id] = discussion_message_id
continue
if row_kind != "language":
continue
language = Language(str(row.get("language")))
existing_language = languages.get(language)
if not channel_message_id:
languages.pop(language, None)
continue
if existing_language is None:
languages[language] = LanguageBinding(
language=language,
image_message_ids=[],
text_segments=[TextSegmentBinding(message_id=channel_message_id, mode="message")] if channel_message_id else [],
primary_message_id=channel_message_id,
primary_message_url=build_message_url(link_target, channel_message_id) if channel_message_id else "",
discussion_message_id=discussion_message_id or None,
)
elif existing_language.text_segments:
text_segments = list(existing_language.text_segments)
languages[language] = LanguageBinding(
language=language,
image_message_ids=list(existing_language.image_message_ids),
text_segments=text_segments,
primary_message_id=channel_message_id or None,
primary_message_url=build_message_url(link_target, channel_message_id) if channel_message_id else "",
discussion_message_id=discussion_message_id or None,
)
elif existing_language.image_message_ids:
image_message_ids = list(existing_language.image_message_ids)
languages[language] = LanguageBinding(
language=language,
image_message_ids=image_message_ids,
text_segments=[],
primary_message_id=channel_message_id or None,
primary_message_url=build_message_url(link_target, channel_message_id) if channel_message_id else "",
discussion_message_id=discussion_message_id or None,
)
else:
languages[language] = LanguageBinding(
language=language,
image_message_ids=list(existing_language.image_message_ids),
text_segments=list(existing_language.text_segments),
primary_message_id=channel_message_id or None,
primary_message_url=build_message_url(link_target, channel_message_id) if channel_message_id else "",
discussion_message_id=discussion_message_id or None,
)
if channel_message_id and discussion_message_id:
discussion_message_ids[channel_message_id] = discussion_message_id
return PublishBinding(
global_message_ids=global_message_ids,
global_attached_to_language=global_attached_to_language,
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 validate_selected_target(config: WebAppConfig, metadata: dict) -> None:
try:
resolve_effective_poster_config(config, metadata)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
def resolve_delete_target_config(config: WebAppConfig, metadata: dict, payload: dict | None):
saved_bot_id = str(metadata.get("telegram_bot_id", "")).strip()
saved_chat_id = str(metadata.get("telegram_chat_id", "")).strip()
if saved_bot_id and saved_chat_id:
return resolve_poster_config(config.config_dir, saved_bot_id, saved_chat_id)
if saved_bot_id or saved_chat_id:
try:
return resolve_effective_poster_config(config, metadata)
except ValueError:
pass
temporary_bot_id = str((payload or {}).get("telegram_bot_id", "")).strip()
temporary_chat_id = str((payload or {}).get("telegram_chat_id", "")).strip()
if not temporary_bot_id or not temporary_chat_id:
raise ValueError("Choose a temporary Telegram bot and chat before trying to delete this linked announcement.")
return resolve_poster_config(config.config_dir, temporary_bot_id, temporary_chat_id)
def resolve_effective_poster_config(config: WebAppConfig, metadata: dict):
bot_id = str(metadata.get("telegram_bot_id", "")).strip()
chat_id = str(metadata.get("telegram_chat_id", "")).strip()
if bot_id and chat_id:
return resolve_poster_config(config.config_dir, bot_id, chat_id)
raise ValueError("Choose a Telegram bot and chat before posting or scheduling.")
def format_timestamp_for_timezone(raw_value: str, timezone_name: str) -> str:
source = datetime.fromisoformat(raw_value)
target = source.astimezone(ZoneInfo(timezone_name))
return target.strftime("%Y-%m-%d %H:%M:%S")
def normalize_login_language(value: object) -> str:
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"auto", *SUPPORTED_TRANSLATION_LANGUAGES}:
return lowered
return "auto"
def resolve_effective_ui_language(request: Request, selected_language: str) -> str:
if selected_language != "auto":
return selected_language
accepted_languages = parse_accept_language(request.headers.get("accept-language", ""))
for candidate in ("fi", "sv", "en"):
if candidate in accepted_languages:
return candidate
return "en"
def parse_accept_language(header_value: str) -> list[str]:
languages: list[str] = []
for part in header_value.split(","):
token = part.split(";")[0].strip().lower()
if not token:
continue
base = token.split("-")[0]
if base in SUPPORTED_TRANSLATION_LANGUAGES and base not in languages:
languages.append(base)
return languages
def create_error_app(message: str) -> FastAPI:
app = FastAPI()
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"])
async def error_page(path: str):
return PlainTextResponse(message, status_code=500)
return app
try:
app = create_app()
except ConfigFileError as error:
app = create_error_app(str(error))