notification_service.py 131 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218
  1. """Notification service for sending push notifications via various providers."""
  2. import asyncio
  3. import html
  4. import json
  5. import logging
  6. import re
  7. import smtplib
  8. from collections.abc import Callable
  9. from datetime import datetime, timedelta, timezone
  10. from email.mime.image import MIMEImage
  11. from email.mime.multipart import MIMEMultipart
  12. from email.mime.text import MIMEText
  13. from typing import Any
  14. from urllib.parse import quote
  15. import httpx
  16. from sqlalchemy import select
  17. from sqlalchemy.ext.asyncio import AsyncSession
  18. from backend.app.core.logging_filters import redact_refused_headers
  19. from backend.app.models.archive import PrintArchive
  20. from backend.app.models.notification import (
  21. NotificationDigestQueue,
  22. NotificationLog,
  23. NotificationProvider,
  24. TelegramPendingVerdict,
  25. )
  26. from backend.app.models.notification_template import NotificationTemplate
  27. from backend.app.services.email_service import add_required_headers
  28. from backend.app.services.notify_client import NotifyClient, NotifyError, notify_credentials, notify_supports_photos
  29. from backend.app.services.print_confirmation import one_tap_url
  30. from backend.app.utils.notification_photos import save_notification_photo
  31. logger = logging.getLogger(__name__)
  32. # How long a digest event is kept for: through one failed daily digest, so it
  33. # goes out with the next one, then dropped (FO-C4).
  34. DIGEST_RETRY_HOURS = 48
  35. # Honest User-Agent — matches the convention used by every other outbound
  36. # httpx client in the codebase (bambu_cloud, makerworld, firmware_check,
  37. # inventory). Previously this client leaked python-httpx/<version>, which
  38. # was both inconsistent with the rest of the project and a more obvious
  39. # bot signature for upstream WAFs.
  40. _USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
  41. # Appended to a Telegram outcome prompt in reaction mode (#3046); the message
  42. # itself carries no buttons there, so the user needs telling how to answer.
  43. TELEGRAM_REACTION_HINT = "React with \U0001f44d or \U0001f44e to record the outcome."
  44. def telegram_markdown_escape(message: str) -> str:
  45. """Escape underscores in the message body so Telegram Markdown parsing
  46. doesn't break on job names like "A1_plate_8" or error codes like
  47. "0300_0001". The title is already wrapped in *bold* markers, so only
  48. escape after the first newline. Shared with the reaction poller, which
  49. re-sends the stored body when it edits the prompt (#3046)."""
  50. if "\n" in message:
  51. title_part, body_part = message.split("\n", 1)
  52. body_part = body_part.replace("_", "\\_")
  53. return f"{title_part}\n{body_part}"
  54. return message
  55. def _looks_like_cloudflare_challenge(response: httpx.Response) -> bool:
  56. """Return True if ``response`` looks like a Cloudflare mitigation
  57. interstitial (JS challenge / managed challenge / block page) rather
  58. than a legitimate response passed through Cloudflare.
  59. Self-hosted servers behind Cloudflare (Tunnel, "Bot Fight Mode", or
  60. "Under Attack" mode) intercept non-browser clients at the edge and
  61. return a challenge HTML page instead of forwarding to the origin —
  62. so we never reach the user's actual ntfy / webhook backend.
  63. Cloudflare cannot be defeated from a Python client; the user has to
  64. add a security-skip rule on their side. We detect the shape so the
  65. UI can tell them that, instead of dumping the raw HTML.
  66. Detection deliberately does NOT rely on ``Server: cloudflare`` alone
  67. — Cloudflare adds that header to every response it proxies (success
  68. AND legitimate origin errors), so a real 401 "wrong token" from a
  69. CF-fronted ntfy would false-positive into a misleading "your CF is
  70. blocking" message. Reliable signals: the ``cf-mitigated`` header
  71. (set only when CF actively mitigates) and the challenge body shape.
  72. """
  73. if response.headers.get("cf-mitigated"):
  74. return True
  75. content_type = (response.headers.get("content-type") or "").lower()
  76. if "html" not in content_type:
  77. return False
  78. body = (response.text or "")[:1024].lower()
  79. # "Just a moment..." is Cloudflare's universal challenge-page title
  80. # (managed challenge, JS challenge, Under Attack mode). Combined with
  81. # an HTML content-type this is unambiguous — no legitimate ntfy or
  82. # webhook backend returns HTML with that title. ``cf-chl-*`` and
  83. # ``challenge-platform`` cover newer / non-default CF templates.
  84. return "just a moment" in body or "cf-chl-bypass" in body or "cf-chl-opt" in body or "challenge-platform" in body
  85. def _assert_safe_provider_url(url: str, *, label: str) -> str | None:
  86. """Validate a provider URL taken from user-supplied config.
  87. Returns an error message on rejection, or None when the URL is
  88. acceptable — the ``_send_*`` methods return ``tuple[bool, str]`` rather
  89. than raising, so a message is more useful here than an exception.
  90. Uses the LAN-service policy: self-hosting ntfy, Bark, Gotify or a webhook
  91. receiver on the home LAN is normal and must keep working, so loopback and
  92. RFC-1918 stay permitted. Cloud-metadata endpoints, numeric-encoded IPs and
  93. non-HTTP schemes are rejected.
  94. """
  95. from backend.app.api.routes._url_safety import assert_safe_lan_service_url
  96. try:
  97. assert_safe_lan_service_url(url, label=label)
  98. except ValueError as exc:
  99. return str(exc)
  100. return None
  101. def _event_priority_level(config: dict, event_type: str | None) -> int | None:
  102. """The 1-5 priority level mapped to this event in config.event_priorities.
  103. 1=min, 2=low, 3=default, 4=high, 5=urgent, as the dialog writes them for
  104. ntfy (#990) and Gotify (#2743). None when the event has no valid entry.
  105. """
  106. event_priorities = config.get("event_priorities") or {}
  107. if not event_type or not isinstance(event_priorities, dict):
  108. return None
  109. raw = event_priorities.get(event_type)
  110. if raw is None and not event_type.startswith("on_"):
  111. raw = event_priorities.get(f"on_{event_type}")
  112. try:
  113. level = int(raw) if raw is not None else None
  114. except (TypeError, ValueError):
  115. return None
  116. if level is not None and 1 <= level <= 5:
  117. return level
  118. return None
  119. # Gotify priorities run 0-10; its Android app stays silent below 4 and pops
  120. # the notification up from 8. The dialog's five levels map onto those bands.
  121. _GOTIFY_PRIORITY_BY_LEVEL = {1: 0, 2: 2, 3: 5, 4: 8, 5: 10}
  122. def _opaque_http_failure(response: httpx.Response, *, label: str) -> str:
  123. """Failure message for a provider whose destination host the user supplies.
  124. The response body is deliberately **not** returned to the caller. Provider
  125. URLs are configurable by anyone holding ``NOTIFICATIONS_CREATE`` — which
  126. the default Operators group carries and which does not imply
  127. ``SETTINGS_UPDATE`` — and ``POST /notifications/test-config`` accepts a URL
  128. straight from the request body without persisting anything. Echoing the
  129. response body there turned an intended "does my webhook work?" check into
  130. an authenticated read primitive against any host the Bambuddy process can
  131. reach, including services that are not exposed to the network at all.
  132. Providers whose host Bambuddy hardcodes (Pushover, Telegram, CallMeBot)
  133. keep returning the upstream body — there is no trust boundary to cross
  134. when the destination cannot be influenced.
  135. The body is logged at debug level, where it stays available to whoever
  136. already administers the host without being handed back over the API.
  137. """
  138. logger.debug(
  139. "%s delivery failed with HTTP %s; body: %s",
  140. label,
  141. response.status_code,
  142. (response.text or "")[:200],
  143. )
  144. return f"HTTP {response.status_code} from the configured {label} (see server logs at debug level for details)"
  145. _sample_notification_image: bytes | None = None
  146. _sample_notification_image_loaded = False
  147. def _load_sample_notification_image() -> bytes | None:
  148. """Render the bundled app screenshot as a small JPEG.
  149. Stands in for a real camera snapshot on Test Configuration / test-all,
  150. since there's no live print to capture one from. Cached after the first
  151. call so we're not re-reading and resizing the file on every click.
  152. """
  153. global _sample_notification_image, _sample_notification_image_loaded
  154. if _sample_notification_image_loaded:
  155. return _sample_notification_image
  156. _sample_notification_image_loaded = True
  157. try:
  158. from io import BytesIO
  159. from PIL import Image
  160. from backend.app.core.config import settings
  161. source_path = settings.static_dir / "img" / "screenshot-desktop.png"
  162. with Image.open(source_path) as img:
  163. resized = img.convert("RGB")
  164. resized.thumbnail((640, 640))
  165. buffer = BytesIO()
  166. resized.save(buffer, format="JPEG", quality=85)
  167. _sample_notification_image = buffer.getvalue()
  168. except Exception as e:
  169. logger.warning("Failed to build sample notification image: %s", e)
  170. _sample_notification_image = None
  171. return _sample_notification_image
  172. class NotificationService:
  173. """Service for sending notifications through various providers."""
  174. def __init__(self):
  175. self._http_client: httpx.AsyncClient | None = None
  176. self._template_cache: dict[str, NotificationTemplate] = {}
  177. self._digest_scheduler_task: asyncio.Task | None = None
  178. self._last_digest_check: str = "" # "HH:MM" to avoid duplicate checks
  179. async def _get_client(self) -> httpx.AsyncClient:
  180. """Get or create HTTP client.
  181. The connect timeout is deliberately far shorter than the rest. A flat
  182. 30 s meant that when a site's internet went down, every alarm spent a
  183. full 30 s inside ``connect`` — longer than SQLite's 15 s
  184. ``busy_timeout`` — and any other task that wanted to write during that
  185. window failed with "database is locked" (#2770). Reaching a host either
  186. works in a couple of seconds or is not going to; sending the body is the
  187. part that legitimately takes time, so read/write keep the old 30 s and
  188. an image upload on a slow uplink is unaffected.
  189. """
  190. if self._http_client is None or self._http_client.is_closed:
  191. self._http_client = httpx.AsyncClient(
  192. timeout=httpx.Timeout(30.0, connect=5.0),
  193. headers={"User-Agent": _USER_AGENT},
  194. )
  195. return self._http_client
  196. async def close(self):
  197. """Close HTTP client."""
  198. if self._http_client and not self._http_client.is_closed:
  199. await self._http_client.aclose()
  200. def _is_in_quiet_hours(self, provider: NotificationProvider) -> bool:
  201. """Check if current time is within provider's quiet hours."""
  202. if not provider.quiet_hours_enabled:
  203. return False
  204. if not provider.quiet_hours_start or not provider.quiet_hours_end:
  205. return False
  206. try:
  207. now = datetime.now()
  208. current_time = now.hour * 60 + now.minute
  209. start_parts = provider.quiet_hours_start.split(":")
  210. end_parts = provider.quiet_hours_end.split(":")
  211. start_minutes = int(start_parts[0]) * 60 + int(start_parts[1])
  212. end_minutes = int(end_parts[0]) * 60 + int(end_parts[1])
  213. # Handle overnight quiet hours (e.g., 22:00 to 07:00)
  214. if start_minutes > end_minutes:
  215. # Quiet hours span midnight
  216. return current_time >= start_minutes or current_time < end_minutes
  217. else:
  218. # Same day quiet hours
  219. return start_minutes <= current_time < end_minutes
  220. except (ValueError, TypeError, AttributeError):
  221. logger.warning("Invalid quiet hours format for provider %s", provider.name)
  222. return False
  223. async def _get_template(self, db: AsyncSession, event_type: str) -> NotificationTemplate | None:
  224. """Get a notification template by event type.
  225. ``no_autoflush`` for the same reason as ``_get_providers_for_event``:
  226. this read runs before the provider is contacted, and must not be the
  227. thing that opens a write transaction on the caller's session (#2770).
  228. """
  229. # Check cache first
  230. if event_type in self._template_cache:
  231. return self._template_cache[event_type]
  232. with db.no_autoflush:
  233. result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.event_type == event_type))
  234. template = result.scalar_one_or_none()
  235. if template:
  236. self._template_cache[event_type] = template
  237. return template
  238. def _render_template(self, template_str: str, variables: dict[str, Any]) -> str:
  239. """Render a template string with variables. Missing variables become empty."""
  240. result = template_str
  241. for key, value in variables.items():
  242. result = result.replace("{" + key + "}", str(value) if value is not None else "")
  243. # Remove any remaining unreplaced placeholders
  244. result = re.sub(r"\{[a-z_]+\}", "", result)
  245. return result
  246. async def _format_eta(self, seconds: int | None, db: AsyncSession) -> str:
  247. """Format ETA as wall-clock time, respecting user's time_format setting."""
  248. if not seconds or seconds <= 0:
  249. return "Unknown"
  250. from backend.app.api.routes.settings import get_setting
  251. eta_time = datetime.now() + timedelta(seconds=seconds)
  252. time_format = await get_setting(db, "time_format")
  253. if time_format == "12h":
  254. return eta_time.strftime("%I:%M %p").lstrip("0")
  255. # Default to 24h for "24h", "system", or unset
  256. return eta_time.strftime("%H:%M")
  257. def _format_duration(self, seconds: int | None) -> str:
  258. """Format duration in seconds to human-readable string."""
  259. if seconds is None:
  260. return "Unknown"
  261. hours = seconds // 3600
  262. minutes = (seconds % 3600) // 60
  263. if hours > 0:
  264. return f"{hours}h {minutes}m"
  265. return f"{minutes}m"
  266. def _clean_filename(self, filename: str) -> str:
  267. """Extract filename and remove file extensions."""
  268. import os
  269. # Strip path prefix (e.g., /data/Metadata/plate_5.gcode -> plate_5.gcode)
  270. filename = os.path.basename(filename)
  271. # Remove common extensions
  272. if filename.endswith(".gcode.3mf"):
  273. return filename[:-10]
  274. elif filename.endswith(".gcode"):
  275. return filename[:-6]
  276. elif filename.endswith(".3mf"):
  277. return filename[:-4]
  278. return filename
  279. async def _build_message_from_template(
  280. self, db: AsyncSession, event_type: str, variables: dict[str, Any]
  281. ) -> tuple[str, str]:
  282. """Build notification title and body from template."""
  283. # Add common variables
  284. variables["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M")
  285. variables["app_name"] = "Bambuddy"
  286. template = await self._get_template(db, event_type)
  287. if not template:
  288. # Fallback to simple message
  289. logger.warning("Template not found for event type: %s", event_type)
  290. return event_type.replace("_", " ").title(), str(variables)
  291. title = self._render_template(template.title_template, variables)
  292. body = self._render_template(template.body_template, variables)
  293. return title, body
  294. async def send_test_notification(
  295. self,
  296. provider_type: str,
  297. config: dict[str, Any],
  298. db: AsyncSession | None = None,
  299. attach_photo: bool = True,
  300. ) -> tuple[bool, str]:
  301. """Send a test notification to verify configuration.
  302. attach_photo mirrors the provider's own toggle (see _send_to_provider)
  303. — when set, a bundled sample image stands in for a real snapshot.
  304. """
  305. success, message = await self._send_test_notification(provider_type, config, db, attach_photo)
  306. # The text goes back to the caller: an HTTP client refusing a header
  307. # quotes its value, which may be a credential the caller never sees.
  308. return success, redact_refused_headers(message) or message
  309. async def _send_test_notification(
  310. self,
  311. provider_type: str,
  312. config: dict[str, Any],
  313. db: AsyncSession | None,
  314. attach_photo: bool,
  315. ) -> tuple[bool, str]:
  316. if db:
  317. title, message = await self._build_message_from_template(db, "test", {})
  318. else:
  319. title = "Bambuddy Test"
  320. message = "This is a test notification. If you see this, notifications are working!"
  321. if provider_type == "notify" and not notify_supports_photos(config.get("device_id")):
  322. attach_photo = False
  323. image_data = await asyncio.to_thread(_load_sample_notification_image) if attach_photo else None
  324. try:
  325. if provider_type == "callmebot":
  326. return await self._send_callmebot(config, f"{title}\n{message}")
  327. elif provider_type == "ntfy":
  328. return await self._send_ntfy(config, title, message, image_data=image_data)
  329. elif provider_type == "pushover":
  330. return await self._send_pushover(config, title, message, image_data=image_data)
  331. elif provider_type == "telegram":
  332. return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
  333. elif provider_type == "email":
  334. return await self._send_email(config, title, message, image_data=image_data)
  335. elif provider_type == "discord":
  336. return await self._send_discord(config, title, message, image_data=image_data)
  337. elif provider_type == "webhook":
  338. photo_url = None
  339. if attach_photo and (config.get("payload_format") or "generic").strip() == "slack":
  340. photo_url = await self._get_or_build_photo_url(db, image_data, "test")
  341. return await self._send_webhook(
  342. config, title, message, image_data=image_data, event_type="test", image_url=photo_url
  343. )
  344. elif provider_type == "homeassistant":
  345. photo_url = await self._get_or_build_photo_url(db, image_data, "test") if attach_photo else None
  346. return await self._send_homeassistant(config, title, message, db=db, image_url=photo_url)
  347. elif provider_type == "bark":
  348. photo_url = await self._get_or_build_photo_url(db, image_data, "test") if attach_photo else None
  349. return await self._send_bark(config, title, message, image_url=photo_url)
  350. elif provider_type == "gotify":
  351. photo_url = await self._get_or_build_photo_url(db, image_data, "test") if attach_photo else None
  352. return await self._send_gotify(config, title, message, event_type="test", image_url=photo_url)
  353. elif provider_type == "notify":
  354. photo_url = await self._get_or_build_photo_url(db, image_data, "test") if attach_photo else None
  355. return await self._send_notify(config, title, message, image_url=photo_url)
  356. else:
  357. return False, f"Unknown provider type: {provider_type}"
  358. except Exception as e:
  359. logger.exception("Error sending test notification via %s", provider_type)
  360. return False, str(e)
  361. async def _send_notify(
  362. self,
  363. config: dict,
  364. title: str,
  365. message: str,
  366. image_url: str | None = None,
  367. event_type: str | None = None,
  368. printer_id: int | None = None,
  369. ) -> tuple[bool, str]:
  370. """Ordinary Notify! alerts use the existing event, digest and quiet-hours rules."""
  371. try:
  372. device_id, token = notify_credentials(config)
  373. client = NotifyClient(await self._get_client())
  374. group_type = str(config.get("group_type") or "").strip()
  375. if not group_type:
  376. group_type = f"bambuddy-printer-{printer_id}" if printer_id is not None else "bambuddy"
  377. await client.send_notification(
  378. device_id,
  379. token,
  380. title=title,
  381. text=message,
  382. group_type=group_type,
  383. icon_url=config.get("icon_url"),
  384. image_url=image_url,
  385. time_sensitive=config.get("time_sensitive") is True
  386. and event_type in {"print_failed", "print_stopped", "printer_error", "ai_failure_detection"},
  387. )
  388. return True, "Message sent successfully"
  389. except NotifyError as exc:
  390. return False, str(exc)
  391. async def _send_callmebot(self, config: dict, message: str) -> tuple[bool, str]:
  392. """Send notification via CallMeBot (WhatsApp)."""
  393. phone = config.get("phone", "").strip()
  394. apikey = config.get("apikey", "").strip()
  395. if not phone or not apikey:
  396. return False, "Phone number and API key are required"
  397. # Every value URL-encoded: a phone number written "+49 151 ..." would
  398. # otherwise lose its "+", which a query string reads as a space.
  399. url = (
  400. "https://api.callmebot.com/whatsapp.php"
  401. f"?phone={quote(phone, safe='')}&text={quote(message, safe='')}&apikey={quote(apikey, safe='')}"
  402. )
  403. client = await self._get_client()
  404. response = await client.get(url)
  405. if response.status_code == 200:
  406. return True, "Message sent successfully"
  407. else:
  408. return False, f"HTTP {response.status_code}: {response.text[:200]}"
  409. async def _send_bark(
  410. self, config: dict, title: str, message: str, url: str | None = None, image_url: str | None = None
  411. ) -> tuple[bool, str]:
  412. """Send notification via Bark, the self-hostable iOS push service (#1495).
  413. POSTs JSON to {server}/push. Defaults to the official api.day.app
  414. relay; a self-hosted bark-server works by overriding the server URL.
  415. ``url`` opens on tap — the outcome confirmation (#1898) deep-links
  416. into the archive's confirmation dialog with it.
  417. """
  418. server = (config.get("server") or "https://api.day.app").strip().rstrip("/")
  419. device_key = (config.get("device_key") or "").strip()
  420. if not device_key:
  421. return False, "Device key is required"
  422. url_error = _assert_safe_provider_url(server, label="Bark server URL")
  423. if url_error:
  424. return False, url_error
  425. payload: dict[str, Any] = {
  426. "device_key": device_key,
  427. "title": title,
  428. "body": message,
  429. }
  430. group = (config.get("group") or "").strip()
  431. if group:
  432. payload["group"] = group
  433. sound = (config.get("sound") or "").strip()
  434. if sound:
  435. payload["sound"] = sound
  436. level = (config.get("level") or "").strip()
  437. if level in ("active", "timeSensitive", "critical", "passive"):
  438. payload["level"] = level
  439. if url:
  440. payload["url"] = url
  441. if image_url:
  442. # Closest thing Bark has to a photo attachment — shows as a
  443. # round icon on iOS.
  444. payload["icon"] = image_url
  445. client = await self._get_client()
  446. response = await client.post(f"{server}/push", json=payload)
  447. if response.status_code == 200:
  448. # bark-server can report failures inside an HTTP 200 body
  449. # ({"code": 400, "message": ...}), so the status alone isn't proof.
  450. try:
  451. body = response.json()
  452. except ValueError:
  453. body = None
  454. if isinstance(body, dict) and body.get("code") not in (200, None):
  455. # Only the numeric code is echoed. A server chosen by the caller
  456. # controls this body too, so the free-text message is a (narrow)
  457. # read channel of the same kind _opaque_http_failure closes.
  458. logger.debug("Bark reported error %s: %s", body.get("code"), str(body.get("message"))[:200])
  459. return False, f"Bark error {body.get('code')} (see server logs at debug level for details)"
  460. return True, "Message sent successfully"
  461. return False, _opaque_http_failure(response, label="Bark server")
  462. async def _send_gotify(
  463. self,
  464. config: dict,
  465. title: str,
  466. message: str,
  467. event_type: str | None = None,
  468. url: str | None = None,
  469. image_url: str | None = None,
  470. ) -> tuple[bool, str]:
  471. """Send notification via a self-hosted Gotify server (#2743).
  472. POSTs JSON to {server}/message with the application token in the
  473. X-Gotify-Key header, so the token never ends up in a URL or a log line.
  474. Gotify takes no uploads: a photo goes in as a URL its Android app
  475. fetches itself, and ``url`` opens on tap.
  476. """
  477. server = (config.get("server") or "").strip().rstrip("/")
  478. # Gotify's own docs show the full endpoint, so a pasted ".../message"
  479. # must not turn into ".../message/message".
  480. server = server.removesuffix("/message")
  481. app_token = (config.get("app_token") or "").strip()
  482. if not server or not app_token:
  483. return False, "Server URL and app token are required"
  484. url_error = _assert_safe_provider_url(server, label="Gotify server URL")
  485. if url_error:
  486. return False, url_error
  487. # Events without a mapped level go out at Gotify's middle band (5), the
  488. # "Default" the dialog shows for them -- sound on, no pop-up.
  489. level = _event_priority_level(config, event_type) or 3
  490. payload: dict[str, Any] = {
  491. "title": title,
  492. "message": message,
  493. "priority": _GOTIFY_PRIORITY_BY_LEVEL[level],
  494. }
  495. notification_extras: dict[str, Any] = {}
  496. if url:
  497. notification_extras["click"] = {"url": url}
  498. if image_url:
  499. notification_extras["bigImageUrl"] = image_url
  500. if notification_extras:
  501. payload["extras"] = {"client::notification": notification_extras}
  502. client = await self._get_client()
  503. response = await client.post(f"{server}/message", json=payload, headers={"X-Gotify-Key": app_token})
  504. if response.status_code == 200:
  505. return True, "Message sent successfully"
  506. return False, _opaque_http_failure(response, label="Gotify server")
  507. async def _send_ntfy(
  508. self,
  509. config: dict,
  510. title: str,
  511. message: str,
  512. image_data: bytes | None = None,
  513. event_type: str | None = None,
  514. actions: str | None = None,
  515. ) -> tuple[bool, str]:
  516. """Send notification via ntfy.
  517. ``actions`` is a pre-built value for ntfy's Actions header (simple
  518. format), used by the outcome-confirmation event (#1898) to put
  519. one-tap Good/Reject buttons directly into the push notification.
  520. """
  521. server = config.get("server", "https://ntfy.sh").rstrip("/")
  522. topic = config.get("topic", "").strip()
  523. auth_token = config.get("auth_token", "").strip()
  524. if not topic:
  525. return False, "Topic is required"
  526. url_error = _assert_safe_provider_url(server, label="ntfy server URL")
  527. if url_error:
  528. return False, url_error
  529. url = f"{server}/{topic}"
  530. # ntfy reads Title/Message from HTTP headers. httpx enforces ASCII
  531. # for str header values, but printer names and filenames can contain
  532. # non-ASCII characters (e.g. accented letters, CJK). Passing bytes
  533. # bypasses the ASCII check — ntfy handles UTF-8 headers correctly.
  534. headers: dict[str, str | bytes] = {"Title": title.encode("utf-8")}
  535. # Per-event Priority header (#990). Only set when the user has
  536. # explicitly mapped this event to a 1-5 value; otherwise fall through
  537. # to the ntfy server's default so existing setups stay unchanged.
  538. #
  539. # The map is keyed by the provider's toggle column ("on_print_failed"),
  540. # because that is what the dialog builds its rows from -- but every
  541. # sender is called with the bare event name ("print_failed"), so the
  542. # lookup used to miss for every real notification and hit only in tests
  543. # that called this method with the prefixed name (issue #3139). Both
  544. # spellings are accepted, which also leaves stored configs untouched.
  545. priority = _event_priority_level(config, event_type)
  546. if priority is not None:
  547. headers["Priority"] = str(priority)
  548. if auth_token:
  549. headers["Authorization"] = f"Bearer {auth_token}"
  550. if actions:
  551. headers["Actions"] = actions
  552. client = await self._get_client()
  553. if image_data:
  554. # ntfy supports image attachments via multipart form-data.
  555. # HTTP headers cannot contain newlines, but ntfy interprets
  556. # literal \n (backslash-n) as newlines in the Message header.
  557. headers["Filename"] = "photo.jpg"
  558. headers["Message"] = message.replace("\n", "\\n").encode("utf-8")
  559. response = await client.put(url, content=image_data, headers=headers)
  560. if response.status_code == 400 and "attachments not allowed" in response.text:
  561. # Server has attachments disabled — retry without the image
  562. headers.pop("Filename", None)
  563. headers.pop("Message", None)
  564. response = await client.post(url, content=message.encode("utf-8"), headers=headers)
  565. else:
  566. response = await client.post(url, content=message.encode("utf-8"), headers=headers)
  567. if response.status_code in (200, 204):
  568. return True, "Message sent successfully"
  569. if _looks_like_cloudflare_challenge(response):
  570. return False, (
  571. f"HTTP {response.status_code} — ntfy server is behind a Cloudflare "
  572. "challenge. Bambuddy was served the JS challenge page instead of "
  573. "reaching ntfy. Cloudflare cannot be solved from a backend; add a "
  574. "Cloudflare security-skip rule for this hostname, disable Bot "
  575. "Fight Mode, or front the server with Cloudflare Access using a "
  576. "service token. (#1534)"
  577. )
  578. return False, _opaque_http_failure(response, label="ntfy server")
  579. async def _send_pushover(
  580. self,
  581. config: dict,
  582. title: str,
  583. message: str,
  584. image_data: bytes | None = None,
  585. url: str | None = None,
  586. url_title: str | None = None,
  587. ) -> tuple[bool, str]:
  588. """Send notification via Pushover.
  589. Args:
  590. config: Provider configuration with user_key, app_token, priority
  591. title: Notification title
  592. message: Notification body
  593. image_data: Optional JPEG image bytes to attach (max 2.5MB)
  594. url: Optional supplementary URL shown under the message
  595. url_title: Optional label for that URL
  596. """
  597. user_key = config.get("user_key", "").strip()
  598. app_token = config.get("app_token", "").strip()
  599. try:
  600. priority = int(config.get("priority", 0))
  601. except (TypeError, ValueError):
  602. priority = 0
  603. if not user_key or not app_token:
  604. return False, "User key and app token are required"
  605. api_url = "https://api.pushover.net/1/messages.json"
  606. data = {
  607. "token": app_token,
  608. "user": user_key,
  609. "title": title,
  610. "message": message,
  611. "priority": priority,
  612. }
  613. if url:
  614. data["url"] = url
  615. if url_title:
  616. data["url_title"] = url_title
  617. # Emergency priority (2) keeps re-alerting until acknowledged, so
  618. # Pushover *requires* retry (how often, >= 30s) and expire (when to
  619. # give up, <= 10800s). Without them the API rejects the message. Only
  620. # send them at priority 2 — Pushover ignores them at other priorities.
  621. if priority == 2:
  622. try:
  623. retry = int(config.get("retry", 60))
  624. except (TypeError, ValueError):
  625. retry = 60
  626. try:
  627. expire = int(config.get("expire", 3600))
  628. except (TypeError, ValueError):
  629. expire = 3600
  630. data["retry"] = max(30, min(retry, 10800))
  631. data["expire"] = max(30, min(expire, 10800))
  632. client = await self._get_client()
  633. if image_data:
  634. # Pushover supports image attachments via multipart form-data
  635. files = {"attachment": ("photo.jpg", image_data, "image/jpeg")}
  636. response = await client.post(api_url, data=data, files=files)
  637. else:
  638. response = await client.post(api_url, data=data)
  639. if response.status_code == 200:
  640. return True, "Message sent successfully"
  641. else:
  642. try:
  643. error_data = response.json()
  644. errors = error_data.get("errors", [])
  645. return False, f"Pushover error: {', '.join(errors)}"
  646. except Exception:
  647. return False, f"HTTP {response.status_code}: {response.text[:200]}"
  648. async def _send_telegram(
  649. self,
  650. config: dict,
  651. message: str,
  652. image_data: bytes | None = None,
  653. buttons: list[dict] | None = None,
  654. link_preview: bool = True,
  655. ) -> tuple[bool, str]:
  656. """Send notification via Telegram bot.
  657. ``buttons`` is one row of inline URL buttons (``{"text", "url"}``
  658. entries), used by the outcome-confirmation event (#1898) to put
  659. one-tap Good/Reject under the message. ``link_preview=False`` asks
  660. Telegram not to fetch the first URL in the text for a preview card.
  661. """
  662. ok, status, _ = await self._send_telegram_message(
  663. config, message, image_data=image_data, buttons=buttons, link_preview=link_preview
  664. )
  665. return ok, status
  666. async def _send_telegram_message(
  667. self,
  668. config: dict,
  669. message: str,
  670. image_data: bytes | None = None,
  671. buttons: list[dict] | None = None,
  672. link_preview: bool = True,
  673. ) -> tuple[bool, str, dict | None]:
  674. """Send via Telegram and also hand back the Bot API ``result`` (the sent Message).
  675. The outcome confirmation in reaction mode (#3046) needs the
  676. ``message_id`` from it to recognise the reaction later; every other
  677. caller goes through ``_send_telegram`` and ignores it.
  678. """
  679. bot_token = config.get("bot_token", "").strip()
  680. chat_id = config.get("chat_id", "").strip()
  681. if not bot_token or not chat_id:
  682. return False, "Bot token and chat ID are required", None
  683. # Optional forum topic (#1518). Telegram expects message_thread_id as an
  684. # integer in the JSON sendMessage body — a string 400s there even though
  685. # the multipart sendPhoto call below would happily accept one. Coerce it
  686. # once, up front, so both call sites agree and a bad value fails loudly
  687. # instead of silently breaking only the text notifications.
  688. thread_id_raw = str(config.get("message_thread_id") or "").strip()
  689. message_thread_id: int | None = None
  690. if thread_id_raw:
  691. try:
  692. message_thread_id = int(thread_id_raw)
  693. except ValueError:
  694. return False, f"Invalid message thread ID: {thread_id_raw!r} is not a number", None
  695. message = telegram_markdown_escape(message)
  696. client = await self._get_client()
  697. async def _post(with_buttons: bool):
  698. if image_data:
  699. # Use sendPhoto to attach the thumbnail with the caption
  700. url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
  701. form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"}
  702. if message_thread_id is not None:
  703. form["message_thread_id"] = message_thread_id
  704. if with_buttons:
  705. # Multipart form fields are strings — reply_markup goes JSON-encoded.
  706. form["reply_markup"] = json.dumps({"inline_keyboard": [buttons]})
  707. return await client.post(
  708. url,
  709. data=form,
  710. files={"photo": ("photo.jpg", image_data, "image/jpeg")},
  711. )
  712. url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
  713. payload: dict[str, Any] = {
  714. "chat_id": chat_id,
  715. "text": message,
  716. "parse_mode": "Markdown",
  717. }
  718. if not link_preview:
  719. payload["disable_web_page_preview"] = True
  720. if message_thread_id is not None:
  721. payload["message_thread_id"] = message_thread_id
  722. if with_buttons:
  723. payload["reply_markup"] = {"inline_keyboard": [buttons]}
  724. return await client.post(url, json=payload)
  725. def _failure(resp) -> str | None:
  726. """What Telegram objected to, or None when the send went through."""
  727. if resp.status_code != 200:
  728. return f"HTTP {resp.status_code}: {resp.text[:200]}"
  729. result = resp.json()
  730. if result.get("ok"):
  731. return None
  732. return f"Telegram error: {result.get('description', 'Unknown error')}"
  733. response = await _post(bool(buttons))
  734. failure = _failure(response)
  735. if failure and buttons:
  736. # Telegram validates every inline-keyboard URL and refuses the whole
  737. # send when one of them is not a URL it accepts — which is what an
  738. # install without a public external_url produces for the #1898
  739. # verdict links. Dropping the buttons is survivable; dropping the
  740. # message the user is waiting for is not.
  741. logger.warning("Telegram refused the message with inline buttons (%s); retrying without them", failure)
  742. response = await _post(False)
  743. failure = _failure(response)
  744. if failure:
  745. return False, failure, None
  746. sent = response.json().get("result")
  747. return True, "Message sent successfully", sent if isinstance(sent, dict) else None
  748. async def _send_telegram_confirm_request(
  749. self,
  750. provider: NotificationProvider,
  751. config: dict,
  752. message: str,
  753. db: AsyncSession | None,
  754. image_data: bytes | None,
  755. buttons: list[dict] | None,
  756. archive_id: int | None,
  757. ) -> tuple[bool, str]:
  758. """Deliver the outcome prompt the way the provider's verdict mode asks (#3046).
  759. "buttons" is the plain #1898 delivery. In "reactions" the inline
  760. keyboard is dropped and the user answers with a thumbs-up/down on the
  761. message itself; "both" keeps the keyboard as well. In either of those
  762. the sent message is remembered in telegram_pending_verdicts, together
  763. with the confirm token its links carry, so the reaction poller can map
  764. the reaction back to the archive for as long as that token is live.
  765. """
  766. mode = provider.telegram_verdict_mode or "buttons"
  767. # Telegram's servers GET the first URL in the text to build a preview
  768. # card. An outcome prompt whose edited body still carries {good_url}
  769. # would have that fetch answer the question before the operator saw
  770. # it, so the preview is off for the prompt in every mode.
  771. if mode == "buttons":
  772. return await self._send_telegram(
  773. config, message, image_data=image_data, buttons=buttons, link_preview=False
  774. )
  775. if mode == "reactions":
  776. buttons = None
  777. message = f"{message}\n\n{TELEGRAM_REACTION_HINT}"
  778. ok, status, sent = await self._send_telegram_message(
  779. config, message, image_data=image_data, buttons=buttons, link_preview=False
  780. )
  781. if not ok:
  782. return ok, status
  783. message_id = (sent or {}).get("message_id")
  784. if not isinstance(message_id, int) or message_id <= 0:
  785. logger.warning("Telegram did not return a message_id for the outcome prompt; reactions cannot be matched")
  786. return ok, status
  787. if db is None or archive_id is None:
  788. return ok, status
  789. try:
  790. # dispatch_outcome_confirmation mints a live token right before
  791. # sending. Without one the prompt's links are dead too, and a
  792. # reaction must not outlive them, so there is nothing to remember.
  793. archive = await db.get(PrintArchive, archive_id)
  794. if archive is None or not archive.confirm_token or archive.confirm_token_used_at is not None:
  795. logger.warning("Outcome prompt for archive %s went out without a live confirm token", archive_id)
  796. return ok, status
  797. db.add(
  798. TelegramPendingVerdict(
  799. provider_id=provider.id,
  800. chat_id=str(((sent or {}).get("chat") or {}).get("id") or config.get("chat_id", "")).strip(),
  801. message_id=message_id,
  802. archive_id=archive_id,
  803. confirm_token=archive.confirm_token,
  804. has_caption=image_data is not None,
  805. message_text=message,
  806. )
  807. )
  808. await db.commit()
  809. except Exception as e:
  810. # The prompt went out; losing the reaction mapping is a degraded
  811. # outcome, not a failed notification.
  812. logger.warning("Failed to record the Telegram outcome prompt for reactions: %s", e)
  813. await db.rollback()
  814. return ok, status
  815. async def _send_email(
  816. self,
  817. config: dict,
  818. subject: str,
  819. body: str,
  820. image_data: bytes | None = None,
  821. finish_photo_url: str | None = None,
  822. ) -> tuple[bool, str]:
  823. """Send notification via email (SMTP).
  824. Inline finish-photo embed is opt-in via the template: when the rendered
  825. ``body`` contains the substituted ``{finish_photo_url}`` value AND the
  826. finish-photo bytes are present, the message is built as
  827. ``multipart/related`` wrapping a ``multipart/alternative`` (plain + HTML)
  828. plus an inline ``MIMEImage`` with ``Content-ID: <bambuddy-finish-photo>``.
  829. The HTML part replaces the URL with ``<img src="cid:...">``; the plain-
  830. text part keeps the URL as a clickable link. When the template doesn't
  831. reference ``{finish_photo_url}`` (or image bytes aren't available), the
  832. original single-part text shape is used — no attachment, no surprise
  833. inline image (#1792).
  834. """
  835. smtp_server = config.get("smtp_server", "").strip()
  836. smtp_port = int(config.get("smtp_port", 587))
  837. username = config.get("username", "").strip()
  838. password = config.get("password", "").strip()
  839. from_email = config.get("from_email", "").strip()
  840. to_email = config.get("to_email", "").strip()
  841. # Security: "starttls" (port 587), "ssl" (port 465), "none" (port 25)
  842. security = config.get("security", "starttls")
  843. # Authentication: "true" or "false"
  844. auth_enabled = config.get("auth_enabled", "true").lower() == "true"
  845. if not all([smtp_server, from_email, to_email]):
  846. return False, "SMTP server, from email, and to email are required"
  847. if auth_enabled and not all([username, password]):
  848. return False, "Username and password are required when authentication is enabled"
  849. # Template-driven: only inline-embed when the user's template explicitly
  850. # referenced {finish_photo_url} (so the URL appears in the rendered body)
  851. # AND the photo bytes are available. Falls back to text-only otherwise.
  852. inline_photo = bool(image_data and finish_photo_url and finish_photo_url in body)
  853. try:
  854. if inline_photo:
  855. # multipart/related → (multipart/alternative → text, html) + inline image
  856. msg = MIMEMultipart("related")
  857. msg["From"] = from_email
  858. msg["To"] = to_email
  859. msg["Subject"] = f"[Bambuddy] {subject}"
  860. alt = MIMEMultipart("alternative")
  861. alt.attach(MIMEText(body, "plain"))
  862. # Build HTML body: escape the rendered body, then swap the
  863. # escaped URL substring for an inline <img> referencing the
  864. # MIMEImage we attach below. Done AFTER escape so the cid: URL
  865. # we inject isn't re-escaped.
  866. escaped_body = html.escape(body).replace("\n", "<br>\n")
  867. escaped_url = html.escape(finish_photo_url)
  868. img_tag = (
  869. '<img src="cid:bambuddy-finish-photo" '
  870. 'alt="Printer camera snapshot" '
  871. 'style="max-width:100%;height:auto;border:1px solid #ddd;border-radius:4px;">'
  872. )
  873. html_body = f"<html><body><p>{escaped_body.replace(escaped_url, img_tag)}</p></body></html>"
  874. alt.attach(MIMEText(html_body, "html"))
  875. msg.attach(alt)
  876. img = MIMEImage(image_data, _subtype="jpeg")
  877. # Angle-bracketed Content-ID per RFC 2392, referenced from HTML
  878. # without the brackets via ``cid:bambuddy-finish-photo``.
  879. img.add_header("Content-ID", "<bambuddy-finish-photo>")
  880. img.add_header("Content-Disposition", "inline", filename="finish-photo.jpg")
  881. msg.attach(img)
  882. else:
  883. msg = MIMEMultipart()
  884. msg["From"] = from_email
  885. msg["To"] = to_email
  886. msg["Subject"] = f"[Bambuddy] {subject}"
  887. msg.attach(MIMEText(body, "plain"))
  888. add_required_headers(msg, from_email)
  889. # smtplib is synchronous and blocking: a wedged / greylisting /
  890. # firewall-dropped relay leaves recv() stuck. Two problems, two
  891. # fixes (#2572):
  892. # 1. No timeout — smtplib defaults to the global socket timeout,
  893. # which this app never sets, so a stuck relay blocks forever.
  894. # Pass an explicit timeout to every connect.
  895. # 2. Run on the event loop — a stuck (or merely slow) send freezes
  896. # every other coroutine, including a DB session a caller is
  897. # holding open across this notification. Offload to a worker
  898. # thread so the loop stays live and the connection is released
  899. # on schedule.
  900. smtp_timeout = 30.0
  901. msg_str = msg.as_string()
  902. def _blocking_send() -> None:
  903. if security == "ssl":
  904. # Direct SSL connection (typically port 465)
  905. server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=smtp_timeout)
  906. elif security == "starttls":
  907. # STARTTLS upgrade (typically port 587)
  908. server = smtplib.SMTP(smtp_server, smtp_port, timeout=smtp_timeout)
  909. server.starttls()
  910. else:
  911. # No encryption (typically port 25) - use with caution
  912. server = smtplib.SMTP(smtp_server, smtp_port, timeout=smtp_timeout)
  913. try:
  914. if auth_enabled:
  915. server.login(username, password)
  916. server.sendmail(from_email, to_email, msg_str)
  917. finally:
  918. # quit() in finally so a send error doesn't leak the socket.
  919. try:
  920. server.quit()
  921. except Exception: # noqa: BLE001 — closing a broken connection is best-effort
  922. pass
  923. await asyncio.to_thread(_blocking_send)
  924. return True, "Email sent successfully"
  925. except smtplib.SMTPAuthenticationError:
  926. return False, "SMTP authentication failed - check username/password"
  927. except smtplib.SMTPException as e:
  928. return False, f"SMTP error: {str(e)}"
  929. except Exception as e:
  930. return False, f"Email error: {str(e)}"
  931. async def _send_discord(
  932. self, config: dict, title: str, message: str, image_data: bytes | None = None
  933. ) -> tuple[bool, str]:
  934. """Send notification via Discord webhook."""
  935. webhook_url = config.get("webhook_url", "").strip()
  936. if not webhook_url:
  937. return False, "Webhook URL is required"
  938. if not (
  939. webhook_url.startswith("https://discord.com/api/webhooks/")
  940. or webhook_url.startswith("https://discordapp.com/api/webhooks/")
  941. ):
  942. return False, "Invalid Discord webhook URL"
  943. # Discord embed format for nicer messages
  944. embed = {
  945. "title": title,
  946. "description": message,
  947. "color": 0x00AE42, # Bambu green
  948. }
  949. client = await self._get_client()
  950. if image_data:
  951. # Attach image via multipart form-data and reference in embed
  952. embed["image"] = {"url": "attachment://photo.jpg"}
  953. payload = {"embeds": [embed]}
  954. response = await client.post(
  955. webhook_url,
  956. data={"payload_json": json.dumps(payload)},
  957. files={"files[0]": ("photo.jpg", image_data, "image/jpeg")},
  958. )
  959. else:
  960. response = await client.post(webhook_url, json={"embeds": [embed]})
  961. if response.status_code in (200, 204):
  962. return True, "Message sent successfully"
  963. else:
  964. return False, f"HTTP {response.status_code}: {response.text[:200]}"
  965. async def _send_webhook(
  966. self,
  967. config: dict,
  968. title: str,
  969. message: str,
  970. image_data: bytes | None = None,
  971. event_type: str | None = None,
  972. variables: dict | None = None,
  973. image_url: str | None = None,
  974. ) -> tuple[bool, str]:
  975. """Send notification via generic webhook (POST JSON).
  976. Supports two payload formats:
  977. - generic: Custom field names with timestamp/source metadata + structured event data
  978. - slack: Slack/Mattermost compatible format (just {"text": "..."})
  979. """
  980. webhook_url = config.get("webhook_url", "").strip()
  981. auth_header = config.get("auth_header", "").strip()
  982. payload_format = config.get("payload_format", "generic").strip()
  983. if not webhook_url:
  984. return False, "Webhook URL is required"
  985. url_error = _assert_safe_provider_url(webhook_url, label="Webhook URL")
  986. if url_error:
  987. return False, url_error
  988. # Build payload based on format
  989. if payload_format == "slack":
  990. # Incoming webhooks can't take a byte upload, so a photo has to
  991. # be a URL Slack/Mattermost fetch themselves.
  992. data = {"text": f"*{title}*\n{message}"}
  993. if event_type == "print_confirm_request":
  994. # Slack and Mattermost fetch every URL in the text to build
  995. # preview cards, and the outcome prompt (#1898) is the one
  996. # message whose links are single-use capabilities — that fetch
  997. # would be a machine answering the operator's question. Off
  998. # here for the same reason Telegram's preview is.
  999. #
  1000. # Only here: the slack payload never attaches image bytes (the
  1001. # base64 attach below is generic-format only), so unfurling is
  1002. # the only way a {finish_photo_url} in a print_complete body
  1003. # can render as a photo in the channel. Switching it off for
  1004. # every event would quietly take that away with no setting to
  1005. # get it back.
  1006. data["unfurl_links"] = False
  1007. data["unfurl_media"] = False
  1008. if image_url:
  1009. data["attachments"] = [{"fallback": title, "image_url": image_url}]
  1010. else:
  1011. # Generic format with custom field names
  1012. custom_field_title = config.get("field_title", "title").strip() or "title"
  1013. custom_field_message = config.get("field_message", "message").strip() or "message"
  1014. data = {
  1015. custom_field_title: title,
  1016. custom_field_message: message,
  1017. "timestamp": datetime.now().isoformat(),
  1018. "source": "Bambuddy",
  1019. }
  1020. # For generic format, include structured event data for automation tools
  1021. if payload_format != "slack":
  1022. if event_type:
  1023. data["event"] = event_type
  1024. if variables:
  1025. for key, value in variables.items():
  1026. if key not in data: # Don't overwrite title/message/timestamp/source
  1027. data[key] = value
  1028. # Attach base64-encoded image when available (generic format only)
  1029. if image_data and payload_format != "slack":
  1030. import base64
  1031. data["image"] = base64.b64encode(image_data).decode("ascii")
  1032. headers = {"Content-Type": "application/json"}
  1033. if auth_header:
  1034. # Support "Bearer token" or just "token" format
  1035. if " " in auth_header:
  1036. headers["Authorization"] = auth_header
  1037. else:
  1038. headers["Authorization"] = f"Bearer {auth_header}"
  1039. client = await self._get_client()
  1040. try:
  1041. response = await client.post(webhook_url, json=data, headers=headers)
  1042. if response.status_code in (200, 201, 202, 204):
  1043. return True, "Webhook delivered successfully"
  1044. else:
  1045. return False, _opaque_http_failure(response, label="webhook endpoint")
  1046. except Exception as e:
  1047. return False, f"Webhook error: {str(e)}"
  1048. async def _send_homeassistant(
  1049. self,
  1050. config: dict,
  1051. title: str,
  1052. message: str,
  1053. db: AsyncSession | None = None,
  1054. image_url: str | None = None,
  1055. ) -> tuple[bool, str]:
  1056. """Send notification via Home Assistant.
  1057. Uses the globally configured HA URL/token from settings.
  1058. Defaults to persistent_notification/create, but supports
  1059. custom services via config["service"] (e.g. notify.mobile_app_myphone).
  1060. """
  1061. # Get HA connection settings from global config
  1062. ha_url = ""
  1063. ha_token = ""
  1064. if db:
  1065. from backend.app.api.routes.settings import get_homeassistant_settings
  1066. try:
  1067. ha_settings = await get_homeassistant_settings(db)
  1068. ha_url = ha_settings.get("ha_url", "")
  1069. ha_token = ha_settings.get("ha_token", "")
  1070. except Exception as e:
  1071. logger.warning("Failed to read HA settings from database: %s", e)
  1072. else:
  1073. # Fallback: read directly from environment if no DB session
  1074. import os
  1075. ha_url = os.environ.get("HA_URL", "")
  1076. ha_token = os.environ.get("HA_TOKEN", "")
  1077. if not ha_url or not ha_token:
  1078. return False, (
  1079. "Home Assistant is not configured. Please set HA URL and token in Settings → Network → Home Assistant."
  1080. )
  1081. # Determine which HA service to call - Default: persistent_notification.create
  1082. service = (config.get("service") or "").strip()
  1083. if service:
  1084. # Allow in different forms:
  1085. # - notify.mobile_app_<device>
  1086. # - notify/mobile_app_<device>
  1087. # - api/services/notify/mobile_app_<device>
  1088. service_str = service.lstrip("/")
  1089. if service_str.startswith("api/services/"):
  1090. endpoint = service_str
  1091. elif "/" in service_str:
  1092. endpoint = f"api/services/{service_str}"
  1093. elif "." in service_str:
  1094. domain, svc = service_str.split(".", 1)
  1095. endpoint = f"api/services/{domain}/{svc}"
  1096. else:
  1097. return False, (
  1098. "Invalid Home Assistant service name. Use e.g. 'notify.mobile_app_yourdevice' or 'notify/your_service'."
  1099. )
  1100. if not re.match(r"^api/services/[a-zA-Z0-9_]+/[a-zA-Z0-9_]+$", endpoint):
  1101. return False, (
  1102. "Invalid Home Assistant service name. Domain and service must only contain letters, numbers, and underscores."
  1103. )
  1104. else:
  1105. endpoint = "api/services/persistent_notification/create"
  1106. url = f"{ha_url.rstrip('/')}/{endpoint}"
  1107. headers = {
  1108. "Authorization": f"Bearer {ha_token}",
  1109. "Content-Type": "application/json",
  1110. }
  1111. payload = {
  1112. "title": title,
  1113. "message": message,
  1114. }
  1115. # Optional custom service-data (#1441), forwarded as HA's nested "data"
  1116. # object so mobile-app push options (priority, ttl, channel, group, ...)
  1117. # reach the notify service. Only included when configured — the default
  1118. # persistent_notification.create schema rejects unknown keys.
  1119. raw_data = config.get("data")
  1120. data_payload: dict = {}
  1121. if raw_data:
  1122. if isinstance(raw_data, str):
  1123. try:
  1124. parsed_data = json.loads(raw_data)
  1125. except json.JSONDecodeError as e:
  1126. return False, f"Invalid JSON in the Data field: {e}"
  1127. else:
  1128. parsed_data = raw_data
  1129. if not isinstance(parsed_data, dict):
  1130. return False, 'The Data field must be a JSON object, e.g. {"priority": "high", "ttl": 0}'
  1131. data_payload = parsed_data
  1132. if image_url and service:
  1133. # HA's notify.* services fetch data.image themselves. Gated on a
  1134. # custom service being set: the default persistent_notification
  1135. # .create has a strict schema and 400s on fields it doesn't
  1136. # recognize, so we'd break plain notifications for anyone who
  1137. # never asked for a photo. setdefault so a user's own "image"
  1138. # key still wins.
  1139. data_payload.setdefault("image", image_url)
  1140. if data_payload:
  1141. payload["data"] = data_payload
  1142. client = await self._get_client()
  1143. response = await client.post(url, json=payload, headers=headers)
  1144. if response.status_code in (200, 201):
  1145. return True, "Notification sent via Home Assistant"
  1146. elif response.status_code == 401:
  1147. return False, "Home Assistant authentication failed - check your token"
  1148. else:
  1149. # ha_url comes from global settings (SETTINGS_UPDATE, admin-only), so
  1150. # this is a narrower channel than the per-request provider URLs — but
  1151. # it lands in the same NOTIFICATIONS_CREATE-gated test response, so it
  1152. # gets the same treatment.
  1153. return False, _opaque_http_failure(response, label="Home Assistant endpoint")
  1154. async def _get_or_build_photo_url(
  1155. self,
  1156. db: AsyncSession | None,
  1157. image_data: bytes | None,
  1158. event_type: str | None,
  1159. photo_cache: dict | None = None,
  1160. ) -> str | None:
  1161. """Return a URL for a notification photo, for providers that fetch it themselves.
  1162. Writes *image_data* under an unguessable filename and builds the URL
  1163. from the ``external_url`` setting. The filename is the only credential
  1164. (see notification_photos.py), so the URL opens this one photo and
  1165. nothing else -- no camera token, which would open every printer's live
  1166. stream. Print Complete goes through here too rather than reusing the
  1167. archive's ``finish_photo_url``: that route needs a media token, so with
  1168. auth on HA/Bark/Slack would get a 401.
  1169. Returns None with no photo or no ``external_url`` set: HA and Bark
  1170. fetch this URL themselves, so a relative path does them no good.
  1171. *photo_cache* is shared by every provider in one send, so only the
  1172. first HA/Bark/Slack provider pays for the disk write. It's kept apart
  1173. from the template variables so the URL never lands in a generic
  1174. webhook's payload.
  1175. """
  1176. if photo_cache is not None and "url" in photo_cache:
  1177. return photo_cache["url"]
  1178. if not image_data or db is None:
  1179. return None
  1180. from backend.app.api.routes.settings import get_setting
  1181. external_url = (await get_setting(db, "external_url") or "").strip()
  1182. if not external_url:
  1183. return None
  1184. try:
  1185. filename = await asyncio.to_thread(save_notification_photo, image_data, event_type or "event")
  1186. except Exception as e:
  1187. logger.warning("Failed to persist notification photo: %s", e)
  1188. return None
  1189. url = f"{external_url.rstrip('/')}/api/v1/notifications/photos/{filename}"
  1190. if photo_cache is not None:
  1191. photo_cache["url"] = url
  1192. return url
  1193. async def _send_to_provider(
  1194. self,
  1195. provider: NotificationProvider,
  1196. title: str,
  1197. message: str,
  1198. db: AsyncSession | None = None,
  1199. image_data: bytes | None = None,
  1200. event_type: str | None = None,
  1201. variables: dict | None = None,
  1202. photo_cache: dict | None = None,
  1203. printer_id: int | None = None,
  1204. bypass_quiet_hours: bool = False,
  1205. ) -> tuple[bool, str]:
  1206. """Send notification to a specific provider.
  1207. *photo_cache* lets the providers of one send share a single persisted
  1208. photo URL (see _get_or_build_photo_url). *bypass_quiet_hours* is for
  1209. the daily digest, which goes out at the time chosen for it.
  1210. """
  1211. # Check quiet hours
  1212. if not bypass_quiet_hours and self._is_in_quiet_hours(provider):
  1213. logger.info("Skipping notification to %s - quiet hours active", provider.name)
  1214. return True, "Skipped - quiet hours"
  1215. config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
  1216. # attach_photo is a per-provider opt-out. Clearing image_data covers
  1217. # the byte-upload providers below; HA/Bark/Slack get their own check
  1218. # further down too, since they could otherwise get the URL an earlier
  1219. # provider in the same send left in photo_cache.
  1220. if not provider.attach_photo:
  1221. image_data = None
  1222. try:
  1223. if provider.provider_type == "callmebot":
  1224. return await self._send_callmebot(config, f"{title}\n{message}")
  1225. elif provider.provider_type == "ntfy":
  1226. # Outcome confirmation (#1898): render the verdict capability
  1227. # links as one-tap buttons on the notification itself. http so
  1228. # no browser needs to open; POST because that is the method
  1229. # that records — a GET only opens the confirmation page, which
  1230. # is what keeps unfurlers from answering the prompt.
  1231. # clear=true dismisses the notification once a button was tapped.
  1232. ntfy_actions = None
  1233. good_url = (variables or {}).get("good_url")
  1234. reject_url = (variables or {}).get("reject_url")
  1235. # Buttons need absolute URLs; without a configured external_url
  1236. # the links are relative, and the body's deep link into the
  1237. # archive has to do.
  1238. if (
  1239. event_type == "print_confirm_request"
  1240. and good_url
  1241. and reject_url
  1242. and good_url.startswith("http")
  1243. and reject_url.startswith("http")
  1244. ):
  1245. ntfy_actions = (
  1246. f"http, Good, {good_url}, method=POST, clear=true; "
  1247. f"http, Reject, {reject_url}, method=POST, clear=true"
  1248. )
  1249. return await self._send_ntfy(
  1250. config, title, message, image_data=image_data, event_type=event_type, actions=ntfy_actions
  1251. )
  1252. elif provider.provider_type == "pushover":
  1253. # Outcome confirmation (#1898): Pushover has no arbitrary
  1254. # buttons, but supports one supplementary URL — deep-link into
  1255. # the archive's confirmation dialog.
  1256. supplement_url = None
  1257. supplement_url_title = None
  1258. _confirm_url = (variables or {}).get("confirm_url")
  1259. if event_type == "print_confirm_request" and _confirm_url and _confirm_url.startswith("http"):
  1260. supplement_url = _confirm_url
  1261. supplement_url_title = "Confirm print outcome"
  1262. return await self._send_pushover(
  1263. config, title, message, image_data=image_data, url=supplement_url, url_title=supplement_url_title
  1264. )
  1265. elif provider.provider_type == "telegram":
  1266. if event_type == "print_confirm_request":
  1267. # Outcome confirmation (#1898): inline URL buttons under
  1268. # the message — one tap records the verdict via the
  1269. # capability link. Same absolute-URL requirement as the
  1270. # ntfy actions. Telegram has no way to POST, so this is the
  1271. # one affordance that opens a browser, and it is the one
  1272. # that gets the one-tap marker: the page submits itself
  1273. # only for a URL that came off a button. Telegram does not
  1274. # fetch inline-keyboard URLs and nothing else can read
  1275. # them, so the marker never reaches a scanner — which is
  1276. # the difference between this and trusting the User-Agent.
  1277. tg_buttons = None
  1278. _tg_good = (variables or {}).get("good_url")
  1279. _tg_reject = (variables or {}).get("reject_url")
  1280. if _tg_good and _tg_reject and _tg_good.startswith("http") and _tg_reject.startswith("http"):
  1281. tg_buttons = [
  1282. {"text": "\U0001f44d Good", "url": one_tap_url(_tg_good)},
  1283. {"text": "\U0001f44e Reject", "url": one_tap_url(_tg_reject)},
  1284. ]
  1285. # Telegram verdict mode (#3046): buttons, a reaction on
  1286. # the message, or both. The link preview is off in all of
  1287. # them (see _send_telegram_confirm_request).
  1288. _archive_id = (variables or {}).get("archive_id")
  1289. return await self._send_telegram_confirm_request(
  1290. provider,
  1291. config,
  1292. f"*{title}*\n{message}",
  1293. db,
  1294. image_data,
  1295. tg_buttons,
  1296. _archive_id if isinstance(_archive_id, int) else None,
  1297. )
  1298. # Every other event keeps Telegram's link preview, for the same
  1299. # reason as the Slack unfurl in _send_webhook: when the finish
  1300. # photo is too large to attach, the preview is how a
  1301. # {finish_photo_url} in a print_complete body still shows up as
  1302. # a photo in the chat.
  1303. return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
  1304. elif provider.provider_type == "email":
  1305. # finish_photo_url is pulled from the rendered template variables
  1306. # so _send_email can detect whether the template referenced the
  1307. # URL and inline-embed the photo only in that case.
  1308. finish_photo_url = (variables or {}).get("finish_photo_url")
  1309. return await self._send_email(
  1310. config, title, message, image_data=image_data, finish_photo_url=finish_photo_url
  1311. )
  1312. elif provider.provider_type == "discord":
  1313. return await self._send_discord(config, title, message, image_data=image_data)
  1314. elif provider.provider_type == "webhook":
  1315. # Only Slack format needs a URL — generic format gets the
  1316. # base64 image field instead.
  1317. photo_url = None
  1318. if provider.attach_photo and (config.get("payload_format") or "generic").strip() == "slack":
  1319. photo_url = await self._get_or_build_photo_url(db, image_data, event_type, photo_cache)
  1320. return await self._send_webhook(
  1321. config,
  1322. title,
  1323. message,
  1324. image_data=image_data,
  1325. event_type=event_type,
  1326. variables=variables,
  1327. image_url=photo_url,
  1328. )
  1329. elif provider.provider_type == "homeassistant":
  1330. photo_url = (
  1331. await self._get_or_build_photo_url(db, image_data, event_type, photo_cache)
  1332. if provider.attach_photo
  1333. else None
  1334. )
  1335. return await self._send_homeassistant(config, title, message, db=db, image_url=photo_url)
  1336. elif provider.provider_type == "bark":
  1337. # Outcome confirmation (#1898): Bark opens one URL on tap —
  1338. # deep-link into the confirmation dialog, like Pushover.
  1339. bark_url = None
  1340. _bark_confirm = (variables or {}).get("confirm_url")
  1341. if event_type == "print_confirm_request" and _bark_confirm and _bark_confirm.startswith("http"):
  1342. bark_url = _bark_confirm
  1343. photo_url = (
  1344. await self._get_or_build_photo_url(db, image_data, event_type, photo_cache)
  1345. if provider.attach_photo
  1346. else None
  1347. )
  1348. return await self._send_bark(config, title, message, url=bark_url, image_url=photo_url)
  1349. elif provider.provider_type == "notify":
  1350. photo_url = (
  1351. await self._get_or_build_photo_url(db, image_data, event_type, photo_cache)
  1352. if provider.attach_photo and notify_supports_photos(config.get("device_id"))
  1353. else None
  1354. )
  1355. return await self._send_notify(
  1356. config, title, message, image_url=photo_url, event_type=event_type, printer_id=printer_id
  1357. )
  1358. elif provider.provider_type == "gotify":
  1359. # Outcome confirmation (#1898): like Bark, Gotify opens one URL
  1360. # on tap -- deep-link into the confirmation dialog.
  1361. gotify_url = None
  1362. _gotify_confirm = (variables or {}).get("confirm_url")
  1363. if event_type == "print_confirm_request" and _gotify_confirm and _gotify_confirm.startswith("http"):
  1364. gotify_url = _gotify_confirm
  1365. photo_url = (
  1366. await self._get_or_build_photo_url(db, image_data, event_type, photo_cache)
  1367. if provider.attach_photo
  1368. else None
  1369. )
  1370. return await self._send_gotify(
  1371. config, title, message, event_type=event_type, url=gotify_url, image_url=photo_url
  1372. )
  1373. else:
  1374. return False, f"Unknown provider type: {provider.provider_type}"
  1375. except Exception as e:
  1376. logger.exception("Error sending notification via %s", provider.provider_type)
  1377. return False, str(e)
  1378. async def _update_provider_status(
  1379. self, db: AsyncSession, provider_id: int, success: bool, error: str | None = None
  1380. ):
  1381. """Update provider status after sending notification."""
  1382. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  1383. provider = result.scalar_one_or_none()
  1384. if provider:
  1385. if success:
  1386. provider.last_success = datetime.now(timezone.utc)
  1387. else:
  1388. # Read by every notification reader: no refused header's value.
  1389. provider.last_error = redact_refused_headers(error)
  1390. provider.last_error_at = datetime.now(timezone.utc)
  1391. await db.commit()
  1392. async def _get_providers_for_event(
  1393. self,
  1394. db: AsyncSession,
  1395. event_field: str,
  1396. printer_id: int | None = None,
  1397. *,
  1398. fleet_wide: bool = False,
  1399. ) -> list[NotificationProvider]:
  1400. """Get all enabled providers that want a specific event type.
  1401. A provider limited to one printer gets that printer's events. An event
  1402. with no printer -- a job for "Any <model>" not yet assigned -- reaches
  1403. only "All printers" providers, unless it is about the whole fleet
  1404. (``fleet_wide``: the queue finishing, stock, app messages), which
  1405. every provider that wants it gets.
  1406. Runs under ``no_autoflush`` (#2770). Callers routinely hold pending
  1407. writes when they raise an event — the AMS sensor loop does
  1408. ``db.add(history)`` and only commits after the alarms have gone out — and
  1409. without this, autoflush satisfies this SELECT by writing those rows,
  1410. which opens a write transaction on SQLite. The provider is then contacted
  1411. over the network with that transaction still open, so a site whose
  1412. internet is down holds the single SQLite writer for the whole connect
  1413. timeout and unrelated background tasks fail with "database is locked".
  1414. Deferring the flush costs nothing here: providers are committed rows, so
  1415. a pending change in the caller's session cannot be one this query wants.
  1416. """
  1417. # Build the query dynamically based on event field
  1418. query = select(NotificationProvider).where(
  1419. NotificationProvider.enabled.is_(True),
  1420. getattr(NotificationProvider, event_field).is_(True),
  1421. )
  1422. if printer_id is not None:
  1423. query = query.where(
  1424. (NotificationProvider.printer_id.is_(None)) | (NotificationProvider.printer_id == printer_id)
  1425. )
  1426. elif not fleet_wide:
  1427. query = query.where(NotificationProvider.printer_id.is_(None))
  1428. with db.no_autoflush:
  1429. result = await db.execute(query)
  1430. return list(result.scalars().all())
  1431. async def _log_notification(
  1432. self,
  1433. db: AsyncSession,
  1434. provider_id: int,
  1435. event_type: str,
  1436. title: str,
  1437. message: str,
  1438. success: bool,
  1439. error_message: str | None = None,
  1440. printer_id: int | None = None,
  1441. printer_name: str | None = None,
  1442. ):
  1443. """Create a log entry for a sent notification."""
  1444. try:
  1445. log = NotificationLog(
  1446. provider_id=provider_id,
  1447. event_type=event_type,
  1448. title=title,
  1449. message=message,
  1450. success=success,
  1451. error_message=redact_refused_headers(error_message),
  1452. printer_id=printer_id,
  1453. printer_name=printer_name,
  1454. )
  1455. db.add(log)
  1456. await db.commit()
  1457. except Exception as e:
  1458. logger.warning("Failed to log notification: %s", e)
  1459. # Don't fail the notification just because logging failed
  1460. async def _send_to_providers(
  1461. self,
  1462. providers: list[NotificationProvider],
  1463. title: str,
  1464. message: str,
  1465. db: AsyncSession,
  1466. event_type: str = "unknown",
  1467. printer_id: int | None = None,
  1468. printer_name: str | None = None,
  1469. image_data: bytes | None = None,
  1470. variables: dict | None = None,
  1471. ):
  1472. """Send notification to multiple providers and log the results.
  1473. All notifications are always sent immediately. If digest mode is enabled,
  1474. the notification is ALSO queued for the daily digest summary.
  1475. """
  1476. photo_cache: dict = {}
  1477. for provider in providers:
  1478. try:
  1479. # Always send notification immediately
  1480. success, error = await self._send_to_provider(
  1481. provider,
  1482. title,
  1483. message,
  1484. db,
  1485. image_data=image_data,
  1486. event_type=event_type,
  1487. variables=variables,
  1488. photo_cache=photo_cache,
  1489. printer_id=printer_id,
  1490. )
  1491. # Also queue for digest if enabled (digest is a summary, not a queue)
  1492. if provider.daily_digest_enabled and provider.daily_digest_time:
  1493. await self._queue_for_digest(
  1494. provider=provider,
  1495. event_type=event_type,
  1496. title=title,
  1497. message=message,
  1498. db=db,
  1499. printer_id=printer_id,
  1500. printer_name=printer_name,
  1501. )
  1502. await self._update_provider_status(db, provider.id, success, error if not success else None)
  1503. await self._log_notification(
  1504. db=db,
  1505. provider_id=provider.id,
  1506. event_type=event_type,
  1507. title=title,
  1508. message=message,
  1509. success=success,
  1510. error_message=error if not success else None,
  1511. printer_id=printer_id,
  1512. printer_name=printer_name,
  1513. )
  1514. if success:
  1515. logger.info("Sent notification via %s", provider.name)
  1516. else:
  1517. logger.warning("Failed to send notification via %s: %s", provider.name, error)
  1518. except Exception as e:
  1519. logger.exception("Error sending notification via %s", provider.name)
  1520. await self._update_provider_status(db, provider.id, False, str(e))
  1521. await self._log_notification(
  1522. db=db,
  1523. provider_id=provider.id,
  1524. event_type=event_type,
  1525. title=title,
  1526. message=message,
  1527. success=False,
  1528. error_message=str(e),
  1529. printer_id=printer_id,
  1530. printer_name=printer_name,
  1531. )
  1532. async def on_app_message(
  1533. self,
  1534. db: AsyncSession,
  1535. *,
  1536. sender: str,
  1537. title: str,
  1538. message: str,
  1539. url: str | None = None,
  1540. allowed: Callable[[NotificationProvider], bool] | None = None,
  1541. ) -> int:
  1542. """A message another application sends through Bambuddy.
  1543. Goes to every enabled channel with "Messages from connected apps" on,
  1544. through the same path as Bambuddy's own events: quiet hours, the daily
  1545. digest and the log (whose event type names the sender). The text is
  1546. the app's own; a link, when given, is appended so every channel type
  1547. carries it. Returns how many channels it was handed to. ``allowed``
  1548. narrows the channels to those the sender may reach.
  1549. """
  1550. providers = await self._get_providers_for_event(db, "on_app_message", fleet_wide=True)
  1551. if allowed is not None:
  1552. providers = [provider for provider in providers if allowed(provider)]
  1553. if not providers:
  1554. return 0
  1555. body = f"{message}\n{url}" if url else message
  1556. await self._send_to_providers(providers, title, body, db, event_type=f"app:{sender}"[:50])
  1557. return len(providers)
  1558. async def on_print_start(
  1559. self,
  1560. printer_id: int,
  1561. printer_name: str,
  1562. data: dict,
  1563. db: AsyncSession,
  1564. archive_data: dict | None = None,
  1565. ):
  1566. """Handle print start event - send notifications to relevant providers.
  1567. Args:
  1568. printer_id: The printer ID
  1569. printer_name: The printer name
  1570. data: MQTT event data with filename, subtask_name, remaining_time, raw_data
  1571. db: Database session
  1572. archive_data: Optional archive data with print_time_seconds from 3MF parsing
  1573. """
  1574. logger.info("on_print_start called for printer %s (%s)", printer_id, printer_name)
  1575. providers = await self._get_providers_for_event(db, "on_print_start", printer_id)
  1576. if not providers:
  1577. logger.info("No notification providers configured for print_start event on printer %s", printer_id)
  1578. return
  1579. # Use subtask_name (project name) if available, otherwise use filename
  1580. subtask_name = data.get("subtask_name")
  1581. if subtask_name:
  1582. # Replace underscores with spaces for readability
  1583. filename = subtask_name.replace("_", " ")
  1584. else:
  1585. filename = self._clean_filename(data.get("filename", "Unknown"))
  1586. # Priority for estimated_time:
  1587. # 1. Archive's print_time_seconds from 3MF parsing (most reliable)
  1588. # 2. MQTT remaining_time (may be 0 at print start)
  1589. # 3. raw_data mc_remaining_time
  1590. estimated_time = None
  1591. # Try archive data first (from 3MF parsing - most reliable)
  1592. if archive_data and archive_data.get("print_time_seconds"):
  1593. estimated_time = archive_data["print_time_seconds"]
  1594. logger.debug("Using print_time_seconds from archive: %s", estimated_time)
  1595. # Fall back to MQTT remaining_time
  1596. if estimated_time is None:
  1597. estimated_time = data.get("remaining_time")
  1598. if estimated_time:
  1599. logger.debug("Using remaining_time from MQTT: %s", estimated_time)
  1600. # Last resort: raw_data mc_remaining_time (in minutes, convert to seconds)
  1601. if estimated_time is None:
  1602. raw_time = data.get("raw_data", {}).get("mc_remaining_time")
  1603. if raw_time:
  1604. estimated_time = raw_time * 60
  1605. logger.debug("Using mc_remaining_time from raw_data: %s", estimated_time)
  1606. time_str = self._format_duration(estimated_time)
  1607. eta_str = await self._format_eta(estimated_time, db)
  1608. variables = {
  1609. "printer": printer_name,
  1610. "filename": filename,
  1611. "estimated_time": time_str,
  1612. "eta": eta_str,
  1613. }
  1614. # Extract image data for providers that support attachments (e.g. Pushover)
  1615. image_data = None
  1616. if archive_data:
  1617. image_data = archive_data.get("image_data")
  1618. logger.info("Found %s providers for print_start: %s", len(providers), [p.name for p in providers])
  1619. title, message = await self._build_message_from_template(db, "print_start", variables)
  1620. await self._send_to_providers(
  1621. providers,
  1622. title,
  1623. message,
  1624. db,
  1625. "print_start",
  1626. printer_id,
  1627. printer_name,
  1628. image_data=image_data,
  1629. variables=variables,
  1630. )
  1631. async def on_print_complete(
  1632. self,
  1633. printer_id: int,
  1634. printer_name: str,
  1635. status: str,
  1636. data: dict,
  1637. db: AsyncSession,
  1638. archive_data: dict | None = None,
  1639. ):
  1640. """Handle print complete event - send notifications to relevant providers."""
  1641. logger.info("on_print_complete called for printer %s (%s), status=%s", printer_id, printer_name, status)
  1642. # Determine event type based on status
  1643. if status == "completed":
  1644. event_field = "on_print_complete"
  1645. event_type = "print_complete"
  1646. elif status in ("failed",):
  1647. event_field = "on_print_failed"
  1648. event_type = "print_failed"
  1649. elif status in ("aborted", "stopped", "cancelled"):
  1650. event_field = "on_print_stopped"
  1651. event_type = "print_stopped"
  1652. else:
  1653. logger.warning("Unknown print status '%s', defaulting to on_print_complete", status)
  1654. event_field = "on_print_complete"
  1655. event_type = "print_complete"
  1656. providers = await self._get_providers_for_event(db, event_field, printer_id)
  1657. if not providers:
  1658. logger.info("No notification providers configured for %s event on printer %s", event_field, printer_id)
  1659. return
  1660. # Use subtask_name (project name) if available, otherwise use filename
  1661. subtask_name = data.get("subtask_name")
  1662. if subtask_name:
  1663. filename = subtask_name.replace("_", " ")
  1664. else:
  1665. filename = self._clean_filename(data.get("filename", "Unknown"))
  1666. variables = {
  1667. "printer": printer_name,
  1668. "filename": filename,
  1669. "duration": "Unknown",
  1670. "filament_grams": "Unknown",
  1671. "reason": "Unknown",
  1672. }
  1673. if archive_data:
  1674. # {{duration}} on completion / failure / stopped events is the *actual*
  1675. # elapsed time (#1198). Slicer-estimated print_time_seconds is only used
  1676. # as a last-resort fallback when timestamps weren't recorded.
  1677. duration_seconds = archive_data.get("actual_time_seconds") or archive_data.get("print_time_seconds")
  1678. if duration_seconds:
  1679. variables["duration"] = self._format_duration(duration_seconds)
  1680. if archive_data.get("actual_filament_grams"):
  1681. variables["filament_grams"] = f"{archive_data['actual_filament_grams']:.1f}"
  1682. if status == "failed" and archive_data.get("failure_reason"):
  1683. variables["reason"] = archive_data["failure_reason"]
  1684. if archive_data.get("finish_photo_url"):
  1685. variables["finish_photo_url"] = archive_data["finish_photo_url"]
  1686. # Build per-slot breakdown string with AMS info when available
  1687. if archive_data.get("usage_results"):
  1688. parts = []
  1689. for u in archive_data["usage_results"]:
  1690. ams_id = u.get("ams_id", 0)
  1691. tray_id = u.get("tray_id", 0)
  1692. material = u.get("material", "Unknown") or "Unknown"
  1693. used = u.get("weight_used", 0)
  1694. if ams_id >= 128:
  1695. slot_label = "Ext"
  1696. else:
  1697. slot_label = f"AMS-{chr(65 + ams_id)} T{tray_id + 1}"
  1698. parts.append(f"{slot_label} {material}: {used:.1f}g")
  1699. variables["filament_details"] = " | ".join(parts)
  1700. elif archive_data.get("filament_slots"):
  1701. parts = []
  1702. for slot in archive_data["filament_slots"]:
  1703. ftype = slot.get("type", "Unknown") or "Unknown"
  1704. used = slot.get("used_g", 0)
  1705. parts.append(f"{ftype}: {used:.1f}g")
  1706. variables["filament_details"] = " | ".join(parts)
  1707. # Add progress for partial prints
  1708. if archive_data.get("progress") is not None:
  1709. variables["progress"] = str(archive_data["progress"])
  1710. # Extract image data for providers that support attachments (e.g. Pushover)
  1711. image_data = None
  1712. if archive_data:
  1713. image_data = archive_data.get("image_data")
  1714. logger.info("Found %s providers for %s: %s", len(providers), event_field, [p.name for p in providers])
  1715. title, message = await self._build_message_from_template(db, event_type, variables)
  1716. await self._send_to_providers(
  1717. providers,
  1718. title,
  1719. message,
  1720. db,
  1721. event_type,
  1722. printer_id,
  1723. printer_name,
  1724. image_data=image_data,
  1725. variables=variables,
  1726. )
  1727. async def on_print_confirm_request(
  1728. self,
  1729. printer_id: int,
  1730. printer_name: str,
  1731. data: dict,
  1732. db: AsyncSession,
  1733. archive_data: dict | None = None,
  1734. good_url: str | None = None,
  1735. reject_url: str | None = None,
  1736. confirm_url: str | None = None,
  1737. archive_id: int | None = None,
  1738. ):
  1739. """Ask for a post-print outcome verdict (#1898).
  1740. Fires only for completed prints whose queue item opted in — the
  1741. provider-level toggle exists to mute a channel, not to enable the
  1742. feature. good_url / reject_url are the one-tap capability links
  1743. (rendered as ntfy action buttons), confirm_url deep-links into the
  1744. archive's confirmation dialog in the web UI. archive_id lets a Telegram
  1745. provider in reaction mode (#3046) tie the sent message to the archive.
  1746. """
  1747. providers = await self._get_providers_for_event(db, "on_print_confirm_request", printer_id)
  1748. if not providers:
  1749. return
  1750. subtask_name = data.get("subtask_name")
  1751. if subtask_name:
  1752. filename = subtask_name.replace("_", " ")
  1753. else:
  1754. filename = self._clean_filename(data.get("filename", "Unknown"))
  1755. variables = {"printer": printer_name, "filename": filename}
  1756. if good_url:
  1757. variables["good_url"] = good_url
  1758. if reject_url:
  1759. variables["reject_url"] = reject_url
  1760. if confirm_url:
  1761. variables["confirm_url"] = confirm_url
  1762. if archive_id is not None:
  1763. variables["archive_id"] = archive_id
  1764. image_data = None
  1765. if archive_data:
  1766. if archive_data.get("finish_photo_url"):
  1767. variables["finish_photo_url"] = archive_data["finish_photo_url"]
  1768. image_data = archive_data.get("image_data")
  1769. title, message = await self._build_message_from_template(db, "print_confirm_request", variables)
  1770. await self._send_to_providers(
  1771. providers,
  1772. title,
  1773. message,
  1774. db,
  1775. "print_confirm_request",
  1776. printer_id,
  1777. printer_name,
  1778. image_data=image_data,
  1779. variables=variables,
  1780. )
  1781. async def on_print_progress(
  1782. self,
  1783. printer_id: int,
  1784. printer_name: str,
  1785. filename: str,
  1786. progress: int,
  1787. db: AsyncSession,
  1788. remaining_time: int | None = None,
  1789. image_data: bytes | None = None,
  1790. ):
  1791. """Handle print progress milestone (25%, 50%, 75%)."""
  1792. providers = await self._get_providers_for_event(db, "on_print_progress", printer_id)
  1793. if not providers:
  1794. return
  1795. eta_str = await self._format_eta(remaining_time, db)
  1796. variables = {
  1797. "printer": printer_name,
  1798. "filename": self._clean_filename(filename),
  1799. "progress": str(progress),
  1800. "remaining_time": self._format_duration(remaining_time) if remaining_time else "Unknown",
  1801. "eta": eta_str,
  1802. }
  1803. title, message = await self._build_message_from_template(db, "print_progress", variables)
  1804. await self._send_to_providers(
  1805. providers,
  1806. title,
  1807. message,
  1808. db,
  1809. "print_progress",
  1810. printer_id,
  1811. printer_name,
  1812. image_data=image_data,
  1813. variables=variables,
  1814. )
  1815. async def on_print_missing_spool_assignment(
  1816. self,
  1817. printer_id: int,
  1818. printer_name: str,
  1819. missing_slots: list[dict[str, str]],
  1820. db: AsyncSession,
  1821. ):
  1822. """Handle print-start event when required trays are missing spool assignments."""
  1823. if not missing_slots:
  1824. return
  1825. providers = await self._get_providers_for_event(db, "on_print_missing_spool_assignment", printer_id)
  1826. if not providers:
  1827. return
  1828. missing_slot_names = ", ".join(slot.get("slot", "Unknown") for slot in missing_slots)
  1829. detail_lines = []
  1830. for slot in missing_slots:
  1831. slot_name = slot.get("slot", "Unknown")
  1832. profile = slot.get("profile", "Unknown")
  1833. detail_lines.append(f"- {slot_name}: {profile}")
  1834. missing_profile_details = "\n".join(detail_lines)
  1835. variables = {
  1836. "printer": printer_name,
  1837. "missing_slots": missing_slot_names,
  1838. "missing_slot_details": missing_profile_details,
  1839. }
  1840. title, message = await self._build_message_from_template(db, "print_missing_spool_assignment", variables)
  1841. await self._send_to_providers(
  1842. providers,
  1843. title,
  1844. message,
  1845. db,
  1846. "print_missing_spool_assignment",
  1847. printer_id,
  1848. printer_name,
  1849. variables=variables,
  1850. )
  1851. async def on_billing_charge_failed(
  1852. self,
  1853. printer_id: int,
  1854. printer_name: str,
  1855. filename: str,
  1856. archive_id: int | None,
  1857. error: str,
  1858. db: AsyncSession,
  1859. ) -> None:
  1860. """Notify providers that a terminal print could not be charged."""
  1861. providers = await self._get_providers_for_event(db, "on_billing_charge_failed", printer_id)
  1862. if not providers:
  1863. return
  1864. variables = {
  1865. "printer": printer_name,
  1866. "filename": self._clean_filename(filename),
  1867. "archive_id": str(archive_id) if archive_id is not None else "Unknown",
  1868. "error": error,
  1869. }
  1870. title, message = await self._build_message_from_template(db, "billing_charge_failed", variables)
  1871. await self._send_to_providers(
  1872. providers,
  1873. title,
  1874. message,
  1875. db,
  1876. "billing_charge_failed",
  1877. printer_id,
  1878. printer_name,
  1879. variables=variables,
  1880. )
  1881. async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
  1882. """Handle printer offline event."""
  1883. providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
  1884. if not providers:
  1885. return
  1886. variables = {"printer": printer_name}
  1887. title, message = await self._build_message_from_template(db, "printer_offline", variables)
  1888. await self._send_to_providers(
  1889. providers, title, message, db, "printer_offline", printer_id, printer_name, variables=variables
  1890. )
  1891. async def on_printer_error(
  1892. self,
  1893. printer_id: int | None,
  1894. printer_name: str,
  1895. error_type: str,
  1896. db: AsyncSession,
  1897. error_detail: str | None = None,
  1898. image_data: bytes | None = None,
  1899. ):
  1900. """Handle printer error event (AMS issues, etc.)."""
  1901. providers = await self._get_providers_for_event(db, "on_printer_error", printer_id)
  1902. if not providers:
  1903. return
  1904. variables = {
  1905. "printer": printer_name,
  1906. "error_type": error_type,
  1907. "error_detail": error_detail or "No details available",
  1908. }
  1909. title, message = await self._build_message_from_template(db, "printer_error", variables)
  1910. await self._send_to_providers(
  1911. providers,
  1912. title,
  1913. message,
  1914. db,
  1915. "printer_error",
  1916. printer_id,
  1917. printer_name,
  1918. image_data=image_data,
  1919. variables=variables,
  1920. )
  1921. async def on_ai_failure_detection(
  1922. self,
  1923. printer_id: int,
  1924. printer_name: str,
  1925. task_name: str,
  1926. confidence: float,
  1927. action: str,
  1928. db: AsyncSession,
  1929. image_data: bytes | None = None,
  1930. ):
  1931. """Handle AI failure-detection event (Obico spaghetti / print-failure ML).
  1932. Split out of on_printer_error (#1794) so a user can subscribe to AI
  1933. alerts without also being paged for every HMS hardware code.
  1934. """
  1935. providers = await self._get_providers_for_event(db, "on_ai_failure_detection", printer_id)
  1936. if not providers:
  1937. return
  1938. variables = {
  1939. "printer": printer_name,
  1940. "task_name": task_name or "current job",
  1941. "confidence": f"{confidence:.2f}",
  1942. "action": action,
  1943. }
  1944. title, message = await self._build_message_from_template(db, "ai_failure_detection", variables)
  1945. await self._send_to_providers(
  1946. providers,
  1947. title,
  1948. message,
  1949. db,
  1950. "ai_failure_detection",
  1951. printer_id,
  1952. printer_name,
  1953. image_data=image_data,
  1954. variables=variables,
  1955. )
  1956. async def on_plate_not_empty(
  1957. self,
  1958. printer_id: int,
  1959. printer_name: str,
  1960. db: AsyncSession,
  1961. difference_percent: float | None = None,
  1962. image_data: bytes | None = None,
  1963. ):
  1964. """Handle plate not empty event - objects detected on build plate before print."""
  1965. providers = await self._get_providers_for_event(db, "on_plate_not_empty", printer_id)
  1966. if not providers:
  1967. return
  1968. variables = {
  1969. "printer": printer_name,
  1970. "difference_percent": f"{difference_percent:.1f}" if difference_percent else "N/A",
  1971. }
  1972. title, message = await self._build_message_from_template(db, "plate_not_empty", variables)
  1973. await self._send_to_providers(
  1974. providers,
  1975. title,
  1976. message,
  1977. db,
  1978. "plate_not_empty",
  1979. printer_id,
  1980. printer_name,
  1981. image_data=image_data,
  1982. variables=variables,
  1983. )
  1984. async def on_plate_clear_required(
  1985. self,
  1986. printer_id: int,
  1987. printer_name: str,
  1988. db: AsyncSession,
  1989. ):
  1990. """Handle plate-clear-required event — a print ended and the queue is gated (#2525).
  1991. Distinct from ``on_plate_not_empty``, which is the camera check *before* a
  1992. print starts. This one fires on the rising edge of the Bambuddy-side
  1993. awaiting-plate-clear flag, i.e. whenever a print reaches a terminal state
  1994. and the next queued job can't dispatch until someone confirms the bed is
  1995. free. Off by default on every provider: it lands at the same moment as the
  1996. print-complete notification, so opting in is a deliberate choice.
  1997. """
  1998. providers = await self._get_providers_for_event(db, "on_plate_clear_required", printer_id)
  1999. if not providers:
  2000. return
  2001. variables = {"printer": printer_name}
  2002. title, message = await self._build_message_from_template(db, "plate_clear_required", variables)
  2003. await self._send_to_providers(
  2004. providers,
  2005. title,
  2006. message,
  2007. db,
  2008. "plate_clear_required",
  2009. printer_id,
  2010. printer_name,
  2011. variables=variables,
  2012. )
  2013. async def on_filament_low(
  2014. self,
  2015. printer_id: int,
  2016. printer_name: str,
  2017. slot: str,
  2018. remaining_percent: int,
  2019. db: AsyncSession,
  2020. color: str | None = None,
  2021. ):
  2022. """Handle low filament event."""
  2023. providers = await self._get_providers_for_event(db, "on_filament_low", printer_id)
  2024. if not providers:
  2025. return
  2026. variables = {
  2027. "printer": printer_name,
  2028. "slot": slot,
  2029. "remaining_percent": str(remaining_percent),
  2030. "color": color or "",
  2031. }
  2032. title, message = await self._build_message_from_template(db, "filament_low", variables)
  2033. await self._send_to_providers(
  2034. providers, title, message, db, "filament_low", printer_id, printer_name, variables=variables
  2035. )
  2036. async def on_maintenance_due(
  2037. self,
  2038. printer_id: int,
  2039. printer_name: str,
  2040. maintenance_items: list[dict],
  2041. db: AsyncSession,
  2042. ):
  2043. """Handle maintenance due event - sends notification when maintenance is due or warning."""
  2044. if not maintenance_items:
  2045. return
  2046. providers = await self._get_providers_for_event(db, "on_maintenance_due", printer_id)
  2047. if not providers:
  2048. logger.info("No notification providers configured for maintenance_due event on printer %s", printer_id)
  2049. return
  2050. # Format maintenance items list
  2051. items_list = []
  2052. for item in maintenance_items:
  2053. status = "OVERDUE" if item.get("is_due") else "Soon"
  2054. items_list.append(f"- {item['name']} ({status})")
  2055. items_str = "\n".join(items_list)
  2056. variables = {
  2057. "printer": printer_name,
  2058. "items": items_str,
  2059. }
  2060. logger.info("Found %s providers for maintenance_due: %s", len(providers), [p.name for p in providers])
  2061. title, message = await self._build_message_from_template(db, "maintenance_due", variables)
  2062. await self._send_to_providers(
  2063. providers, title, message, db, "maintenance_due", printer_id, printer_name, variables=variables
  2064. )
  2065. async def on_ams_humidity_high(
  2066. self,
  2067. printer_id: int,
  2068. printer_name: str,
  2069. ams_label: str,
  2070. humidity: float,
  2071. threshold: float,
  2072. db: AsyncSession,
  2073. ):
  2074. """Handle AMS high humidity alarm event. Sent immediately, and also in the daily digest if enabled."""
  2075. providers = await self._get_providers_for_event(db, "on_ams_humidity_high", printer_id)
  2076. if not providers:
  2077. return
  2078. variables = {
  2079. "printer": printer_name,
  2080. "ams_label": ams_label,
  2081. "humidity": f"{humidity:.0f}",
  2082. "threshold": f"{threshold:.0f}",
  2083. }
  2084. title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
  2085. await self._send_to_providers(
  2086. providers,
  2087. title,
  2088. message,
  2089. db,
  2090. "ams_humidity_high",
  2091. printer_id,
  2092. printer_name,
  2093. variables=variables,
  2094. )
  2095. async def on_ams_temperature_high(
  2096. self,
  2097. printer_id: int,
  2098. printer_name: str,
  2099. ams_label: str,
  2100. temperature: float,
  2101. threshold: float,
  2102. db: AsyncSession,
  2103. ):
  2104. """Handle AMS high temperature alarm event. Sent immediately, and also in the daily digest if enabled."""
  2105. providers = await self._get_providers_for_event(db, "on_ams_temperature_high", printer_id)
  2106. if not providers:
  2107. return
  2108. variables = {
  2109. "printer": printer_name,
  2110. "ams_label": ams_label,
  2111. "temperature": f"{temperature:.1f}",
  2112. "threshold": f"{threshold:.1f}",
  2113. }
  2114. title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
  2115. await self._send_to_providers(
  2116. providers,
  2117. title,
  2118. message,
  2119. db,
  2120. "ams_temperature_high",
  2121. printer_id,
  2122. printer_name,
  2123. variables=variables,
  2124. )
  2125. async def on_ams_drying_suspended(
  2126. self,
  2127. printer_id: int,
  2128. printer_name: str,
  2129. ams_label: str,
  2130. humidity: float,
  2131. threshold: float,
  2132. cycles: int,
  2133. db: AsyncSession,
  2134. ):
  2135. """Handle automatic drying giving up on one AMS unit (#2770).
  2136. Worth sending immediately (as every notification is; a daily digest only
  2137. repeats it): it reports that
  2138. Bambuddy has STOPPED doing something, and a report of inaction that
  2139. arrives with tomorrow's summary has already cost the user a day.
  2140. """
  2141. providers = await self._get_providers_for_event(db, "on_ams_drying_suspended", printer_id)
  2142. if not providers:
  2143. return
  2144. variables = {
  2145. "printer": printer_name,
  2146. "ams_label": ams_label,
  2147. "humidity": f"{humidity:.0f}",
  2148. "threshold": f"{threshold:.0f}",
  2149. "cycles": str(cycles),
  2150. }
  2151. title, message = await self._build_message_from_template(db, "ams_drying_suspended", variables)
  2152. await self._send_to_providers(
  2153. providers,
  2154. title,
  2155. message,
  2156. db,
  2157. "ams_drying_suspended",
  2158. printer_id,
  2159. printer_name,
  2160. variables=variables,
  2161. )
  2162. async def on_ams_ht_humidity_high(
  2163. self,
  2164. printer_id: int,
  2165. printer_name: str,
  2166. ams_label: str,
  2167. humidity: float,
  2168. threshold: float,
  2169. db: AsyncSession,
  2170. ):
  2171. """Handle AMS-HT high humidity alarm event. Sent immediately, and also in the daily digest if enabled."""
  2172. providers = await self._get_providers_for_event(db, "on_ams_ht_humidity_high", printer_id)
  2173. if not providers:
  2174. return
  2175. variables = {
  2176. "printer": printer_name,
  2177. "ams_label": ams_label,
  2178. "humidity": f"{humidity:.0f}",
  2179. "threshold": f"{threshold:.0f}",
  2180. }
  2181. # Use the same template as regular AMS (can create separate templates later if needed)
  2182. title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
  2183. await self._send_to_providers(
  2184. providers,
  2185. title,
  2186. message,
  2187. db,
  2188. "ams_ht_humidity_high",
  2189. printer_id,
  2190. printer_name,
  2191. variables=variables,
  2192. )
  2193. async def on_ams_ht_temperature_high(
  2194. self,
  2195. printer_id: int,
  2196. printer_name: str,
  2197. ams_label: str,
  2198. temperature: float,
  2199. threshold: float,
  2200. db: AsyncSession,
  2201. ):
  2202. """Handle AMS-HT high temperature alarm event. Sent immediately, and also in the daily digest if enabled."""
  2203. providers = await self._get_providers_for_event(db, "on_ams_ht_temperature_high", printer_id)
  2204. if not providers:
  2205. return
  2206. variables = {
  2207. "printer": printer_name,
  2208. "ams_label": ams_label,
  2209. "temperature": f"{temperature:.1f}",
  2210. "threshold": f"{threshold:.1f}",
  2211. }
  2212. # Use the same template as regular AMS (can create separate templates later if needed)
  2213. title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
  2214. await self._send_to_providers(
  2215. providers,
  2216. title,
  2217. message,
  2218. db,
  2219. "ams_ht_temperature_high",
  2220. printer_id,
  2221. printer_name,
  2222. variables=variables,
  2223. )
  2224. async def on_bed_cooled(
  2225. self,
  2226. printer_id: int,
  2227. printer_name: str,
  2228. bed_temp: float,
  2229. threshold: float,
  2230. filename: str,
  2231. db: AsyncSession,
  2232. ):
  2233. """Handle bed cooled event - bed temperature dropped below threshold after print."""
  2234. providers = await self._get_providers_for_event(db, "on_bed_cooled", printer_id)
  2235. if not providers:
  2236. return
  2237. variables = {
  2238. "printer": printer_name,
  2239. "bed_temp": f"{bed_temp:.0f}",
  2240. "threshold": f"{threshold:.0f}",
  2241. "filename": self._clean_filename(filename) if filename else "Unknown",
  2242. }
  2243. title, message = await self._build_message_from_template(db, "bed_cooled", variables)
  2244. await self._send_to_providers(
  2245. providers, title, message, db, "bed_cooled", printer_id, printer_name, variables=variables
  2246. )
  2247. async def on_ha_sensor_alert(
  2248. self,
  2249. printer_id: int,
  2250. printer_name: str,
  2251. sensor_name: str,
  2252. state: str,
  2253. db: AsyncSession,
  2254. ):
  2255. """A Home Assistant sensor bound to a printer entered its alert state (#1148).
  2256. Worth sending immediately (as every notification is; a daily digest only
  2257. repeats it): the case this exists
  2258. for is an enclosure door left open, which is only worth telling someone
  2259. about while they can still act on it.
  2260. """
  2261. providers = await self._get_providers_for_event(db, "on_ha_sensor_alert", printer_id)
  2262. if not providers:
  2263. return
  2264. variables = {
  2265. "printer": printer_name,
  2266. "sensor": sensor_name,
  2267. "state": state,
  2268. }
  2269. title, message = await self._build_message_from_template(db, "ha_sensor_alert", variables)
  2270. await self._send_to_providers(
  2271. providers,
  2272. title,
  2273. message,
  2274. db,
  2275. "ha_sensor_alert",
  2276. printer_id,
  2277. printer_name,
  2278. variables=variables,
  2279. )
  2280. async def on_location_ha_sensor_alert(
  2281. self,
  2282. location_name: str,
  2283. sensor_name: str,
  2284. state: str,
  2285. db: AsyncSession,
  2286. ):
  2287. """A Home Assistant sensor bound to a storage location entered its alert state (#2824).
  2288. Worth sending immediately, for the same reason
  2289. as on_ha_sensor_alert above: this is the "drybox went stale" case, only
  2290. worth acting on while the humidity/temperature is still climbing.
  2291. """
  2292. # Own column, not on_ha_sensor_alert (#2824): that one can be scoped to
  2293. # a single printer via provider.printer_id, and a location alert has no
  2294. # printer to scope by, so sharing it would leak drybox alerts to a
  2295. # provider narrowed to one printer's sensors.
  2296. providers = await self._get_providers_for_event(db, "on_location_ha_sensor_alert", fleet_wide=True)
  2297. if not providers:
  2298. return
  2299. variables = {
  2300. "location": location_name,
  2301. "sensor": sensor_name,
  2302. "state": state,
  2303. }
  2304. title, message = await self._build_message_from_template(db, "location_ha_sensor_alert", variables)
  2305. await self._send_to_providers(
  2306. providers,
  2307. title,
  2308. message,
  2309. db,
  2310. "location_ha_sensor_alert",
  2311. variables=variables,
  2312. )
  2313. async def on_first_layer_complete(
  2314. self,
  2315. printer_id: int,
  2316. printer_name: str,
  2317. filename: str,
  2318. total_layers: int,
  2319. db: AsyncSession,
  2320. image_data: bytes | None = None,
  2321. ):
  2322. """Handle first layer complete event."""
  2323. providers = await self._get_providers_for_event(db, "on_first_layer_complete", printer_id)
  2324. if not providers:
  2325. return
  2326. variables = {
  2327. "printer": printer_name,
  2328. "filename": self._clean_filename(filename),
  2329. "total_layers": str(total_layers),
  2330. }
  2331. title, message = await self._build_message_from_template(db, "first_layer_complete", variables)
  2332. await self._send_to_providers(
  2333. providers,
  2334. title,
  2335. message,
  2336. db,
  2337. "first_layer_complete",
  2338. printer_id,
  2339. printer_name,
  2340. image_data=image_data,
  2341. variables=variables,
  2342. )
  2343. async def on_print_pause_upcoming(
  2344. self,
  2345. printer_id: int,
  2346. printer_name: str,
  2347. filename: str,
  2348. *,
  2349. minutes: int,
  2350. layer: int,
  2351. total_layers: int,
  2352. db: AsyncSession,
  2353. ):
  2354. """A pause the slicer put into the print is coming up (#2984)."""
  2355. providers = await self._get_providers_for_event(db, "on_print_pause_upcoming", printer_id)
  2356. if not providers:
  2357. return
  2358. variables = {
  2359. "printer": printer_name,
  2360. "filename": self._clean_filename(filename),
  2361. # At least a minute: a pause seconds away still reads as "1m".
  2362. "time_to_pause": self._format_duration(max(minutes, 1) * 60),
  2363. "eta": await self._format_eta(max(minutes * 60, 1), db),
  2364. "layer": str(layer),
  2365. "total_layers": str(total_layers),
  2366. }
  2367. title, message = await self._build_message_from_template(db, "print_pause_upcoming", variables)
  2368. await self._send_to_providers(
  2369. providers,
  2370. title,
  2371. message,
  2372. db,
  2373. "print_pause_upcoming",
  2374. printer_id,
  2375. printer_name,
  2376. variables=variables,
  2377. )
  2378. def clear_template_cache(self):
  2379. """Clear the template cache. Call this when templates are updated."""
  2380. self._template_cache.clear()
  2381. async def send_user_print_email(
  2382. self,
  2383. event_type: str,
  2384. created_by_id: int | None,
  2385. printer_name: str,
  2386. filename: str,
  2387. db: AsyncSession,
  2388. image_data: bytes | None = None,
  2389. finish_photo_url: str | None = None,
  2390. ) -> None:
  2391. """Send a print event email notification to the user who submitted the job.
  2392. Args:
  2393. event_type: 'user_print_start', 'user_print_complete', 'user_print_failed', or 'user_print_stopped'
  2394. created_by_id: User ID who submitted the print job (from archive)
  2395. printer_name: Name of the printer
  2396. filename: Raw filename or subtask name
  2397. db: Database session
  2398. image_data: Camera snapshot bytes, if one was captured for the event.
  2399. finish_photo_url: The same snapshot's public URL — only used so the
  2400. template can reference {finish_photo_url}, which is what opts
  2401. the email into inlining the photo (see send_user_print_notification).
  2402. """
  2403. if created_by_id is None:
  2404. logger.debug("[EMAIL] Skipping user print email (%s): no created_by_id", event_type)
  2405. return
  2406. try:
  2407. # Check if advanced auth is enabled - required for user email notifications
  2408. from backend.app.models.settings import Settings
  2409. result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
  2410. setting = result.scalar_one_or_none()
  2411. if not setting or setting.value.lower() != "true":
  2412. logger.debug("[EMAIL] Skipping user print email (%s): advanced_auth not enabled", event_type)
  2413. return
  2414. # Check if user notifications are enabled (admin-controlled toggle)
  2415. notif_enabled_result = await db.execute(
  2416. select(Settings).where(Settings.key == "user_notifications_enabled")
  2417. )
  2418. notif_enabled_setting = notif_enabled_result.scalar_one_or_none()
  2419. if notif_enabled_setting and notif_enabled_setting.value.lower() == "false":
  2420. logger.debug("[EMAIL] Skipping user print email (%s): user_notifications_enabled is false", event_type)
  2421. return
  2422. # Check SMTP settings are configured - required for sending emails
  2423. from backend.app.services.email_service import get_smtp_settings, send_user_print_notification
  2424. smtp_settings = await get_smtp_settings(db)
  2425. if not smtp_settings:
  2426. logger.debug("[EMAIL] Skipping user print email (%s): SMTP settings not configured", event_type)
  2427. return
  2428. # Load user preferences
  2429. from backend.app.models.user import User
  2430. from backend.app.models.user_email_pref import UserEmailPreference
  2431. user_result = await db.execute(select(User).where(User.id == created_by_id))
  2432. user = user_result.scalar_one_or_none()
  2433. if user is None or not user.email:
  2434. logger.debug(
  2435. "[EMAIL] Skipping user print email (%s): user %s not found or has no email address",
  2436. event_type,
  2437. created_by_id,
  2438. )
  2439. return
  2440. # Load user's notification preferences
  2441. pref_result = await db.execute(
  2442. select(UserEmailPreference).where(UserEmailPreference.user_id == created_by_id)
  2443. )
  2444. pref = pref_result.scalar_one_or_none()
  2445. # Determine if this event type should be sent
  2446. should_send = False
  2447. if event_type == "user_print_start":
  2448. should_send = pref is None or pref.notify_print_start
  2449. elif event_type == "user_print_complete":
  2450. should_send = pref is None or pref.notify_print_complete
  2451. elif event_type == "user_print_failed":
  2452. should_send = pref is None or pref.notify_print_failed
  2453. elif event_type == "user_print_stopped":
  2454. should_send = pref is None or pref.notify_print_stopped
  2455. if not should_send:
  2456. logger.debug(
  2457. "[EMAIL] Skipping user print email (%s): user %s has notifications disabled for this event",
  2458. event_type,
  2459. created_by_id,
  2460. )
  2461. return
  2462. logger.info(
  2463. "[EMAIL] Sending user print email: event=%s, user=%s (%s), printer=%s, file=%s",
  2464. event_type,
  2465. user.username,
  2466. user.email,
  2467. printer_name,
  2468. filename,
  2469. )
  2470. # Build variables
  2471. variables = {
  2472. "printer": printer_name,
  2473. "filename": self._clean_filename(filename),
  2474. }
  2475. if finish_photo_url:
  2476. variables["finish_photo_url"] = finish_photo_url
  2477. # Send the email
  2478. await send_user_print_notification(
  2479. db=db,
  2480. event_type=event_type,
  2481. user_email=user.email,
  2482. username=user.username,
  2483. variables=variables,
  2484. image_data=image_data,
  2485. )
  2486. logger.info("[EMAIL] User print email sent: event=%s → %s", event_type, user.email)
  2487. except Exception as e:
  2488. logger.warning("Failed to send user print email notification: %s", e, exc_info=True)
  2489. # ==================== Queue Notifications ====================
  2490. async def on_queue_job_added(
  2491. self,
  2492. job_name: str,
  2493. target: str,
  2494. db: AsyncSession,
  2495. printer_id: int | None = None,
  2496. printer_name: str | None = None,
  2497. ):
  2498. """Handle queue job added event."""
  2499. providers = await self._get_providers_for_event(db, "on_queue_job_added", printer_id)
  2500. if not providers:
  2501. return
  2502. variables = {
  2503. "job_name": job_name,
  2504. "target": target, # e.g., "Printer1" or "Any X1C"
  2505. "printer": printer_name or target,
  2506. }
  2507. title, message = await self._build_message_from_template(db, "queue_job_added", variables)
  2508. await self._send_to_providers(
  2509. providers, title, message, db, "queue_job_added", printer_id, printer_name, variables=variables
  2510. )
  2511. async def on_queue_job_assigned(
  2512. self,
  2513. job_name: str,
  2514. printer_id: int,
  2515. printer_name: str,
  2516. target_model: str,
  2517. db: AsyncSession,
  2518. ):
  2519. """Handle model-based job assigned to printer event."""
  2520. providers = await self._get_providers_for_event(db, "on_queue_job_assigned", printer_id)
  2521. if not providers:
  2522. return
  2523. variables = {
  2524. "job_name": job_name,
  2525. "printer": printer_name,
  2526. "target_model": target_model,
  2527. }
  2528. title, message = await self._build_message_from_template(db, "queue_job_assigned", variables)
  2529. await self._send_to_providers(
  2530. providers, title, message, db, "queue_job_assigned", printer_id, printer_name, variables=variables
  2531. )
  2532. async def on_queue_job_started(
  2533. self,
  2534. job_name: str,
  2535. printer_id: int,
  2536. printer_name: str,
  2537. db: AsyncSession,
  2538. estimated_time: int | None = None,
  2539. ):
  2540. """Handle queue job started printing event."""
  2541. providers = await self._get_providers_for_event(db, "on_queue_job_started", printer_id)
  2542. if not providers:
  2543. return
  2544. eta_str = await self._format_eta(estimated_time, db)
  2545. variables = {
  2546. "job_name": job_name,
  2547. "printer": printer_name,
  2548. "estimated_time": self._format_duration(estimated_time),
  2549. "eta": eta_str,
  2550. }
  2551. title, message = await self._build_message_from_template(db, "queue_job_started", variables)
  2552. await self._send_to_providers(
  2553. providers, title, message, db, "queue_job_started", printer_id, printer_name, variables=variables
  2554. )
  2555. async def on_queue_job_waiting(
  2556. self,
  2557. job_name: str,
  2558. target_model: str,
  2559. waiting_reason: str,
  2560. db: AsyncSession,
  2561. printer_id: int | None = None,
  2562. ):
  2563. """Handle job waiting for filament event.
  2564. ``printer_id`` is the printer a pinned job waits on; None for a job for
  2565. "Any <model>" that no printer holds.
  2566. """
  2567. providers = await self._get_providers_for_event(db, "on_queue_job_waiting", printer_id)
  2568. if not providers:
  2569. return
  2570. variables = {
  2571. "job_name": job_name,
  2572. "target_model": target_model,
  2573. "waiting_reason": waiting_reason,
  2574. }
  2575. title, message = await self._build_message_from_template(db, "queue_job_waiting", variables)
  2576. await self._send_to_providers(providers, title, message, db, "queue_job_waiting", variables=variables)
  2577. async def on_queue_job_skipped(
  2578. self,
  2579. job_name: str,
  2580. printer_id: int,
  2581. printer_name: str,
  2582. reason: str,
  2583. db: AsyncSession,
  2584. ):
  2585. """Handle job skipped event (e.g., previous print failed)."""
  2586. providers = await self._get_providers_for_event(db, "on_queue_job_skipped", printer_id)
  2587. if not providers:
  2588. return
  2589. variables = {
  2590. "job_name": job_name,
  2591. "printer": printer_name,
  2592. "reason": reason,
  2593. }
  2594. title, message = await self._build_message_from_template(db, "queue_job_skipped", variables)
  2595. await self._send_to_providers(
  2596. providers, title, message, db, "queue_job_skipped", printer_id, printer_name, variables=variables
  2597. )
  2598. async def on_queue_job_failed(
  2599. self,
  2600. job_name: str,
  2601. printer_id: int | None,
  2602. printer_name: str | None,
  2603. reason: str,
  2604. db: AsyncSession,
  2605. ):
  2606. """Handle job failed to start event (upload error, etc.)."""
  2607. providers = await self._get_providers_for_event(db, "on_queue_job_failed", printer_id)
  2608. if not providers:
  2609. return
  2610. variables = {
  2611. "job_name": job_name,
  2612. "printer": printer_name or "Unknown",
  2613. "reason": reason,
  2614. }
  2615. title, message = await self._build_message_from_template(db, "queue_job_failed", variables)
  2616. await self._send_to_providers(
  2617. providers, title, message, db, "queue_job_failed", printer_id, printer_name, variables=variables
  2618. )
  2619. async def on_queue_completed(
  2620. self,
  2621. completed_count: int,
  2622. db: AsyncSession,
  2623. ):
  2624. """Handle all queue jobs completed event."""
  2625. providers = await self._get_providers_for_event(db, "on_queue_completed", fleet_wide=True)
  2626. if not providers:
  2627. return
  2628. variables = {
  2629. "completed_count": str(completed_count),
  2630. }
  2631. title, message = await self._build_message_from_template(db, "queue_completed", variables)
  2632. await self._send_to_providers(providers, title, message, db, "queue_completed", variables=variables)
  2633. # ==================== Inventory Stock Alerts ====================
  2634. async def on_stock_reorder_alert(
  2635. self,
  2636. material: str,
  2637. brand: str | None,
  2638. stock_g: float,
  2639. rate_g_day: float,
  2640. days_left: int,
  2641. db: AsyncSession,
  2642. *,
  2643. subtype: str | None = None,
  2644. color: str | None = None,
  2645. skip_break_subscribers: bool = False,
  2646. ):
  2647. """Fire when an inventory SKU reaches its reorder point.
  2648. ``subtype`` and ``color`` are what tell apart the messages for two colours of one
  2649. product, which the forecast (grouped by colour as well) reports separately.
  2650. A SKU in stock break has also reached its reorder point. ``skip_break_subscribers``
  2651. leaves out the providers that have the break alert on, so the producer can send
  2652. the reorder event for a break SKU without telling those providers twice.
  2653. """
  2654. providers = await self._get_providers_for_event(db, "on_stock_reorder_alert", fleet_wide=True)
  2655. if skip_break_subscribers:
  2656. providers = [p for p in providers if not p.on_stock_break_alert]
  2657. if not providers:
  2658. return
  2659. variables = {
  2660. "material": material,
  2661. "subtype": subtype or "",
  2662. "brand": brand or "",
  2663. "color": color or "",
  2664. "stock_g": f"{stock_g:.0f}",
  2665. "rate_g_day": f"{rate_g_day:.1f}",
  2666. "days_left": str(days_left),
  2667. }
  2668. title, message = await self._build_message_from_template(db, "stock_reorder_alert", variables)
  2669. await self._send_to_providers(providers, title, message, db, "stock_reorder_alert", variables=variables)
  2670. async def on_stock_break_alert(
  2671. self,
  2672. material: str,
  2673. brand: str | None,
  2674. stock_g: float,
  2675. rate_g_day: float,
  2676. days_left: int,
  2677. lead_time_days: int,
  2678. db: AsyncSession,
  2679. *,
  2680. subtype: str | None = None,
  2681. color: str | None = None,
  2682. ):
  2683. """Fire when a stock break is detected (stock runs out before lead time).
  2684. ``subtype`` and ``color`` as for :meth:`on_stock_reorder_alert`.
  2685. """
  2686. providers = await self._get_providers_for_event(db, "on_stock_break_alert", fleet_wide=True)
  2687. if not providers:
  2688. return
  2689. variables = {
  2690. "material": material,
  2691. "subtype": subtype or "",
  2692. "brand": brand or "",
  2693. "color": color or "",
  2694. "stock_g": f"{stock_g:.0f}",
  2695. "rate_g_day": f"{rate_g_day:.1f}",
  2696. "days_left": str(days_left),
  2697. "lead_time_days": str(lead_time_days),
  2698. }
  2699. title, message = await self._build_message_from_template(db, "stock_break_alert", variables)
  2700. await self._send_to_providers(providers, title, message, db, "stock_break_alert", variables=variables)
  2701. async def _queue_for_digest(
  2702. self,
  2703. provider: NotificationProvider,
  2704. event_type: str,
  2705. title: str,
  2706. message: str,
  2707. db: AsyncSession,
  2708. printer_id: int | None = None,
  2709. printer_name: str | None = None,
  2710. ):
  2711. """Queue a notification for later delivery in the daily digest."""
  2712. try:
  2713. queue_entry = NotificationDigestQueue(
  2714. provider_id=provider.id,
  2715. event_type=event_type,
  2716. title=title,
  2717. message=message,
  2718. printer_id=printer_id,
  2719. printer_name=printer_name,
  2720. )
  2721. db.add(queue_entry)
  2722. await db.commit()
  2723. logger.info("Queued notification for digest: %s for provider %s", event_type, provider.name)
  2724. except Exception as e:
  2725. logger.warning("Failed to queue notification for digest: %s", e)
  2726. async def send_digest(self, provider_id: int):
  2727. """Send all queued notifications as a single digest for a provider."""
  2728. from backend.app.core.database import async_session
  2729. async with async_session() as db:
  2730. # Get the provider
  2731. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  2732. provider = result.scalar_one_or_none()
  2733. if not provider or not provider.enabled:
  2734. return
  2735. # Get all queued notifications for this provider
  2736. result = await db.execute(
  2737. select(NotificationDigestQueue)
  2738. .where(NotificationDigestQueue.provider_id == provider_id)
  2739. .order_by(NotificationDigestQueue.created_at)
  2740. )
  2741. queue_entries = list(result.scalars().all())
  2742. # Events kept after a failed digest get the next one, not every
  2743. # one after it: a provider whose digest always fails (revoked
  2744. # credentials, a busy day too long for Pushover) would otherwise
  2745. # grow its queue without end. Before FO-C4 a failed day was
  2746. # simply deleted.
  2747. cutoff = datetime.utcnow() - timedelta(hours=DIGEST_RETRY_HOURS)
  2748. expired = [e for e in queue_entries if e.created_at is not None and e.created_at < cutoff]
  2749. for entry in expired:
  2750. await db.delete(entry)
  2751. queue_entries = [e for e in queue_entries if e not in expired]
  2752. if not queue_entries:
  2753. await db.commit()
  2754. logger.debug("No queued notifications for provider %s", provider.name)
  2755. return
  2756. # Build digest message
  2757. title = f"Daily Digest - {len(queue_entries)} Events"
  2758. # Group by event type
  2759. events_by_type: dict[str, list] = {}
  2760. for entry in queue_entries:
  2761. if entry.event_type not in events_by_type:
  2762. events_by_type[entry.event_type] = []
  2763. events_by_type[entry.event_type].append(entry)
  2764. # Format the digest body
  2765. body_parts = []
  2766. for event_type, entries in events_by_type.items():
  2767. event_label = event_type.replace("_", " ").title()
  2768. body_parts.append(f"== {event_label} ({len(entries)}) ==")
  2769. for entry in entries:
  2770. # From an earlier day (late yesterday, or kept from a
  2771. # failed digest): say which day.
  2772. if entry.created_at.date() != datetime.utcnow().date():
  2773. time_str = entry.created_at.strftime("%m-%d %H:%M")
  2774. else:
  2775. time_str = entry.created_at.strftime("%H:%M")
  2776. printer_info = f"[{entry.printer_name}] " if entry.printer_name else ""
  2777. body_parts.append(f" {time_str} {printer_info}{entry.title}")
  2778. body_parts.append("")
  2779. body = "\n".join(body_parts)
  2780. # Send the digest. At its own time even inside quiet hours: skipped
  2781. # there, it was logged as sent and its events deleted (FO-C4).
  2782. success, error = await self._send_to_provider(provider, title, body, db, bypass_quiet_hours=True)
  2783. # Log the digest
  2784. await self._log_notification(
  2785. db=db,
  2786. provider_id=provider.id,
  2787. event_type="daily_digest",
  2788. title=title,
  2789. message=body,
  2790. success=success,
  2791. error_message=error if not success else None,
  2792. )
  2793. # Clear the queue once it went out; a failed send keeps the events
  2794. # for the next digest instead of losing them (FO-C4).
  2795. if success:
  2796. for entry in queue_entries:
  2797. await db.delete(entry)
  2798. await db.commit()
  2799. if success:
  2800. logger.info("Sent daily digest with %s events to %s", len(queue_entries), provider.name)
  2801. else:
  2802. logger.warning("Failed to send daily digest to %s: %s", provider.name, error)
  2803. async def check_and_send_digests(self):
  2804. """Check all providers and send digests if it's their scheduled time."""
  2805. from backend.app.core.database import async_session
  2806. current_time = datetime.now().strftime("%H:%M")
  2807. # Avoid duplicate checks within the same minute
  2808. if current_time == self._last_digest_check:
  2809. return
  2810. self._last_digest_check = current_time
  2811. async with async_session() as db:
  2812. # Find all providers with digest enabled at this time
  2813. result = await db.execute(
  2814. select(NotificationProvider).where(
  2815. NotificationProvider.enabled.is_(True),
  2816. NotificationProvider.daily_digest_enabled.is_(True),
  2817. NotificationProvider.daily_digest_time == current_time,
  2818. )
  2819. )
  2820. providers = result.scalars().all()
  2821. for provider in providers:
  2822. try:
  2823. await self.send_digest(provider.id)
  2824. except Exception as e:
  2825. logger.error("Error sending digest for provider %s: %s", provider.id, e)
  2826. def start_digest_scheduler(self):
  2827. """Start the background scheduler for daily digest notifications."""
  2828. if self._digest_scheduler_task is None:
  2829. self._digest_scheduler_task = asyncio.create_task(self._digest_scheduler_loop())
  2830. logger.info("Notification digest scheduler started")
  2831. def stop_digest_scheduler(self):
  2832. """Stop the background scheduler for daily digests."""
  2833. if self._digest_scheduler_task:
  2834. self._digest_scheduler_task.cancel()
  2835. self._digest_scheduler_task = None
  2836. logger.info("Notification digest scheduler stopped")
  2837. async def _digest_scheduler_loop(self):
  2838. """Background loop that checks for scheduled digests every minute."""
  2839. while True:
  2840. try:
  2841. await self.check_and_send_digests()
  2842. except Exception as e:
  2843. logger.error("Error in digest scheduler: %s", e)
  2844. # Wait until the next minute
  2845. await asyncio.sleep(60)
  2846. # Global instance
  2847. notification_service = NotificationService()