mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
230 lines
9.6 KiB
Python
230 lines
9.6 KiB
Python
"""Post-print outcome confirmation helpers (#1898)."""
|
|
|
|
import logging
|
|
from collections.abc import Mapping
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.models.print_log import PrintLogEntry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# How a verdict reached the archive. 'reaction' is written by the Telegram
|
|
# reaction poller (#3046, services/telegram_reactions.py) through
|
|
# apply_outcome_verdict below.
|
|
VERDICT_SOURCES = ("dialog", "link", "plate_clear", "printer_card", "api", "reaction")
|
|
|
|
# Link-preview unfurlers and mail-security scanners fetch every URL they find in
|
|
# a message, unattended, within seconds of it being sent. Nothing they can do
|
|
# with a GET records a verdict any more -- that is the POST route's job -- so
|
|
# this list is the second layer: it decides whether the confirmation page
|
|
# submits its own form, which is what keeps a human at one tap. A scanner that
|
|
# runs JavaScript would otherwise press the button on the operator's behalf.
|
|
# Matched as case-insensitive substrings of the User-Agent; the generic "bot"
|
|
# token covers TelegramBot, Discordbot, Slackbot-LinkExpanding, Twitterbot and
|
|
# LinkedInBot in one go.
|
|
UNATTENDED_FETCH_AGENTS = (
|
|
"bot",
|
|
"crawler",
|
|
"spider",
|
|
"facebookexternalhit",
|
|
"whatsapp",
|
|
"skypeuripreview",
|
|
"bingpreview",
|
|
"safelinks",
|
|
"urldefense",
|
|
"proofpoint",
|
|
"mimecast",
|
|
"barracuda",
|
|
"forcepoint",
|
|
)
|
|
|
|
# Prefetch / preload hints. A finger on a notification button is never one.
|
|
UNATTENDED_FETCH_HEADERS = {
|
|
"purpose": ("prefetch", "preview"),
|
|
"x-purpose": ("prefetch", "preview"),
|
|
"x-moz": ("prefetch",),
|
|
"sec-purpose": ("prefetch",),
|
|
}
|
|
|
|
|
|
def is_unattended_fetch(method: str, headers: Mapping[str, str]) -> bool:
|
|
"""Whether a request for a one-tap verdict link came from a machine.
|
|
|
|
Decides whether the confirmation page submits itself. False positives are
|
|
deliberately cheap -- a browser mistaken for a bot gets the same page with
|
|
a button to press -- so the lists above err towards catching more.
|
|
"""
|
|
if method.upper() != "GET":
|
|
# Anything that is not the page load is not a page load: only the GET
|
|
# route renders, and only a GET can be widened to HEAD by a future
|
|
# router change.
|
|
return True
|
|
for header, markers in UNATTENDED_FETCH_HEADERS.items():
|
|
value = (headers.get(header) or "").lower()
|
|
if value and any(marker in value for marker in markers):
|
|
return True
|
|
agent = (headers.get("user-agent") or "").lower()
|
|
return any(marker in agent for marker in UNATTENDED_FETCH_AGENTS)
|
|
|
|
|
|
# The one-tap marker. The confirmation page submits its own form only when the
|
|
# URL it was opened from carries this, and the marker is put on exactly one
|
|
# thing: the Telegram inline keyboard's buttons -- an affordance no unfurler,
|
|
# gateway or proxy reads, for the same reason the capability URLs themselves no
|
|
# longer travel in message text.
|
|
#
|
|
# It is what closes the gap the User-Agent list above cannot: a mail-security
|
|
# sandbox that renders HTML and runs JavaScript sends an ordinary Chrome string
|
|
# (so does literal HeadlessChrome), and a verdict URL that reached it did so out
|
|
# of the message BODY -- where the marker never appears. That fetch now gets the
|
|
# page with a button on it and records nothing. The operator's tap on the
|
|
# notification button still costs exactly one tap.
|
|
ONE_TAP_PARAM = "tap"
|
|
|
|
|
|
def one_tap_url(url: str) -> str:
|
|
"""Mark a verdict URL as one a human is about to press.
|
|
|
|
Only for the affordances a person taps directly. A URL that goes into text
|
|
anybody's machine might follow is left unmarked on purpose.
|
|
"""
|
|
return f"{url}{'&' if '?' in url else '?'}{ONE_TAP_PARAM}=1"
|
|
|
|
|
|
def is_one_tap_request(query_params: Mapping[str, str]) -> bool:
|
|
"""Whether this page load came from a button rather than from message text."""
|
|
return (query_params.get(ONE_TAP_PARAM) or "") == "1"
|
|
|
|
|
|
def stamp_verdict(archive: PrintArchive, source: str) -> None:
|
|
"""Record a verdict's provenance and the moment it landed (#1898).
|
|
|
|
Both fields move together on every verdict write, which is what keeps the
|
|
"already answered" page from pairing a new source with the timestamp of an
|
|
older decision. `retire_confirm_token` is separate on purpose: spending the
|
|
one-tap capability happens once, recording a verdict can happen again.
|
|
"""
|
|
archive.user_verdict_source = source
|
|
archive.user_verdict_at = datetime.now(timezone.utc)
|
|
|
|
|
|
def retire_confirm_token(archive: PrintArchive) -> None:
|
|
"""Spend the one-tap capability token without destroying it.
|
|
|
|
The token is still single-use: once ``confirm_token_used_at`` is stamped,
|
|
no verdict path accepts it again. Keeping the VALUE is what lets the
|
|
one-tap route recognise a link belonging to an already-answered print and
|
|
say so, instead of 404ing as if the link had never been real (the live-farm
|
|
case: the plate-clear default answered the prompt, then the user tapped the
|
|
Telegram button and got "invalid or already used").
|
|
"""
|
|
if archive.confirm_token and archive.confirm_token_used_at is None:
|
|
archive.confirm_token_used_at = datetime.now(timezone.utc)
|
|
|
|
|
|
VERDICTS = ("good", "reject")
|
|
|
|
|
|
async def apply_outcome_verdict(db: AsyncSession, archive: PrintArchive, verdict: str, *, source: str) -> bool:
|
|
"""Record a verdict on an archive that has not been answered yet.
|
|
|
|
For verdict paths that arrive with nobody looking at the archive, like the
|
|
Telegram reaction poller (#3046). It writes what every verdict write in
|
|
this module writes: the archive's user_verdict with its provenance stamp,
|
|
the same value mirrored onto the latest PrintLogEntry (verdict-aware
|
|
statistics read the log, the #1444 mirror), and the one-tap capability
|
|
token spent.
|
|
|
|
First verdict wins. An archive that already carries one is left exactly
|
|
as it is and False is returned, so a late reaction cannot flip a decision
|
|
somebody made in the meantime. Deliberately does NOT commit.
|
|
"""
|
|
if verdict not in VERDICTS:
|
|
raise ValueError(f"Verdict must be one of {VERDICTS}, got {verdict!r}")
|
|
if source not in VERDICT_SOURCES:
|
|
raise ValueError(f"Verdict source must be one of {VERDICT_SOURCES}, got {source!r}")
|
|
if archive.user_verdict is not None:
|
|
return False
|
|
|
|
archive.user_verdict = verdict
|
|
stamp_verdict(archive, source)
|
|
retire_confirm_token(archive)
|
|
|
|
latest_entry = await db.scalar(
|
|
select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
|
|
)
|
|
if latest_entry is not None:
|
|
latest_entry.user_verdict = verdict
|
|
return True
|
|
|
|
|
|
async def resolve_pending_confirmation_as_good(db: AsyncSession, printer_id: int) -> int | None:
|
|
"""Mark the printer's latest pending-confirmation archive as good.
|
|
|
|
Backs the opt-in ``confirm_default_good_on_plate_clear`` setting: releasing
|
|
the build plate is the moment the operator moves on to the next job, so an
|
|
unanswered outcome prompt can default to "good part" right there instead of
|
|
lingering as unconfirmed. Only the LATEST pending archive is resolved — the
|
|
plate release refers to the print that just came off the plate, not to
|
|
older unanswered prompts.
|
|
|
|
Mirrors the verdict onto the latest PrintLogEntry (the #1444 mirror) and
|
|
retires the one-tap capability token. Deliberately does NOT commit — both
|
|
callers (the clear-plate route and the queue dispatcher) manage their own
|
|
transaction.
|
|
|
|
Returns the resolved archive id, or None when nothing was pending.
|
|
"""
|
|
archive = await db.scalar(
|
|
select(PrintArchive)
|
|
.where(
|
|
PrintArchive.printer_id == printer_id,
|
|
PrintArchive.status == "completed",
|
|
PrintArchive.confirm_requested.is_(True),
|
|
PrintArchive.user_verdict.is_(None),
|
|
)
|
|
.order_by(PrintArchive.id.desc())
|
|
.limit(1)
|
|
)
|
|
if archive is None:
|
|
return None
|
|
|
|
archive.user_verdict = "good"
|
|
stamp_verdict(archive, "plate_clear")
|
|
retire_confirm_token(archive)
|
|
|
|
latest_entry = await db.scalar(
|
|
select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
|
|
)
|
|
if latest_entry is not None:
|
|
latest_entry.user_verdict = "good"
|
|
|
|
logger.info("[#1898] Plate clear defaulted archive %s to 'good' (printer %s)", archive.id, printer_id)
|
|
return archive.id
|
|
|
|
|
|
async def confirm_outcome_for_new_queue_item(db: AsyncSession, *, started_outside_bambuddy: bool = False) -> bool:
|
|
"""The ask-for-outcome flag for a queue item created without the print dialog.
|
|
|
|
The dialog seeds its own per-job toggle from ``default_confirm_outcome``.
|
|
Every other queue-creation path -- the virtual printer, the library bulk
|
|
add, the webhook, a pipeline run -- has no toggle to seed and used to leave
|
|
the column at its ``False`` default, so "Ask for Outcome" only ever reached
|
|
jobs queued by hand.
|
|
|
|
``started_outside_bambuddy`` additionally honours
|
|
``confirm_outcome_external_prints``: a plate sent from Bambu Studio to a
|
|
virtual printer is one of the prints that setting's description names, but
|
|
it arrives with a queue item, so ``on_print_start`` never sees it as
|
|
external and the setting could not otherwise reach it.
|
|
"""
|
|
from backend.app.api.routes.settings import get_setting, setting_is_true
|
|
|
|
if setting_is_true(await get_setting(db, "default_confirm_outcome")):
|
|
return True
|
|
return started_outside_bambuddy and setting_is_true(await get_setting(db, "confirm_outcome_external_prints"))
|