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))