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
1388 lines
60 KiB
Python
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))
|