notification_service.py 96 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438
  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 datetime import datetime, timedelta, timezone
  9. from email.mime.image import MIMEImage
  10. from email.mime.multipart import MIMEMultipart
  11. from email.mime.text import MIMEText
  12. from typing import Any
  13. from urllib.parse import quote
  14. import httpx
  15. from sqlalchemy import select
  16. from sqlalchemy.ext.asyncio import AsyncSession
  17. from backend.app.models.notification import NotificationDigestQueue, NotificationLog, NotificationProvider
  18. from backend.app.models.notification_template import NotificationTemplate
  19. logger = logging.getLogger(__name__)
  20. # Honest User-Agent — matches the convention used by every other outbound
  21. # httpx client in the codebase (bambu_cloud, makerworld, firmware_check,
  22. # inventory). Previously this client leaked python-httpx/<version>, which
  23. # was both inconsistent with the rest of the project and a more obvious
  24. # bot signature for upstream WAFs.
  25. _USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
  26. def _looks_like_cloudflare_challenge(response: httpx.Response) -> bool:
  27. """Return True if ``response`` looks like a Cloudflare mitigation
  28. interstitial (JS challenge / managed challenge / block page) rather
  29. than a legitimate response passed through Cloudflare.
  30. Self-hosted servers behind Cloudflare (Tunnel, "Bot Fight Mode", or
  31. "Under Attack" mode) intercept non-browser clients at the edge and
  32. return a challenge HTML page instead of forwarding to the origin —
  33. so we never reach the user's actual ntfy / webhook backend.
  34. Cloudflare cannot be defeated from a Python client; the user has to
  35. add a security-skip rule on their side. We detect the shape so the
  36. UI can tell them that, instead of dumping the raw HTML.
  37. Detection deliberately does NOT rely on ``Server: cloudflare`` alone
  38. — Cloudflare adds that header to every response it proxies (success
  39. AND legitimate origin errors), so a real 401 "wrong token" from a
  40. CF-fronted ntfy would false-positive into a misleading "your CF is
  41. blocking" message. Reliable signals: the ``cf-mitigated`` header
  42. (set only when CF actively mitigates) and the challenge body shape.
  43. """
  44. if response.headers.get("cf-mitigated"):
  45. return True
  46. content_type = (response.headers.get("content-type") or "").lower()
  47. if "html" not in content_type:
  48. return False
  49. body = (response.text or "")[:1024].lower()
  50. # "Just a moment..." is Cloudflare's universal challenge-page title
  51. # (managed challenge, JS challenge, Under Attack mode). Combined with
  52. # an HTML content-type this is unambiguous — no legitimate ntfy or
  53. # webhook backend returns HTML with that title. ``cf-chl-*`` and
  54. # ``challenge-platform`` cover newer / non-default CF templates.
  55. return "just a moment" in body or "cf-chl-bypass" in body or "cf-chl-opt" in body or "challenge-platform" in body
  56. def _assert_safe_provider_url(url: str, *, label: str) -> str | None:
  57. """Validate a provider URL taken from user-supplied config.
  58. Returns an error message on rejection, or None when the URL is
  59. acceptable — the ``_send_*`` methods return ``tuple[bool, str]`` rather
  60. than raising, so a message is more useful here than an exception.
  61. Uses the LAN-service policy: self-hosting ntfy, Bark, Gotify or a webhook
  62. receiver on the home LAN is normal and must keep working, so loopback and
  63. RFC-1918 stay permitted. Cloud-metadata endpoints, numeric-encoded IPs and
  64. non-HTTP schemes are rejected.
  65. """
  66. from backend.app.api.routes._url_safety import assert_safe_lan_service_url
  67. try:
  68. assert_safe_lan_service_url(url, label=label)
  69. except ValueError as exc:
  70. return str(exc)
  71. return None
  72. def _opaque_http_failure(response: httpx.Response, *, label: str) -> str:
  73. """Failure message for a provider whose destination host the user supplies.
  74. The response body is deliberately **not** returned to the caller. Provider
  75. URLs are configurable by anyone holding ``NOTIFICATIONS_CREATE`` — which
  76. the default Operators group carries and which does not imply
  77. ``SETTINGS_UPDATE`` — and ``POST /notifications/test-config`` accepts a URL
  78. straight from the request body without persisting anything. Echoing the
  79. response body there turned an intended "does my webhook work?" check into
  80. an authenticated read primitive against any host the Bambuddy process can
  81. reach, including services that are not exposed to the network at all.
  82. Providers whose host Bambuddy hardcodes (Pushover, Telegram, CallMeBot)
  83. keep returning the upstream body — there is no trust boundary to cross
  84. when the destination cannot be influenced.
  85. The body is logged at debug level, where it stays available to whoever
  86. already administers the host without being handed back over the API.
  87. """
  88. logger.debug(
  89. "%s delivery failed with HTTP %s; body: %s",
  90. label,
  91. response.status_code,
  92. (response.text or "")[:200],
  93. )
  94. return f"HTTP {response.status_code} from the configured {label} (see server logs at debug level for details)"
  95. class NotificationService:
  96. """Service for sending notifications through various providers."""
  97. def __init__(self):
  98. self._http_client: httpx.AsyncClient | None = None
  99. self._template_cache: dict[str, NotificationTemplate] = {}
  100. self._digest_scheduler_task: asyncio.Task | None = None
  101. self._last_digest_check: str = "" # "HH:MM" to avoid duplicate checks
  102. async def _get_client(self) -> httpx.AsyncClient:
  103. """Get or create HTTP client.
  104. The connect timeout is deliberately far shorter than the rest. A flat
  105. 30 s meant that when a site's internet went down, every alarm spent a
  106. full 30 s inside ``connect`` — longer than SQLite's 15 s
  107. ``busy_timeout`` — and any other task that wanted to write during that
  108. window failed with "database is locked" (#2770). Reaching a host either
  109. works in a couple of seconds or is not going to; sending the body is the
  110. part that legitimately takes time, so read/write keep the old 30 s and
  111. an image upload on a slow uplink is unaffected.
  112. """
  113. if self._http_client is None or self._http_client.is_closed:
  114. self._http_client = httpx.AsyncClient(
  115. timeout=httpx.Timeout(30.0, connect=5.0),
  116. headers={"User-Agent": _USER_AGENT},
  117. )
  118. return self._http_client
  119. async def close(self):
  120. """Close HTTP client."""
  121. if self._http_client and not self._http_client.is_closed:
  122. await self._http_client.aclose()
  123. def _is_in_quiet_hours(self, provider: NotificationProvider) -> bool:
  124. """Check if current time is within provider's quiet hours."""
  125. if not provider.quiet_hours_enabled:
  126. return False
  127. if not provider.quiet_hours_start or not provider.quiet_hours_end:
  128. return False
  129. try:
  130. now = datetime.now()
  131. current_time = now.hour * 60 + now.minute
  132. start_parts = provider.quiet_hours_start.split(":")
  133. end_parts = provider.quiet_hours_end.split(":")
  134. start_minutes = int(start_parts[0]) * 60 + int(start_parts[1])
  135. end_minutes = int(end_parts[0]) * 60 + int(end_parts[1])
  136. # Handle overnight quiet hours (e.g., 22:00 to 07:00)
  137. if start_minutes > end_minutes:
  138. # Quiet hours span midnight
  139. return current_time >= start_minutes or current_time < end_minutes
  140. else:
  141. # Same day quiet hours
  142. return start_minutes <= current_time < end_minutes
  143. except (ValueError, TypeError, AttributeError):
  144. logger.warning("Invalid quiet hours format for provider %s", provider.name)
  145. return False
  146. async def _get_template(self, db: AsyncSession, event_type: str) -> NotificationTemplate | None:
  147. """Get a notification template by event type.
  148. ``no_autoflush`` for the same reason as ``_get_providers_for_event``:
  149. this read runs before the provider is contacted, and must not be the
  150. thing that opens a write transaction on the caller's session (#2770).
  151. """
  152. # Check cache first
  153. if event_type in self._template_cache:
  154. return self._template_cache[event_type]
  155. with db.no_autoflush:
  156. result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.event_type == event_type))
  157. template = result.scalar_one_or_none()
  158. if template:
  159. self._template_cache[event_type] = template
  160. return template
  161. def _render_template(self, template_str: str, variables: dict[str, Any]) -> str:
  162. """Render a template string with variables. Missing variables become empty."""
  163. result = template_str
  164. for key, value in variables.items():
  165. result = result.replace("{" + key + "}", str(value) if value is not None else "")
  166. # Remove any remaining unreplaced placeholders
  167. result = re.sub(r"\{[a-z_]+\}", "", result)
  168. return result
  169. async def _format_eta(self, seconds: int | None, db: AsyncSession) -> str:
  170. """Format ETA as wall-clock time, respecting user's time_format setting."""
  171. if not seconds or seconds <= 0:
  172. return "Unknown"
  173. from backend.app.api.routes.settings import get_setting
  174. eta_time = datetime.now() + timedelta(seconds=seconds)
  175. time_format = await get_setting(db, "time_format")
  176. if time_format == "12h":
  177. return eta_time.strftime("%I:%M %p").lstrip("0")
  178. # Default to 24h for "24h", "system", or unset
  179. return eta_time.strftime("%H:%M")
  180. def _format_duration(self, seconds: int | None) -> str:
  181. """Format duration in seconds to human-readable string."""
  182. if seconds is None:
  183. return "Unknown"
  184. hours = seconds // 3600
  185. minutes = (seconds % 3600) // 60
  186. if hours > 0:
  187. return f"{hours}h {minutes}m"
  188. return f"{minutes}m"
  189. def _clean_filename(self, filename: str) -> str:
  190. """Extract filename and remove file extensions."""
  191. import os
  192. # Strip path prefix (e.g., /data/Metadata/plate_5.gcode -> plate_5.gcode)
  193. filename = os.path.basename(filename)
  194. # Remove common extensions
  195. if filename.endswith(".gcode.3mf"):
  196. return filename[:-10]
  197. elif filename.endswith(".gcode"):
  198. return filename[:-6]
  199. elif filename.endswith(".3mf"):
  200. return filename[:-4]
  201. return filename
  202. async def _build_message_from_template(
  203. self, db: AsyncSession, event_type: str, variables: dict[str, Any]
  204. ) -> tuple[str, str]:
  205. """Build notification title and body from template."""
  206. # Add common variables
  207. variables["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M")
  208. variables["app_name"] = "Bambuddy"
  209. template = await self._get_template(db, event_type)
  210. if not template:
  211. # Fallback to simple message
  212. logger.warning("Template not found for event type: %s", event_type)
  213. return event_type.replace("_", " ").title(), str(variables)
  214. title = self._render_template(template.title_template, variables)
  215. body = self._render_template(template.body_template, variables)
  216. return title, body
  217. async def send_test_notification(
  218. self, provider_type: str, config: dict[str, Any], db: AsyncSession | None = None
  219. ) -> tuple[bool, str]:
  220. """Send a test notification to verify configuration."""
  221. if db:
  222. title, message = await self._build_message_from_template(db, "test", {})
  223. else:
  224. title = "Bambuddy Test"
  225. message = "This is a test notification. If you see this, notifications are working!"
  226. try:
  227. if provider_type == "callmebot":
  228. return await self._send_callmebot(config, f"{title}\n{message}")
  229. elif provider_type == "ntfy":
  230. return await self._send_ntfy(config, title, message)
  231. elif provider_type == "pushover":
  232. return await self._send_pushover(config, title, message)
  233. elif provider_type == "telegram":
  234. return await self._send_telegram(config, f"*{title}*\n{message}")
  235. elif provider_type == "email":
  236. return await self._send_email(config, title, message)
  237. elif provider_type == "discord":
  238. return await self._send_discord(config, title, message)
  239. elif provider_type == "webhook":
  240. return await self._send_webhook(config, title, message)
  241. elif provider_type == "homeassistant":
  242. return await self._send_homeassistant(config, title, message, db=db)
  243. elif provider_type == "bark":
  244. return await self._send_bark(config, title, message)
  245. else:
  246. return False, f"Unknown provider type: {provider_type}"
  247. except Exception as e:
  248. logger.exception("Error sending test notification via %s", provider_type)
  249. return False, str(e)
  250. async def _send_callmebot(self, config: dict, message: str) -> tuple[bool, str]:
  251. """Send notification via CallMeBot (WhatsApp)."""
  252. phone = config.get("phone", "").strip()
  253. apikey = config.get("apikey", "").strip()
  254. if not phone or not apikey:
  255. return False, "Phone number and API key are required"
  256. # URL encode the message
  257. encoded_message = quote(message)
  258. url = f"https://api.callmebot.com/whatsapp.php?phone={phone}&text={encoded_message}&apikey={apikey}"
  259. client = await self._get_client()
  260. response = await client.get(url)
  261. if response.status_code == 200:
  262. return True, "Message sent successfully"
  263. else:
  264. return False, f"HTTP {response.status_code}: {response.text[:200]}"
  265. async def _send_bark(self, config: dict, title: str, message: str) -> tuple[bool, str]:
  266. """Send notification via Bark, the self-hostable iOS push service (#1495).
  267. POSTs JSON to {server}/push. Defaults to the official api.day.app
  268. relay; a self-hosted bark-server works by overriding the server URL.
  269. """
  270. server = (config.get("server") or "https://api.day.app").strip().rstrip("/")
  271. device_key = (config.get("device_key") or "").strip()
  272. if not device_key:
  273. return False, "Device key is required"
  274. url_error = _assert_safe_provider_url(server, label="Bark server URL")
  275. if url_error:
  276. return False, url_error
  277. payload: dict[str, Any] = {
  278. "device_key": device_key,
  279. "title": title,
  280. "body": message,
  281. }
  282. group = (config.get("group") or "").strip()
  283. if group:
  284. payload["group"] = group
  285. sound = (config.get("sound") or "").strip()
  286. if sound:
  287. payload["sound"] = sound
  288. level = (config.get("level") or "").strip()
  289. if level in ("active", "timeSensitive", "critical", "passive"):
  290. payload["level"] = level
  291. client = await self._get_client()
  292. response = await client.post(f"{server}/push", json=payload)
  293. if response.status_code == 200:
  294. # bark-server can report failures inside an HTTP 200 body
  295. # ({"code": 400, "message": ...}), so the status alone isn't proof.
  296. try:
  297. body = response.json()
  298. except ValueError:
  299. body = None
  300. if isinstance(body, dict) and body.get("code") not in (200, None):
  301. # Only the numeric code is echoed. A server chosen by the caller
  302. # controls this body too, so the free-text message is a (narrow)
  303. # read channel of the same kind _opaque_http_failure closes.
  304. logger.debug("Bark reported error %s: %s", body.get("code"), str(body.get("message"))[:200])
  305. return False, f"Bark error {body.get('code')} (see server logs at debug level for details)"
  306. return True, "Message sent successfully"
  307. return False, _opaque_http_failure(response, label="Bark server")
  308. async def _send_ntfy(
  309. self,
  310. config: dict,
  311. title: str,
  312. message: str,
  313. image_data: bytes | None = None,
  314. event_type: str | None = None,
  315. ) -> tuple[bool, str]:
  316. """Send notification via ntfy."""
  317. server = config.get("server", "https://ntfy.sh").rstrip("/")
  318. topic = config.get("topic", "").strip()
  319. auth_token = config.get("auth_token", "").strip()
  320. if not topic:
  321. return False, "Topic is required"
  322. url_error = _assert_safe_provider_url(server, label="ntfy server URL")
  323. if url_error:
  324. return False, url_error
  325. url = f"{server}/{topic}"
  326. # ntfy reads Title/Message from HTTP headers. httpx enforces ASCII
  327. # for str header values, but printer names and filenames can contain
  328. # non-ASCII characters (e.g. accented letters, CJK). Passing bytes
  329. # bypasses the ASCII check — ntfy handles UTF-8 headers correctly.
  330. headers: dict[str, str | bytes] = {"Title": title.encode("utf-8")}
  331. # Per-event Priority header (#990). Only set when the user has
  332. # explicitly mapped this event to a 1-5 value; otherwise fall through
  333. # to the ntfy server's default so existing setups stay unchanged.
  334. #
  335. # The map is keyed by the provider's toggle column ("on_print_failed"),
  336. # because that is what the dialog builds its rows from -- but every
  337. # sender is called with the bare event name ("print_failed"), so the
  338. # lookup used to miss for every real notification and hit only in tests
  339. # that called this method with the prefixed name (issue #3139). Both
  340. # spellings are accepted, which also leaves stored configs untouched.
  341. event_priorities = config.get("event_priorities") or {}
  342. if event_type and isinstance(event_priorities, dict):
  343. raw = event_priorities.get(event_type)
  344. if raw is None and not event_type.startswith("on_"):
  345. raw = event_priorities.get(f"on_{event_type}")
  346. try:
  347. priority = int(raw) if raw is not None else None
  348. except (TypeError, ValueError):
  349. priority = None
  350. if priority is not None and 1 <= priority <= 5:
  351. headers["Priority"] = str(priority)
  352. if auth_token:
  353. headers["Authorization"] = f"Bearer {auth_token}"
  354. client = await self._get_client()
  355. if image_data:
  356. # ntfy supports image attachments via multipart form-data.
  357. # HTTP headers cannot contain newlines, but ntfy interprets
  358. # literal \n (backslash-n) as newlines in the Message header.
  359. headers["Filename"] = "photo.jpg"
  360. headers["Message"] = message.replace("\n", "\\n").encode("utf-8")
  361. response = await client.put(url, content=image_data, headers=headers)
  362. if response.status_code == 400 and "attachments not allowed" in response.text:
  363. # Server has attachments disabled — retry without the image
  364. headers.pop("Filename", None)
  365. headers.pop("Message", None)
  366. response = await client.post(url, content=message.encode("utf-8"), headers=headers)
  367. else:
  368. response = await client.post(url, content=message.encode("utf-8"), headers=headers)
  369. if response.status_code in (200, 204):
  370. return True, "Message sent successfully"
  371. if _looks_like_cloudflare_challenge(response):
  372. return False, (
  373. f"HTTP {response.status_code} — ntfy server is behind a Cloudflare "
  374. "challenge. Bambuddy was served the JS challenge page instead of "
  375. "reaching ntfy. Cloudflare cannot be solved from a backend; add a "
  376. "Cloudflare security-skip rule for this hostname, disable Bot "
  377. "Fight Mode, or front the server with Cloudflare Access using a "
  378. "service token. (#1534)"
  379. )
  380. return False, _opaque_http_failure(response, label="ntfy server")
  381. async def _send_pushover(
  382. self, config: dict, title: str, message: str, image_data: bytes | None = None
  383. ) -> tuple[bool, str]:
  384. """Send notification via Pushover.
  385. Args:
  386. config: Provider configuration with user_key, app_token, priority
  387. title: Notification title
  388. message: Notification body
  389. image_data: Optional JPEG image bytes to attach (max 2.5MB)
  390. """
  391. user_key = config.get("user_key", "").strip()
  392. app_token = config.get("app_token", "").strip()
  393. try:
  394. priority = int(config.get("priority", 0))
  395. except (TypeError, ValueError):
  396. priority = 0
  397. if not user_key or not app_token:
  398. return False, "User key and app token are required"
  399. url = "https://api.pushover.net/1/messages.json"
  400. data = {
  401. "token": app_token,
  402. "user": user_key,
  403. "title": title,
  404. "message": message,
  405. "priority": priority,
  406. }
  407. # Emergency priority (2) keeps re-alerting until acknowledged, so
  408. # Pushover *requires* retry (how often, >= 30s) and expire (when to
  409. # give up, <= 10800s). Without them the API rejects the message. Only
  410. # send them at priority 2 — Pushover ignores them at other priorities.
  411. if priority == 2:
  412. try:
  413. retry = int(config.get("retry", 60))
  414. except (TypeError, ValueError):
  415. retry = 60
  416. try:
  417. expire = int(config.get("expire", 3600))
  418. except (TypeError, ValueError):
  419. expire = 3600
  420. data["retry"] = max(30, min(retry, 10800))
  421. data["expire"] = max(30, min(expire, 10800))
  422. client = await self._get_client()
  423. if image_data:
  424. # Pushover supports image attachments via multipart form-data
  425. files = {"attachment": ("photo.jpg", image_data, "image/jpeg")}
  426. response = await client.post(url, data=data, files=files)
  427. else:
  428. response = await client.post(url, data=data)
  429. if response.status_code == 200:
  430. return True, "Message sent successfully"
  431. else:
  432. try:
  433. error_data = response.json()
  434. errors = error_data.get("errors", [])
  435. return False, f"Pushover error: {', '.join(errors)}"
  436. except Exception:
  437. return False, f"HTTP {response.status_code}: {response.text[:200]}"
  438. async def _send_telegram(self, config: dict, message: str, image_data: bytes | None = None) -> tuple[bool, str]:
  439. """Send notification via Telegram bot."""
  440. bot_token = config.get("bot_token", "").strip()
  441. chat_id = config.get("chat_id", "").strip()
  442. if not bot_token or not chat_id:
  443. return False, "Bot token and chat ID are required"
  444. # Optional forum topic (#1518). Telegram expects message_thread_id as an
  445. # integer in the JSON sendMessage body — a string 400s there even though
  446. # the multipart sendPhoto call below would happily accept one. Coerce it
  447. # once, up front, so both call sites agree and a bad value fails loudly
  448. # instead of silently breaking only the text notifications.
  449. thread_id_raw = str(config.get("message_thread_id") or "").strip()
  450. message_thread_id: int | None = None
  451. if thread_id_raw:
  452. try:
  453. message_thread_id = int(thread_id_raw)
  454. except ValueError:
  455. return False, f"Invalid message thread ID: {thread_id_raw!r} is not a number"
  456. # Escape underscores in the message body so Telegram Markdown
  457. # parsing doesn't break on job names like "A1_plate_8" or error
  458. # codes like "0300_0001". The title is already wrapped in *bold*
  459. # markers, so only escape after the first newline.
  460. if "\n" in message:
  461. title_part, body_part = message.split("\n", 1)
  462. body_part = body_part.replace("_", "\\_")
  463. message = f"{title_part}\n{body_part}"
  464. client = await self._get_client()
  465. if image_data:
  466. # Use sendPhoto to attach the thumbnail with the caption
  467. url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
  468. form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"}
  469. if message_thread_id is not None:
  470. form["message_thread_id"] = message_thread_id
  471. response = await client.post(
  472. url,
  473. data=form,
  474. files={"photo": ("photo.jpg", image_data, "image/jpeg")},
  475. )
  476. else:
  477. url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
  478. data: dict[str, Any] = {
  479. "chat_id": chat_id,
  480. "text": message,
  481. "parse_mode": "Markdown",
  482. }
  483. if message_thread_id is not None:
  484. data["message_thread_id"] = message_thread_id
  485. response = await client.post(url, json=data)
  486. if response.status_code == 200:
  487. result = response.json()
  488. if result.get("ok"):
  489. return True, "Message sent successfully"
  490. else:
  491. return False, f"Telegram error: {result.get('description', 'Unknown error')}"
  492. else:
  493. return False, f"HTTP {response.status_code}: {response.text[:200]}"
  494. async def _send_email(
  495. self,
  496. config: dict,
  497. subject: str,
  498. body: str,
  499. image_data: bytes | None = None,
  500. finish_photo_url: str | None = None,
  501. ) -> tuple[bool, str]:
  502. """Send notification via email (SMTP).
  503. Inline finish-photo embed is opt-in via the template: when the rendered
  504. ``body`` contains the substituted ``{finish_photo_url}`` value AND the
  505. finish-photo bytes are present, the message is built as
  506. ``multipart/related`` wrapping a ``multipart/alternative`` (plain + HTML)
  507. plus an inline ``MIMEImage`` with ``Content-ID: <bambuddy-finish-photo>``.
  508. The HTML part replaces the URL with ``<img src="cid:...">``; the plain-
  509. text part keeps the URL as a clickable link. When the template doesn't
  510. reference ``{finish_photo_url}`` (or image bytes aren't available), the
  511. original single-part text shape is used — no attachment, no surprise
  512. inline image (#1792).
  513. """
  514. smtp_server = config.get("smtp_server", "").strip()
  515. smtp_port = int(config.get("smtp_port", 587))
  516. username = config.get("username", "").strip()
  517. password = config.get("password", "").strip()
  518. from_email = config.get("from_email", "").strip()
  519. to_email = config.get("to_email", "").strip()
  520. # Security: "starttls" (port 587), "ssl" (port 465), "none" (port 25)
  521. security = config.get("security", "starttls")
  522. # Authentication: "true" or "false"
  523. auth_enabled = config.get("auth_enabled", "true").lower() == "true"
  524. if not all([smtp_server, from_email, to_email]):
  525. return False, "SMTP server, from email, and to email are required"
  526. if auth_enabled and not all([username, password]):
  527. return False, "Username and password are required when authentication is enabled"
  528. # Template-driven: only inline-embed when the user's template explicitly
  529. # referenced {finish_photo_url} (so the URL appears in the rendered body)
  530. # AND the photo bytes are available. Falls back to text-only otherwise.
  531. inline_photo = bool(image_data and finish_photo_url and finish_photo_url in body)
  532. try:
  533. if inline_photo:
  534. # multipart/related → (multipart/alternative → text, html) + inline image
  535. msg = MIMEMultipart("related")
  536. msg["From"] = from_email
  537. msg["To"] = to_email
  538. msg["Subject"] = f"[Bambuddy] {subject}"
  539. alt = MIMEMultipart("alternative")
  540. alt.attach(MIMEText(body, "plain"))
  541. # Build HTML body: escape the rendered body, then swap the
  542. # escaped URL substring for an inline <img> referencing the
  543. # MIMEImage we attach below. Done AFTER escape so the cid: URL
  544. # we inject isn't re-escaped.
  545. escaped_body = html.escape(body).replace("\n", "<br>\n")
  546. escaped_url = html.escape(finish_photo_url)
  547. img_tag = (
  548. '<img src="cid:bambuddy-finish-photo" '
  549. 'alt="Printer camera snapshot" '
  550. 'style="max-width:100%;height:auto;border:1px solid #ddd;border-radius:4px;">'
  551. )
  552. html_body = f"<html><body><p>{escaped_body.replace(escaped_url, img_tag)}</p></body></html>"
  553. alt.attach(MIMEText(html_body, "html"))
  554. msg.attach(alt)
  555. img = MIMEImage(image_data, _subtype="jpeg")
  556. # Angle-bracketed Content-ID per RFC 2392, referenced from HTML
  557. # without the brackets via ``cid:bambuddy-finish-photo``.
  558. img.add_header("Content-ID", "<bambuddy-finish-photo>")
  559. img.add_header("Content-Disposition", "inline", filename="finish-photo.jpg")
  560. msg.attach(img)
  561. else:
  562. msg = MIMEMultipart()
  563. msg["From"] = from_email
  564. msg["To"] = to_email
  565. msg["Subject"] = f"[Bambuddy] {subject}"
  566. msg.attach(MIMEText(body, "plain"))
  567. # smtplib is synchronous and blocking: a wedged / greylisting /
  568. # firewall-dropped relay leaves recv() stuck. Two problems, two
  569. # fixes (#2572):
  570. # 1. No timeout — smtplib defaults to the global socket timeout,
  571. # which this app never sets, so a stuck relay blocks forever.
  572. # Pass an explicit timeout to every connect.
  573. # 2. Run on the event loop — a stuck (or merely slow) send freezes
  574. # every other coroutine, including a DB session a caller is
  575. # holding open across this notification. Offload to a worker
  576. # thread so the loop stays live and the connection is released
  577. # on schedule.
  578. smtp_timeout = 30.0
  579. msg_str = msg.as_string()
  580. def _blocking_send() -> None:
  581. if security == "ssl":
  582. # Direct SSL connection (typically port 465)
  583. server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=smtp_timeout)
  584. elif security == "starttls":
  585. # STARTTLS upgrade (typically port 587)
  586. server = smtplib.SMTP(smtp_server, smtp_port, timeout=smtp_timeout)
  587. server.starttls()
  588. else:
  589. # No encryption (typically port 25) - use with caution
  590. server = smtplib.SMTP(smtp_server, smtp_port, timeout=smtp_timeout)
  591. try:
  592. if auth_enabled:
  593. server.login(username, password)
  594. server.sendmail(from_email, to_email, msg_str)
  595. finally:
  596. # quit() in finally so a send error doesn't leak the socket.
  597. try:
  598. server.quit()
  599. except Exception: # noqa: BLE001 — closing a broken connection is best-effort
  600. pass
  601. await asyncio.to_thread(_blocking_send)
  602. return True, "Email sent successfully"
  603. except smtplib.SMTPAuthenticationError:
  604. return False, "SMTP authentication failed - check username/password"
  605. except smtplib.SMTPException as e:
  606. return False, f"SMTP error: {str(e)}"
  607. except Exception as e:
  608. return False, f"Email error: {str(e)}"
  609. async def _send_discord(
  610. self, config: dict, title: str, message: str, image_data: bytes | None = None
  611. ) -> tuple[bool, str]:
  612. """Send notification via Discord webhook."""
  613. webhook_url = config.get("webhook_url", "").strip()
  614. if not webhook_url:
  615. return False, "Webhook URL is required"
  616. if not (
  617. webhook_url.startswith("https://discord.com/api/webhooks/")
  618. or webhook_url.startswith("https://discordapp.com/api/webhooks/")
  619. ):
  620. return False, "Invalid Discord webhook URL"
  621. # Discord embed format for nicer messages
  622. embed = {
  623. "title": title,
  624. "description": message,
  625. "color": 0x00AE42, # Bambu green
  626. }
  627. client = await self._get_client()
  628. if image_data:
  629. # Attach image via multipart form-data and reference in embed
  630. embed["image"] = {"url": "attachment://photo.jpg"}
  631. payload = {"embeds": [embed]}
  632. response = await client.post(
  633. webhook_url,
  634. data={"payload_json": json.dumps(payload)},
  635. files={"files[0]": ("photo.jpg", image_data, "image/jpeg")},
  636. )
  637. else:
  638. response = await client.post(webhook_url, json={"embeds": [embed]})
  639. if response.status_code in (200, 204):
  640. return True, "Message sent successfully"
  641. else:
  642. return False, f"HTTP {response.status_code}: {response.text[:200]}"
  643. async def _send_webhook(
  644. self,
  645. config: dict,
  646. title: str,
  647. message: str,
  648. image_data: bytes | None = None,
  649. event_type: str | None = None,
  650. variables: dict | None = None,
  651. ) -> tuple[bool, str]:
  652. """Send notification via generic webhook (POST JSON).
  653. Supports two payload formats:
  654. - generic: Custom field names with timestamp/source metadata + structured event data
  655. - slack: Slack/Mattermost compatible format (just {"text": "..."})
  656. """
  657. webhook_url = config.get("webhook_url", "").strip()
  658. auth_header = config.get("auth_header", "").strip()
  659. payload_format = config.get("payload_format", "generic").strip()
  660. if not webhook_url:
  661. return False, "Webhook URL is required"
  662. url_error = _assert_safe_provider_url(webhook_url, label="Webhook URL")
  663. if url_error:
  664. return False, url_error
  665. # Build payload based on format
  666. if payload_format == "slack":
  667. # Slack/Mattermost format - just text field
  668. data = {"text": f"*{title}*\n{message}"}
  669. else:
  670. # Generic format with custom field names
  671. custom_field_title = config.get("field_title", "title").strip() or "title"
  672. custom_field_message = config.get("field_message", "message").strip() or "message"
  673. data = {
  674. custom_field_title: title,
  675. custom_field_message: message,
  676. "timestamp": datetime.now().isoformat(),
  677. "source": "Bambuddy",
  678. }
  679. # For generic format, include structured event data for automation tools
  680. if payload_format != "slack":
  681. if event_type:
  682. data["event"] = event_type
  683. if variables:
  684. for key, value in variables.items():
  685. if key not in data: # Don't overwrite title/message/timestamp/source
  686. data[key] = value
  687. # Attach base64-encoded image when available (generic format only)
  688. if image_data and payload_format != "slack":
  689. import base64
  690. data["image"] = base64.b64encode(image_data).decode("ascii")
  691. headers = {"Content-Type": "application/json"}
  692. if auth_header:
  693. # Support "Bearer token" or just "token" format
  694. if " " in auth_header:
  695. headers["Authorization"] = auth_header
  696. else:
  697. headers["Authorization"] = f"Bearer {auth_header}"
  698. client = await self._get_client()
  699. try:
  700. response = await client.post(webhook_url, json=data, headers=headers)
  701. if response.status_code in (200, 201, 202, 204):
  702. return True, "Webhook delivered successfully"
  703. else:
  704. return False, _opaque_http_failure(response, label="webhook endpoint")
  705. except Exception as e:
  706. return False, f"Webhook error: {str(e)}"
  707. async def _send_homeassistant(
  708. self, config: dict, title: str, message: str, db: AsyncSession | None = None
  709. ) -> tuple[bool, str]:
  710. """Send notification via Home Assistant.
  711. Uses the globally configured HA URL/token from settings.
  712. Defaults to persistent_notification/create, but supports
  713. custom services via config["service"] (e.g. notify.mobile_app_myphone).
  714. """
  715. # Get HA connection settings from global config
  716. ha_url = ""
  717. ha_token = ""
  718. if db:
  719. from backend.app.api.routes.settings import get_homeassistant_settings
  720. try:
  721. ha_settings = await get_homeassistant_settings(db)
  722. ha_url = ha_settings.get("ha_url", "")
  723. ha_token = ha_settings.get("ha_token", "")
  724. except Exception as e:
  725. logger.warning("Failed to read HA settings from database: %s", e)
  726. else:
  727. # Fallback: read directly from environment if no DB session
  728. import os
  729. ha_url = os.environ.get("HA_URL", "")
  730. ha_token = os.environ.get("HA_TOKEN", "")
  731. if not ha_url or not ha_token:
  732. return False, (
  733. "Home Assistant is not configured. Please set HA URL and token in Settings → Network → Home Assistant."
  734. )
  735. # Determine which HA service to call - Default: persistent_notification.create
  736. service = (config.get("service") or "").strip()
  737. if service:
  738. # Allow in different forms:
  739. # - notify.mobile_app_<device>
  740. # - notify/mobile_app_<device>
  741. # - api/services/notify/mobile_app_<device>
  742. service_str = service.lstrip("/")
  743. if service_str.startswith("api/services/"):
  744. endpoint = service_str
  745. elif "/" in service_str:
  746. endpoint = f"api/services/{service_str}"
  747. elif "." in service_str:
  748. domain, svc = service_str.split(".", 1)
  749. endpoint = f"api/services/{domain}/{svc}"
  750. else:
  751. return False, (
  752. "Invalid Home Assistant service name. Use e.g. 'notify.mobile_app_yourdevice' or 'notify/your_service'."
  753. )
  754. if not re.match(r"^api/services/[a-zA-Z0-9_]+/[a-zA-Z0-9_]+$", endpoint):
  755. return False, (
  756. "Invalid Home Assistant service name. Domain and service must only contain letters, numbers, and underscores."
  757. )
  758. else:
  759. endpoint = "api/services/persistent_notification/create"
  760. url = f"{ha_url.rstrip('/')}/{endpoint}"
  761. headers = {
  762. "Authorization": f"Bearer {ha_token}",
  763. "Content-Type": "application/json",
  764. }
  765. payload = {
  766. "title": title,
  767. "message": message,
  768. }
  769. # Optional custom service-data (#1441), forwarded as HA's nested "data"
  770. # object so mobile-app push options (priority, ttl, channel, group, ...)
  771. # reach the notify service. Only included when configured — the default
  772. # persistent_notification.create schema rejects unknown keys.
  773. raw_data = config.get("data")
  774. if raw_data:
  775. if isinstance(raw_data, str):
  776. try:
  777. parsed_data = json.loads(raw_data)
  778. except json.JSONDecodeError as e:
  779. return False, f"Invalid JSON in the Data field: {e}"
  780. else:
  781. parsed_data = raw_data
  782. if not isinstance(parsed_data, dict):
  783. return False, 'The Data field must be a JSON object, e.g. {"priority": "high", "ttl": 0}'
  784. if parsed_data:
  785. payload["data"] = parsed_data
  786. client = await self._get_client()
  787. response = await client.post(url, json=payload, headers=headers)
  788. if response.status_code in (200, 201):
  789. return True, "Notification sent via Home Assistant"
  790. elif response.status_code == 401:
  791. return False, "Home Assistant authentication failed - check your token"
  792. else:
  793. # ha_url comes from global settings (SETTINGS_UPDATE, admin-only), so
  794. # this is a narrower channel than the per-request provider URLs — but
  795. # it lands in the same NOTIFICATIONS_CREATE-gated test response, so it
  796. # gets the same treatment.
  797. return False, _opaque_http_failure(response, label="Home Assistant endpoint")
  798. async def _send_to_provider(
  799. self,
  800. provider: NotificationProvider,
  801. title: str,
  802. message: str,
  803. db: AsyncSession | None = None,
  804. image_data: bytes | None = None,
  805. event_type: str | None = None,
  806. variables: dict | None = None,
  807. ) -> tuple[bool, str]:
  808. """Send notification to a specific provider."""
  809. # Check quiet hours
  810. if self._is_in_quiet_hours(provider):
  811. logger.info("Skipping notification to %s - quiet hours active", provider.name)
  812. return True, "Skipped - quiet hours"
  813. config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
  814. try:
  815. if provider.provider_type == "callmebot":
  816. return await self._send_callmebot(config, f"{title}\n{message}")
  817. elif provider.provider_type == "ntfy":
  818. return await self._send_ntfy(config, title, message, image_data=image_data, event_type=event_type)
  819. elif provider.provider_type == "pushover":
  820. return await self._send_pushover(config, title, message, image_data=image_data)
  821. elif provider.provider_type == "telegram":
  822. return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
  823. elif provider.provider_type == "email":
  824. # finish_photo_url is pulled from the rendered template variables
  825. # so _send_email can detect whether the template referenced the
  826. # URL and inline-embed the photo only in that case.
  827. finish_photo_url = (variables or {}).get("finish_photo_url")
  828. return await self._send_email(
  829. config, title, message, image_data=image_data, finish_photo_url=finish_photo_url
  830. )
  831. elif provider.provider_type == "discord":
  832. return await self._send_discord(config, title, message, image_data=image_data)
  833. elif provider.provider_type == "webhook":
  834. return await self._send_webhook(
  835. config, title, message, image_data=image_data, event_type=event_type, variables=variables
  836. )
  837. elif provider.provider_type == "homeassistant":
  838. return await self._send_homeassistant(config, title, message, db=db)
  839. elif provider.provider_type == "bark":
  840. return await self._send_bark(config, title, message)
  841. else:
  842. return False, f"Unknown provider type: {provider.provider_type}"
  843. except Exception as e:
  844. logger.exception("Error sending notification via %s", provider.provider_type)
  845. return False, str(e)
  846. async def _update_provider_status(
  847. self, db: AsyncSession, provider_id: int, success: bool, error: str | None = None
  848. ):
  849. """Update provider status after sending notification."""
  850. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  851. provider = result.scalar_one_or_none()
  852. if provider:
  853. if success:
  854. provider.last_success = datetime.now(timezone.utc)
  855. else:
  856. provider.last_error = error
  857. provider.last_error_at = datetime.now(timezone.utc)
  858. await db.commit()
  859. async def _get_providers_for_event(
  860. self,
  861. db: AsyncSession,
  862. event_field: str,
  863. printer_id: int | None = None,
  864. ) -> list[NotificationProvider]:
  865. """Get all enabled providers that want a specific event type.
  866. Runs under ``no_autoflush`` (#2770). Callers routinely hold pending
  867. writes when they raise an event — the AMS sensor loop does
  868. ``db.add(history)`` and only commits after the alarms have gone out — and
  869. without this, autoflush satisfies this SELECT by writing those rows,
  870. which opens a write transaction on SQLite. The provider is then contacted
  871. over the network with that transaction still open, so a site whose
  872. internet is down holds the single SQLite writer for the whole connect
  873. timeout and unrelated background tasks fail with "database is locked".
  874. Deferring the flush costs nothing here: providers are committed rows, so
  875. a pending change in the caller's session cannot be one this query wants.
  876. """
  877. # Build the query dynamically based on event field
  878. query = select(NotificationProvider).where(
  879. NotificationProvider.enabled.is_(True),
  880. getattr(NotificationProvider, event_field).is_(True),
  881. )
  882. if printer_id is not None:
  883. query = query.where(
  884. (NotificationProvider.printer_id.is_(None)) | (NotificationProvider.printer_id == printer_id)
  885. )
  886. with db.no_autoflush:
  887. result = await db.execute(query)
  888. return list(result.scalars().all())
  889. async def _log_notification(
  890. self,
  891. db: AsyncSession,
  892. provider_id: int,
  893. event_type: str,
  894. title: str,
  895. message: str,
  896. success: bool,
  897. error_message: str | None = None,
  898. printer_id: int | None = None,
  899. printer_name: str | None = None,
  900. ):
  901. """Create a log entry for a sent notification."""
  902. try:
  903. log = NotificationLog(
  904. provider_id=provider_id,
  905. event_type=event_type,
  906. title=title,
  907. message=message,
  908. success=success,
  909. error_message=error_message,
  910. printer_id=printer_id,
  911. printer_name=printer_name,
  912. )
  913. db.add(log)
  914. await db.commit()
  915. except Exception as e:
  916. logger.warning("Failed to log notification: %s", e)
  917. # Don't fail the notification just because logging failed
  918. async def _send_to_providers(
  919. self,
  920. providers: list[NotificationProvider],
  921. title: str,
  922. message: str,
  923. db: AsyncSession,
  924. event_type: str = "unknown",
  925. printer_id: int | None = None,
  926. printer_name: str | None = None,
  927. force_immediate: bool = False,
  928. image_data: bytes | None = None,
  929. variables: dict | None = None,
  930. ):
  931. """Send notification to multiple providers and log the results.
  932. All notifications are always sent immediately. If digest mode is enabled,
  933. the notification is ALSO queued for the daily digest summary.
  934. """
  935. for provider in providers:
  936. try:
  937. # Always send notification immediately
  938. success, error = await self._send_to_provider(
  939. provider, title, message, db, image_data=image_data, event_type=event_type, variables=variables
  940. )
  941. # Also queue for digest if enabled (digest is a summary, not a queue)
  942. if provider.daily_digest_enabled and provider.daily_digest_time:
  943. await self._queue_for_digest(
  944. provider=provider,
  945. event_type=event_type,
  946. title=title,
  947. message=message,
  948. db=db,
  949. printer_id=printer_id,
  950. printer_name=printer_name,
  951. )
  952. await self._update_provider_status(db, provider.id, success, error if not success else None)
  953. await self._log_notification(
  954. db=db,
  955. provider_id=provider.id,
  956. event_type=event_type,
  957. title=title,
  958. message=message,
  959. success=success,
  960. error_message=error if not success else None,
  961. printer_id=printer_id,
  962. printer_name=printer_name,
  963. )
  964. if success:
  965. logger.info("Sent notification via %s", provider.name)
  966. else:
  967. logger.warning("Failed to send notification via %s: %s", provider.name, error)
  968. except Exception as e:
  969. logger.exception("Error sending notification via %s", provider.name)
  970. await self._update_provider_status(db, provider.id, False, str(e))
  971. await self._log_notification(
  972. db=db,
  973. provider_id=provider.id,
  974. event_type=event_type,
  975. title=title,
  976. message=message,
  977. success=False,
  978. error_message=str(e),
  979. printer_id=printer_id,
  980. printer_name=printer_name,
  981. )
  982. async def on_print_start(
  983. self,
  984. printer_id: int,
  985. printer_name: str,
  986. data: dict,
  987. db: AsyncSession,
  988. archive_data: dict | None = None,
  989. ):
  990. """Handle print start event - send notifications to relevant providers.
  991. Args:
  992. printer_id: The printer ID
  993. printer_name: The printer name
  994. data: MQTT event data with filename, subtask_name, remaining_time, raw_data
  995. db: Database session
  996. archive_data: Optional archive data with print_time_seconds from 3MF parsing
  997. """
  998. logger.info("on_print_start called for printer %s (%s)", printer_id, printer_name)
  999. providers = await self._get_providers_for_event(db, "on_print_start", printer_id)
  1000. if not providers:
  1001. logger.info("No notification providers configured for print_start event on printer %s", printer_id)
  1002. return
  1003. # Use subtask_name (project name) if available, otherwise use filename
  1004. subtask_name = data.get("subtask_name")
  1005. if subtask_name:
  1006. # Replace underscores with spaces for readability
  1007. filename = subtask_name.replace("_", " ")
  1008. else:
  1009. filename = self._clean_filename(data.get("filename", "Unknown"))
  1010. # Priority for estimated_time:
  1011. # 1. Archive's print_time_seconds from 3MF parsing (most reliable)
  1012. # 2. MQTT remaining_time (may be 0 at print start)
  1013. # 3. raw_data mc_remaining_time
  1014. estimated_time = None
  1015. # Try archive data first (from 3MF parsing - most reliable)
  1016. if archive_data and archive_data.get("print_time_seconds"):
  1017. estimated_time = archive_data["print_time_seconds"]
  1018. logger.debug("Using print_time_seconds from archive: %s", estimated_time)
  1019. # Fall back to MQTT remaining_time
  1020. if estimated_time is None:
  1021. estimated_time = data.get("remaining_time")
  1022. if estimated_time:
  1023. logger.debug("Using remaining_time from MQTT: %s", estimated_time)
  1024. # Last resort: raw_data mc_remaining_time (in minutes, convert to seconds)
  1025. if estimated_time is None:
  1026. raw_time = data.get("raw_data", {}).get("mc_remaining_time")
  1027. if raw_time:
  1028. estimated_time = raw_time * 60
  1029. logger.debug("Using mc_remaining_time from raw_data: %s", estimated_time)
  1030. time_str = self._format_duration(estimated_time)
  1031. eta_str = await self._format_eta(estimated_time, db)
  1032. variables = {
  1033. "printer": printer_name,
  1034. "filename": filename,
  1035. "estimated_time": time_str,
  1036. "eta": eta_str,
  1037. }
  1038. # Extract image data for providers that support attachments (e.g. Pushover)
  1039. image_data = None
  1040. if archive_data:
  1041. image_data = archive_data.get("image_data")
  1042. logger.info("Found %s providers for print_start: %s", len(providers), [p.name for p in providers])
  1043. title, message = await self._build_message_from_template(db, "print_start", variables)
  1044. await self._send_to_providers(
  1045. providers,
  1046. title,
  1047. message,
  1048. db,
  1049. "print_start",
  1050. printer_id,
  1051. printer_name,
  1052. image_data=image_data,
  1053. variables=variables,
  1054. )
  1055. async def on_print_complete(
  1056. self,
  1057. printer_id: int,
  1058. printer_name: str,
  1059. status: str,
  1060. data: dict,
  1061. db: AsyncSession,
  1062. archive_data: dict | None = None,
  1063. ):
  1064. """Handle print complete event - send notifications to relevant providers."""
  1065. logger.info("on_print_complete called for printer %s (%s), status=%s", printer_id, printer_name, status)
  1066. # Determine event type based on status
  1067. if status == "completed":
  1068. event_field = "on_print_complete"
  1069. event_type = "print_complete"
  1070. elif status in ("failed",):
  1071. event_field = "on_print_failed"
  1072. event_type = "print_failed"
  1073. elif status in ("aborted", "stopped", "cancelled"):
  1074. event_field = "on_print_stopped"
  1075. event_type = "print_stopped"
  1076. else:
  1077. logger.warning("Unknown print status '%s', defaulting to on_print_complete", status)
  1078. event_field = "on_print_complete"
  1079. event_type = "print_complete"
  1080. providers = await self._get_providers_for_event(db, event_field, printer_id)
  1081. if not providers:
  1082. logger.info("No notification providers configured for %s event on printer %s", event_field, printer_id)
  1083. return
  1084. # Use subtask_name (project name) if available, otherwise use filename
  1085. subtask_name = data.get("subtask_name")
  1086. if subtask_name:
  1087. filename = subtask_name.replace("_", " ")
  1088. else:
  1089. filename = self._clean_filename(data.get("filename", "Unknown"))
  1090. variables = {
  1091. "printer": printer_name,
  1092. "filename": filename,
  1093. "duration": "Unknown",
  1094. "filament_grams": "Unknown",
  1095. "reason": "Unknown",
  1096. }
  1097. if archive_data:
  1098. # {{duration}} on completion / failure / stopped events is the *actual*
  1099. # elapsed time (#1198). Slicer-estimated print_time_seconds is only used
  1100. # as a last-resort fallback when timestamps weren't recorded.
  1101. duration_seconds = archive_data.get("actual_time_seconds") or archive_data.get("print_time_seconds")
  1102. if duration_seconds:
  1103. variables["duration"] = self._format_duration(duration_seconds)
  1104. if archive_data.get("actual_filament_grams"):
  1105. variables["filament_grams"] = f"{archive_data['actual_filament_grams']:.1f}"
  1106. if status == "failed" and archive_data.get("failure_reason"):
  1107. variables["reason"] = archive_data["failure_reason"]
  1108. if archive_data.get("finish_photo_url"):
  1109. variables["finish_photo_url"] = archive_data["finish_photo_url"]
  1110. # Build per-slot breakdown string with AMS info when available
  1111. if archive_data.get("usage_results"):
  1112. parts = []
  1113. for u in archive_data["usage_results"]:
  1114. ams_id = u.get("ams_id", 0)
  1115. tray_id = u.get("tray_id", 0)
  1116. material = u.get("material", "Unknown") or "Unknown"
  1117. used = u.get("weight_used", 0)
  1118. if ams_id >= 128:
  1119. slot_label = "Ext"
  1120. else:
  1121. slot_label = f"AMS-{chr(65 + ams_id)} T{tray_id + 1}"
  1122. parts.append(f"{slot_label} {material}: {used:.1f}g")
  1123. variables["filament_details"] = " | ".join(parts)
  1124. elif archive_data.get("filament_slots"):
  1125. parts = []
  1126. for slot in archive_data["filament_slots"]:
  1127. ftype = slot.get("type", "Unknown") or "Unknown"
  1128. used = slot.get("used_g", 0)
  1129. parts.append(f"{ftype}: {used:.1f}g")
  1130. variables["filament_details"] = " | ".join(parts)
  1131. # Add progress for partial prints
  1132. if archive_data.get("progress") is not None:
  1133. variables["progress"] = str(archive_data["progress"])
  1134. # Extract image data for providers that support attachments (e.g. Pushover)
  1135. image_data = None
  1136. if archive_data:
  1137. image_data = archive_data.get("image_data")
  1138. logger.info("Found %s providers for %s: %s", len(providers), event_field, [p.name for p in providers])
  1139. title, message = await self._build_message_from_template(db, event_type, variables)
  1140. await self._send_to_providers(
  1141. providers,
  1142. title,
  1143. message,
  1144. db,
  1145. event_type,
  1146. printer_id,
  1147. printer_name,
  1148. image_data=image_data,
  1149. variables=variables,
  1150. )
  1151. async def on_print_progress(
  1152. self,
  1153. printer_id: int,
  1154. printer_name: str,
  1155. filename: str,
  1156. progress: int,
  1157. db: AsyncSession,
  1158. remaining_time: int | None = None,
  1159. image_data: bytes | None = None,
  1160. ):
  1161. """Handle print progress milestone (25%, 50%, 75%)."""
  1162. providers = await self._get_providers_for_event(db, "on_print_progress", printer_id)
  1163. if not providers:
  1164. return
  1165. eta_str = await self._format_eta(remaining_time, db)
  1166. variables = {
  1167. "printer": printer_name,
  1168. "filename": self._clean_filename(filename),
  1169. "progress": str(progress),
  1170. "remaining_time": self._format_duration(remaining_time) if remaining_time else "Unknown",
  1171. "eta": eta_str,
  1172. }
  1173. title, message = await self._build_message_from_template(db, "print_progress", variables)
  1174. await self._send_to_providers(
  1175. providers,
  1176. title,
  1177. message,
  1178. db,
  1179. "print_progress",
  1180. printer_id,
  1181. printer_name,
  1182. image_data=image_data,
  1183. variables=variables,
  1184. )
  1185. async def on_print_missing_spool_assignment(
  1186. self,
  1187. printer_id: int,
  1188. printer_name: str,
  1189. missing_slots: list[dict[str, str]],
  1190. db: AsyncSession,
  1191. ):
  1192. """Handle print-start event when required trays are missing spool assignments."""
  1193. if not missing_slots:
  1194. return
  1195. providers = await self._get_providers_for_event(db, "on_print_missing_spool_assignment", printer_id)
  1196. if not providers:
  1197. return
  1198. missing_slot_names = ", ".join(slot.get("slot", "Unknown") for slot in missing_slots)
  1199. detail_lines = []
  1200. for slot in missing_slots:
  1201. slot_name = slot.get("slot", "Unknown")
  1202. profile = slot.get("profile", "Unknown")
  1203. detail_lines.append(f"- {slot_name}: {profile}")
  1204. missing_profile_details = "\n".join(detail_lines)
  1205. variables = {
  1206. "printer": printer_name,
  1207. "missing_slots": missing_slot_names,
  1208. "missing_slot_details": missing_profile_details,
  1209. }
  1210. title, message = await self._build_message_from_template(db, "print_missing_spool_assignment", variables)
  1211. await self._send_to_providers(
  1212. providers,
  1213. title,
  1214. message,
  1215. db,
  1216. "print_missing_spool_assignment",
  1217. printer_id,
  1218. printer_name,
  1219. force_immediate=True,
  1220. variables=variables,
  1221. )
  1222. async def on_billing_charge_failed(
  1223. self,
  1224. printer_id: int,
  1225. printer_name: str,
  1226. filename: str,
  1227. archive_id: int | None,
  1228. error: str,
  1229. db: AsyncSession,
  1230. ) -> None:
  1231. """Notify providers that a terminal print could not be charged."""
  1232. providers = await self._get_providers_for_event(db, "on_billing_charge_failed", printer_id)
  1233. if not providers:
  1234. return
  1235. variables = {
  1236. "printer": printer_name,
  1237. "filename": self._clean_filename(filename),
  1238. "archive_id": str(archive_id) if archive_id is not None else "Unknown",
  1239. "error": error,
  1240. }
  1241. title, message = await self._build_message_from_template(db, "billing_charge_failed", variables)
  1242. await self._send_to_providers(
  1243. providers,
  1244. title,
  1245. message,
  1246. db,
  1247. "billing_charge_failed",
  1248. printer_id,
  1249. printer_name,
  1250. force_immediate=True,
  1251. variables=variables,
  1252. )
  1253. async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
  1254. """Handle printer offline event."""
  1255. providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
  1256. if not providers:
  1257. return
  1258. variables = {"printer": printer_name}
  1259. title, message = await self._build_message_from_template(db, "printer_offline", variables)
  1260. await self._send_to_providers(
  1261. providers, title, message, db, "printer_offline", printer_id, printer_name, variables=variables
  1262. )
  1263. async def on_printer_error(
  1264. self,
  1265. printer_id: int,
  1266. printer_name: str,
  1267. error_type: str,
  1268. db: AsyncSession,
  1269. error_detail: str | None = None,
  1270. image_data: bytes | None = None,
  1271. ):
  1272. """Handle printer error event (AMS issues, etc.)."""
  1273. providers = await self._get_providers_for_event(db, "on_printer_error", printer_id)
  1274. if not providers:
  1275. return
  1276. variables = {
  1277. "printer": printer_name,
  1278. "error_type": error_type,
  1279. "error_detail": error_detail or "No details available",
  1280. }
  1281. title, message = await self._build_message_from_template(db, "printer_error", variables)
  1282. await self._send_to_providers(
  1283. providers,
  1284. title,
  1285. message,
  1286. db,
  1287. "printer_error",
  1288. printer_id,
  1289. printer_name,
  1290. image_data=image_data,
  1291. variables=variables,
  1292. )
  1293. async def on_ai_failure_detection(
  1294. self,
  1295. printer_id: int,
  1296. printer_name: str,
  1297. task_name: str,
  1298. confidence: float,
  1299. action: str,
  1300. db: AsyncSession,
  1301. image_data: bytes | None = None,
  1302. ):
  1303. """Handle AI failure-detection event (Obico spaghetti / print-failure ML).
  1304. Split out of on_printer_error (#1794) so a user can subscribe to AI
  1305. alerts without also being paged for every HMS hardware code.
  1306. """
  1307. providers = await self._get_providers_for_event(db, "on_ai_failure_detection", printer_id)
  1308. if not providers:
  1309. return
  1310. variables = {
  1311. "printer": printer_name,
  1312. "task_name": task_name or "current job",
  1313. "confidence": f"{confidence:.2f}",
  1314. "action": action,
  1315. }
  1316. title, message = await self._build_message_from_template(db, "ai_failure_detection", variables)
  1317. await self._send_to_providers(
  1318. providers,
  1319. title,
  1320. message,
  1321. db,
  1322. "ai_failure_detection",
  1323. printer_id,
  1324. printer_name,
  1325. image_data=image_data,
  1326. variables=variables,
  1327. )
  1328. async def on_plate_not_empty(
  1329. self,
  1330. printer_id: int,
  1331. printer_name: str,
  1332. db: AsyncSession,
  1333. difference_percent: float | None = None,
  1334. ):
  1335. """Handle plate not empty event - objects detected on build plate before print."""
  1336. providers = await self._get_providers_for_event(db, "on_plate_not_empty", printer_id)
  1337. if not providers:
  1338. return
  1339. variables = {
  1340. "printer": printer_name,
  1341. "difference_percent": f"{difference_percent:.1f}" if difference_percent else "N/A",
  1342. }
  1343. title, message = await self._build_message_from_template(db, "plate_not_empty", variables)
  1344. await self._send_to_providers(
  1345. providers,
  1346. title,
  1347. message,
  1348. db,
  1349. "plate_not_empty",
  1350. printer_id,
  1351. printer_name,
  1352. force_immediate=True,
  1353. variables=variables,
  1354. )
  1355. async def on_plate_clear_required(
  1356. self,
  1357. printer_id: int,
  1358. printer_name: str,
  1359. db: AsyncSession,
  1360. ):
  1361. """Handle plate-clear-required event — a print ended and the queue is gated (#2525).
  1362. Distinct from ``on_plate_not_empty``, which is the camera check *before* a
  1363. print starts. This one fires on the rising edge of the Bambuddy-side
  1364. awaiting-plate-clear flag, i.e. whenever a print reaches a terminal state
  1365. and the next queued job can't dispatch until someone confirms the bed is
  1366. free. Off by default on every provider: it lands at the same moment as the
  1367. print-complete notification, so opting in is a deliberate choice.
  1368. """
  1369. providers = await self._get_providers_for_event(db, "on_plate_clear_required", printer_id)
  1370. if not providers:
  1371. return
  1372. variables = {"printer": printer_name}
  1373. title, message = await self._build_message_from_template(db, "plate_clear_required", variables)
  1374. await self._send_to_providers(
  1375. providers,
  1376. title,
  1377. message,
  1378. db,
  1379. "plate_clear_required",
  1380. printer_id,
  1381. printer_name,
  1382. variables=variables,
  1383. )
  1384. async def on_filament_low(
  1385. self,
  1386. printer_id: int,
  1387. printer_name: str,
  1388. slot: int,
  1389. remaining_percent: int,
  1390. db: AsyncSession,
  1391. color: str | None = None,
  1392. ):
  1393. """Handle low filament event."""
  1394. providers = await self._get_providers_for_event(db, "on_filament_low", printer_id)
  1395. if not providers:
  1396. return
  1397. variables = {
  1398. "printer": printer_name,
  1399. "slot": str(slot),
  1400. "remaining_percent": str(remaining_percent),
  1401. "color": color or "",
  1402. }
  1403. title, message = await self._build_message_from_template(db, "filament_low", variables)
  1404. await self._send_to_providers(
  1405. providers, title, message, db, "filament_low", printer_id, printer_name, variables=variables
  1406. )
  1407. async def on_maintenance_due(
  1408. self,
  1409. printer_id: int,
  1410. printer_name: str,
  1411. maintenance_items: list[dict],
  1412. db: AsyncSession,
  1413. ):
  1414. """Handle maintenance due event - sends notification when maintenance is due or warning."""
  1415. if not maintenance_items:
  1416. return
  1417. providers = await self._get_providers_for_event(db, "on_maintenance_due", printer_id)
  1418. if not providers:
  1419. logger.info("No notification providers configured for maintenance_due event on printer %s", printer_id)
  1420. return
  1421. # Format maintenance items list
  1422. items_list = []
  1423. for item in maintenance_items:
  1424. status = "OVERDUE" if item.get("is_due") else "Soon"
  1425. items_list.append(f"- {item['name']} ({status})")
  1426. items_str = "\n".join(items_list)
  1427. variables = {
  1428. "printer": printer_name,
  1429. "items": items_str,
  1430. }
  1431. logger.info("Found %s providers for maintenance_due: %s", len(providers), [p.name for p in providers])
  1432. title, message = await self._build_message_from_template(db, "maintenance_due", variables)
  1433. await self._send_to_providers(
  1434. providers, title, message, db, "maintenance_due", printer_id, printer_name, variables=variables
  1435. )
  1436. async def on_ams_humidity_high(
  1437. self,
  1438. printer_id: int,
  1439. printer_name: str,
  1440. ams_label: str,
  1441. humidity: float,
  1442. threshold: float,
  1443. db: AsyncSession,
  1444. ):
  1445. """Handle AMS high humidity alarm event. Always sends immediately (bypasses digest)."""
  1446. providers = await self._get_providers_for_event(db, "on_ams_humidity_high", printer_id)
  1447. if not providers:
  1448. return
  1449. variables = {
  1450. "printer": printer_name,
  1451. "ams_label": ams_label,
  1452. "humidity": f"{humidity:.0f}",
  1453. "threshold": f"{threshold:.0f}",
  1454. }
  1455. title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
  1456. # Alarms always send immediately, bypassing digest mode
  1457. await self._send_to_providers(
  1458. providers,
  1459. title,
  1460. message,
  1461. db,
  1462. "ams_humidity_high",
  1463. printer_id,
  1464. printer_name,
  1465. force_immediate=True,
  1466. variables=variables,
  1467. )
  1468. async def on_ams_temperature_high(
  1469. self,
  1470. printer_id: int,
  1471. printer_name: str,
  1472. ams_label: str,
  1473. temperature: float,
  1474. threshold: float,
  1475. db: AsyncSession,
  1476. ):
  1477. """Handle AMS high temperature alarm event. Always sends immediately (bypasses digest)."""
  1478. providers = await self._get_providers_for_event(db, "on_ams_temperature_high", printer_id)
  1479. if not providers:
  1480. return
  1481. variables = {
  1482. "printer": printer_name,
  1483. "ams_label": ams_label,
  1484. "temperature": f"{temperature:.1f}",
  1485. "threshold": f"{threshold:.1f}",
  1486. }
  1487. title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
  1488. # Alarms always send immediately, bypassing digest mode
  1489. await self._send_to_providers(
  1490. providers,
  1491. title,
  1492. message,
  1493. db,
  1494. "ams_temperature_high",
  1495. printer_id,
  1496. printer_name,
  1497. force_immediate=True,
  1498. variables=variables,
  1499. )
  1500. async def on_ams_drying_suspended(
  1501. self,
  1502. printer_id: int,
  1503. printer_name: str,
  1504. ams_label: str,
  1505. humidity: float,
  1506. threshold: float,
  1507. cycles: int,
  1508. db: AsyncSession,
  1509. ):
  1510. """Handle automatic drying giving up on one AMS unit (#2770).
  1511. Sent immediately rather than folded into a digest: it reports that
  1512. Bambuddy has STOPPED doing something, and a report of inaction that
  1513. arrives with tomorrow's summary has already cost the user a day.
  1514. """
  1515. providers = await self._get_providers_for_event(db, "on_ams_drying_suspended", printer_id)
  1516. if not providers:
  1517. return
  1518. variables = {
  1519. "printer": printer_name,
  1520. "ams_label": ams_label,
  1521. "humidity": f"{humidity:.0f}",
  1522. "threshold": f"{threshold:.0f}",
  1523. "cycles": str(cycles),
  1524. }
  1525. title, message = await self._build_message_from_template(db, "ams_drying_suspended", variables)
  1526. await self._send_to_providers(
  1527. providers,
  1528. title,
  1529. message,
  1530. db,
  1531. "ams_drying_suspended",
  1532. printer_id,
  1533. printer_name,
  1534. force_immediate=True,
  1535. variables=variables,
  1536. )
  1537. async def on_ams_ht_humidity_high(
  1538. self,
  1539. printer_id: int,
  1540. printer_name: str,
  1541. ams_label: str,
  1542. humidity: float,
  1543. threshold: float,
  1544. db: AsyncSession,
  1545. ):
  1546. """Handle AMS-HT high humidity alarm event. Always sends immediately (bypasses digest)."""
  1547. providers = await self._get_providers_for_event(db, "on_ams_ht_humidity_high", printer_id)
  1548. if not providers:
  1549. return
  1550. variables = {
  1551. "printer": printer_name,
  1552. "ams_label": ams_label,
  1553. "humidity": f"{humidity:.0f}",
  1554. "threshold": f"{threshold:.0f}",
  1555. }
  1556. # Use the same template as regular AMS (can create separate templates later if needed)
  1557. title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
  1558. # Alarms always send immediately, bypassing digest mode
  1559. await self._send_to_providers(
  1560. providers,
  1561. title,
  1562. message,
  1563. db,
  1564. "ams_ht_humidity_high",
  1565. printer_id,
  1566. printer_name,
  1567. force_immediate=True,
  1568. variables=variables,
  1569. )
  1570. async def on_ams_ht_temperature_high(
  1571. self,
  1572. printer_id: int,
  1573. printer_name: str,
  1574. ams_label: str,
  1575. temperature: float,
  1576. threshold: float,
  1577. db: AsyncSession,
  1578. ):
  1579. """Handle AMS-HT high temperature alarm event. Always sends immediately (bypasses digest)."""
  1580. providers = await self._get_providers_for_event(db, "on_ams_ht_temperature_high", printer_id)
  1581. if not providers:
  1582. return
  1583. variables = {
  1584. "printer": printer_name,
  1585. "ams_label": ams_label,
  1586. "temperature": f"{temperature:.1f}",
  1587. "threshold": f"{threshold:.1f}",
  1588. }
  1589. # Use the same template as regular AMS (can create separate templates later if needed)
  1590. title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
  1591. # Alarms always send immediately, bypassing digest mode
  1592. await self._send_to_providers(
  1593. providers,
  1594. title,
  1595. message,
  1596. db,
  1597. "ams_ht_temperature_high",
  1598. printer_id,
  1599. printer_name,
  1600. force_immediate=True,
  1601. variables=variables,
  1602. )
  1603. async def on_bed_cooled(
  1604. self,
  1605. printer_id: int,
  1606. printer_name: str,
  1607. bed_temp: float,
  1608. threshold: float,
  1609. filename: str,
  1610. db: AsyncSession,
  1611. ):
  1612. """Handle bed cooled event - bed temperature dropped below threshold after print."""
  1613. providers = await self._get_providers_for_event(db, "on_bed_cooled", printer_id)
  1614. if not providers:
  1615. return
  1616. variables = {
  1617. "printer": printer_name,
  1618. "bed_temp": f"{bed_temp:.0f}",
  1619. "threshold": f"{threshold:.0f}",
  1620. "filename": self._clean_filename(filename) if filename else "Unknown",
  1621. }
  1622. title, message = await self._build_message_from_template(db, "bed_cooled", variables)
  1623. await self._send_to_providers(
  1624. providers, title, message, db, "bed_cooled", printer_id, printer_name, variables=variables
  1625. )
  1626. async def on_ha_sensor_alert(
  1627. self,
  1628. printer_id: int,
  1629. printer_name: str,
  1630. sensor_name: str,
  1631. state: str,
  1632. db: AsyncSession,
  1633. ):
  1634. """A Home Assistant sensor bound to a printer entered its alert state (#1148).
  1635. Sent immediately rather than folded into a digest: the case this exists
  1636. for is an enclosure door left open, which is only worth telling someone
  1637. about while they can still act on it.
  1638. """
  1639. providers = await self._get_providers_for_event(db, "on_ha_sensor_alert", printer_id)
  1640. if not providers:
  1641. return
  1642. variables = {
  1643. "printer": printer_name,
  1644. "sensor": sensor_name,
  1645. "state": state,
  1646. }
  1647. title, message = await self._build_message_from_template(db, "ha_sensor_alert", variables)
  1648. await self._send_to_providers(
  1649. providers,
  1650. title,
  1651. message,
  1652. db,
  1653. "ha_sensor_alert",
  1654. printer_id,
  1655. printer_name,
  1656. force_immediate=True,
  1657. variables=variables,
  1658. )
  1659. async def on_location_ha_sensor_alert(
  1660. self,
  1661. location_name: str,
  1662. sensor_name: str,
  1663. state: str,
  1664. db: AsyncSession,
  1665. ):
  1666. """A Home Assistant sensor bound to a storage location entered its alert state (#2824).
  1667. Sent immediately rather than folded into a digest, for the same reason
  1668. as on_ha_sensor_alert above: this is the "drybox went stale" case, only
  1669. worth acting on while the humidity/temperature is still climbing.
  1670. """
  1671. # Own column, not on_ha_sensor_alert (#2824): that one can be scoped to
  1672. # a single printer via provider.printer_id, and a location alert has no
  1673. # printer to scope by, so sharing it would leak drybox alerts to a
  1674. # provider narrowed to one printer's sensors.
  1675. providers = await self._get_providers_for_event(db, "on_location_ha_sensor_alert", None)
  1676. if not providers:
  1677. return
  1678. variables = {
  1679. "location": location_name,
  1680. "sensor": sensor_name,
  1681. "state": state,
  1682. }
  1683. title, message = await self._build_message_from_template(db, "location_ha_sensor_alert", variables)
  1684. await self._send_to_providers(
  1685. providers,
  1686. title,
  1687. message,
  1688. db,
  1689. "location_ha_sensor_alert",
  1690. force_immediate=True,
  1691. variables=variables,
  1692. )
  1693. async def on_first_layer_complete(
  1694. self,
  1695. printer_id: int,
  1696. printer_name: str,
  1697. filename: str,
  1698. total_layers: int,
  1699. db: AsyncSession,
  1700. image_data: bytes | None = None,
  1701. ):
  1702. """Handle first layer complete event."""
  1703. providers = await self._get_providers_for_event(db, "on_first_layer_complete", printer_id)
  1704. if not providers:
  1705. return
  1706. variables = {
  1707. "printer": printer_name,
  1708. "filename": self._clean_filename(filename),
  1709. "total_layers": str(total_layers),
  1710. }
  1711. title, message = await self._build_message_from_template(db, "first_layer_complete", variables)
  1712. await self._send_to_providers(
  1713. providers,
  1714. title,
  1715. message,
  1716. db,
  1717. "first_layer_complete",
  1718. printer_id,
  1719. printer_name,
  1720. image_data=image_data,
  1721. variables=variables,
  1722. )
  1723. def clear_template_cache(self):
  1724. """Clear the template cache. Call this when templates are updated."""
  1725. self._template_cache.clear()
  1726. async def send_user_print_email(
  1727. self,
  1728. event_type: str,
  1729. created_by_id: int | None,
  1730. printer_name: str,
  1731. filename: str,
  1732. db: AsyncSession,
  1733. ) -> None:
  1734. """Send a print event email notification to the user who submitted the job.
  1735. Args:
  1736. event_type: 'user_print_start', 'user_print_complete', 'user_print_failed', or 'user_print_stopped'
  1737. created_by_id: User ID who submitted the print job (from archive)
  1738. printer_name: Name of the printer
  1739. filename: Raw filename or subtask name
  1740. db: Database session
  1741. """
  1742. if created_by_id is None:
  1743. logger.debug("[EMAIL] Skipping user print email (%s): no created_by_id", event_type)
  1744. return
  1745. try:
  1746. # Check if advanced auth is enabled - required for user email notifications
  1747. from backend.app.models.settings import Settings
  1748. result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
  1749. setting = result.scalar_one_or_none()
  1750. if not setting or setting.value.lower() != "true":
  1751. logger.debug("[EMAIL] Skipping user print email (%s): advanced_auth not enabled", event_type)
  1752. return
  1753. # Check if user notifications are enabled (admin-controlled toggle)
  1754. notif_enabled_result = await db.execute(
  1755. select(Settings).where(Settings.key == "user_notifications_enabled")
  1756. )
  1757. notif_enabled_setting = notif_enabled_result.scalar_one_or_none()
  1758. if notif_enabled_setting and notif_enabled_setting.value.lower() == "false":
  1759. logger.debug("[EMAIL] Skipping user print email (%s): user_notifications_enabled is false", event_type)
  1760. return
  1761. # Check SMTP settings are configured - required for sending emails
  1762. from backend.app.services.email_service import get_smtp_settings, send_user_print_notification
  1763. smtp_settings = await get_smtp_settings(db)
  1764. if not smtp_settings:
  1765. logger.debug("[EMAIL] Skipping user print email (%s): SMTP settings not configured", event_type)
  1766. return
  1767. # Load user preferences
  1768. from backend.app.models.user import User
  1769. from backend.app.models.user_email_pref import UserEmailPreference
  1770. user_result = await db.execute(select(User).where(User.id == created_by_id))
  1771. user = user_result.scalar_one_or_none()
  1772. if user is None or not user.email:
  1773. logger.debug(
  1774. "[EMAIL] Skipping user print email (%s): user %s not found or has no email address",
  1775. event_type,
  1776. created_by_id,
  1777. )
  1778. return
  1779. # Load user's notification preferences
  1780. pref_result = await db.execute(
  1781. select(UserEmailPreference).where(UserEmailPreference.user_id == created_by_id)
  1782. )
  1783. pref = pref_result.scalar_one_or_none()
  1784. # Determine if this event type should be sent
  1785. should_send = False
  1786. if event_type == "user_print_start":
  1787. should_send = pref is None or pref.notify_print_start
  1788. elif event_type == "user_print_complete":
  1789. should_send = pref is None or pref.notify_print_complete
  1790. elif event_type == "user_print_failed":
  1791. should_send = pref is None or pref.notify_print_failed
  1792. elif event_type == "user_print_stopped":
  1793. should_send = pref is None or pref.notify_print_stopped
  1794. if not should_send:
  1795. logger.debug(
  1796. "[EMAIL] Skipping user print email (%s): user %s has notifications disabled for this event",
  1797. event_type,
  1798. created_by_id,
  1799. )
  1800. return
  1801. logger.info(
  1802. "[EMAIL] Sending user print email: event=%s, user=%s (%s), printer=%s, file=%s",
  1803. event_type,
  1804. user.username,
  1805. user.email,
  1806. printer_name,
  1807. filename,
  1808. )
  1809. # Build variables
  1810. variables = {
  1811. "printer": printer_name,
  1812. "filename": self._clean_filename(filename),
  1813. }
  1814. # Send the email
  1815. await send_user_print_notification(
  1816. db=db,
  1817. event_type=event_type,
  1818. user_email=user.email,
  1819. username=user.username,
  1820. variables=variables,
  1821. )
  1822. logger.info("[EMAIL] User print email sent: event=%s → %s", event_type, user.email)
  1823. except Exception as e:
  1824. logger.warning("Failed to send user print email notification: %s", e, exc_info=True)
  1825. # ==================== Queue Notifications ====================
  1826. async def on_queue_job_added(
  1827. self,
  1828. job_name: str,
  1829. target: str,
  1830. db: AsyncSession,
  1831. printer_id: int | None = None,
  1832. printer_name: str | None = None,
  1833. ):
  1834. """Handle queue job added event."""
  1835. providers = await self._get_providers_for_event(db, "on_queue_job_added", printer_id)
  1836. if not providers:
  1837. return
  1838. variables = {
  1839. "job_name": job_name,
  1840. "target": target, # e.g., "Printer1" or "Any X1C"
  1841. "printer": printer_name or target,
  1842. }
  1843. title, message = await self._build_message_from_template(db, "queue_job_added", variables)
  1844. await self._send_to_providers(
  1845. providers, title, message, db, "queue_job_added", printer_id, printer_name, variables=variables
  1846. )
  1847. async def on_queue_job_assigned(
  1848. self,
  1849. job_name: str,
  1850. printer_id: int,
  1851. printer_name: str,
  1852. target_model: str,
  1853. db: AsyncSession,
  1854. ):
  1855. """Handle model-based job assigned to printer event."""
  1856. providers = await self._get_providers_for_event(db, "on_queue_job_assigned", printer_id)
  1857. if not providers:
  1858. return
  1859. variables = {
  1860. "job_name": job_name,
  1861. "printer": printer_name,
  1862. "target_model": target_model,
  1863. }
  1864. title, message = await self._build_message_from_template(db, "queue_job_assigned", variables)
  1865. await self._send_to_providers(
  1866. providers, title, message, db, "queue_job_assigned", printer_id, printer_name, variables=variables
  1867. )
  1868. async def on_queue_job_started(
  1869. self,
  1870. job_name: str,
  1871. printer_id: int,
  1872. printer_name: str,
  1873. db: AsyncSession,
  1874. estimated_time: int | None = None,
  1875. ):
  1876. """Handle queue job started printing event."""
  1877. providers = await self._get_providers_for_event(db, "on_queue_job_started", printer_id)
  1878. if not providers:
  1879. return
  1880. eta_str = await self._format_eta(estimated_time, db)
  1881. variables = {
  1882. "job_name": job_name,
  1883. "printer": printer_name,
  1884. "estimated_time": self._format_duration(estimated_time),
  1885. "eta": eta_str,
  1886. }
  1887. title, message = await self._build_message_from_template(db, "queue_job_started", variables)
  1888. await self._send_to_providers(
  1889. providers, title, message, db, "queue_job_started", printer_id, printer_name, variables=variables
  1890. )
  1891. async def on_queue_job_waiting(
  1892. self,
  1893. job_name: str,
  1894. target_model: str,
  1895. waiting_reason: str,
  1896. db: AsyncSession,
  1897. ):
  1898. """Handle job waiting for filament event."""
  1899. providers = await self._get_providers_for_event(db, "on_queue_job_waiting", None)
  1900. if not providers:
  1901. return
  1902. variables = {
  1903. "job_name": job_name,
  1904. "target_model": target_model,
  1905. "waiting_reason": waiting_reason,
  1906. }
  1907. title, message = await self._build_message_from_template(db, "queue_job_waiting", variables)
  1908. await self._send_to_providers(providers, title, message, db, "queue_job_waiting", variables=variables)
  1909. async def on_queue_job_skipped(
  1910. self,
  1911. job_name: str,
  1912. printer_id: int,
  1913. printer_name: str,
  1914. reason: str,
  1915. db: AsyncSession,
  1916. ):
  1917. """Handle job skipped event (e.g., previous print failed)."""
  1918. providers = await self._get_providers_for_event(db, "on_queue_job_skipped", printer_id)
  1919. if not providers:
  1920. return
  1921. variables = {
  1922. "job_name": job_name,
  1923. "printer": printer_name,
  1924. "reason": reason,
  1925. }
  1926. title, message = await self._build_message_from_template(db, "queue_job_skipped", variables)
  1927. await self._send_to_providers(
  1928. providers, title, message, db, "queue_job_skipped", printer_id, printer_name, variables=variables
  1929. )
  1930. async def on_queue_job_failed(
  1931. self,
  1932. job_name: str,
  1933. printer_id: int | None,
  1934. printer_name: str | None,
  1935. reason: str,
  1936. db: AsyncSession,
  1937. ):
  1938. """Handle job failed to start event (upload error, etc.)."""
  1939. providers = await self._get_providers_for_event(db, "on_queue_job_failed", printer_id)
  1940. if not providers:
  1941. return
  1942. variables = {
  1943. "job_name": job_name,
  1944. "printer": printer_name or "Unknown",
  1945. "reason": reason,
  1946. }
  1947. title, message = await self._build_message_from_template(db, "queue_job_failed", variables)
  1948. await self._send_to_providers(
  1949. providers, title, message, db, "queue_job_failed", printer_id, printer_name, variables=variables
  1950. )
  1951. async def on_queue_completed(
  1952. self,
  1953. completed_count: int,
  1954. db: AsyncSession,
  1955. ):
  1956. """Handle all queue jobs completed event."""
  1957. providers = await self._get_providers_for_event(db, "on_queue_completed", None)
  1958. if not providers:
  1959. return
  1960. variables = {
  1961. "completed_count": str(completed_count),
  1962. }
  1963. title, message = await self._build_message_from_template(db, "queue_completed", variables)
  1964. await self._send_to_providers(providers, title, message, db, "queue_completed", variables=variables)
  1965. # ==================== Inventory Stock Alerts ====================
  1966. async def on_stock_reorder_alert(
  1967. self,
  1968. material: str,
  1969. brand: str | None,
  1970. stock_g: float,
  1971. rate_g_day: float,
  1972. days_left: int,
  1973. db: AsyncSession,
  1974. ):
  1975. """Fire when an inventory SKU reaches its reorder point."""
  1976. providers = await self._get_providers_for_event(db, "on_stock_reorder_alert", None)
  1977. if not providers:
  1978. return
  1979. variables = {
  1980. "material": material,
  1981. "brand": brand or "",
  1982. "stock_g": f"{stock_g:.0f}",
  1983. "rate_g_day": f"{rate_g_day:.1f}",
  1984. "days_left": str(days_left),
  1985. }
  1986. title, message = await self._build_message_from_template(db, "stock_reorder_alert", variables)
  1987. await self._send_to_providers(providers, title, message, db, "stock_reorder_alert", variables=variables)
  1988. async def on_stock_break_alert(
  1989. self,
  1990. material: str,
  1991. brand: str | None,
  1992. stock_g: float,
  1993. rate_g_day: float,
  1994. days_left: int,
  1995. lead_time_days: int,
  1996. db: AsyncSession,
  1997. ):
  1998. """Fire when a stock break is detected (stock runs out before lead time)."""
  1999. providers = await self._get_providers_for_event(db, "on_stock_break_alert", None)
  2000. if not providers:
  2001. return
  2002. variables = {
  2003. "material": material,
  2004. "brand": brand or "",
  2005. "stock_g": f"{stock_g:.0f}",
  2006. "rate_g_day": f"{rate_g_day:.1f}",
  2007. "days_left": str(days_left),
  2008. "lead_time_days": str(lead_time_days),
  2009. }
  2010. title, message = await self._build_message_from_template(db, "stock_break_alert", variables)
  2011. await self._send_to_providers(providers, title, message, db, "stock_break_alert", variables=variables)
  2012. async def _queue_for_digest(
  2013. self,
  2014. provider: NotificationProvider,
  2015. event_type: str,
  2016. title: str,
  2017. message: str,
  2018. db: AsyncSession,
  2019. printer_id: int | None = None,
  2020. printer_name: str | None = None,
  2021. ):
  2022. """Queue a notification for later delivery in the daily digest."""
  2023. try:
  2024. queue_entry = NotificationDigestQueue(
  2025. provider_id=provider.id,
  2026. event_type=event_type,
  2027. title=title,
  2028. message=message,
  2029. printer_id=printer_id,
  2030. printer_name=printer_name,
  2031. )
  2032. db.add(queue_entry)
  2033. await db.commit()
  2034. logger.info("Queued notification for digest: %s for provider %s", event_type, provider.name)
  2035. except Exception as e:
  2036. logger.warning("Failed to queue notification for digest: %s", e)
  2037. async def send_digest(self, provider_id: int):
  2038. """Send all queued notifications as a single digest for a provider."""
  2039. from backend.app.core.database import async_session
  2040. async with async_session() as db:
  2041. # Get the provider
  2042. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  2043. provider = result.scalar_one_or_none()
  2044. if not provider or not provider.enabled:
  2045. return
  2046. # Get all queued notifications for this provider
  2047. result = await db.execute(
  2048. select(NotificationDigestQueue)
  2049. .where(NotificationDigestQueue.provider_id == provider_id)
  2050. .order_by(NotificationDigestQueue.created_at)
  2051. )
  2052. queue_entries = list(result.scalars().all())
  2053. if not queue_entries:
  2054. logger.debug("No queued notifications for provider %s", provider.name)
  2055. return
  2056. # Build digest message
  2057. title = f"Daily Digest - {len(queue_entries)} Events"
  2058. # Group by event type
  2059. events_by_type: dict[str, list] = {}
  2060. for entry in queue_entries:
  2061. if entry.event_type not in events_by_type:
  2062. events_by_type[entry.event_type] = []
  2063. events_by_type[entry.event_type].append(entry)
  2064. # Format the digest body
  2065. body_parts = []
  2066. for event_type, entries in events_by_type.items():
  2067. event_label = event_type.replace("_", " ").title()
  2068. body_parts.append(f"== {event_label} ({len(entries)}) ==")
  2069. for entry in entries:
  2070. time_str = entry.created_at.strftime("%H:%M")
  2071. printer_info = f"[{entry.printer_name}] " if entry.printer_name else ""
  2072. body_parts.append(f" {time_str} {printer_info}{entry.title}")
  2073. body_parts.append("")
  2074. body = "\n".join(body_parts)
  2075. # Send the digest
  2076. success, error = await self._send_to_provider(provider, title, body, db)
  2077. # Log the digest
  2078. await self._log_notification(
  2079. db=db,
  2080. provider_id=provider.id,
  2081. event_type="daily_digest",
  2082. title=title,
  2083. message=body,
  2084. success=success,
  2085. error_message=error if not success else None,
  2086. )
  2087. # Clear the queue
  2088. for entry in queue_entries:
  2089. await db.delete(entry)
  2090. await db.commit()
  2091. if success:
  2092. logger.info("Sent daily digest with %s events to %s", len(queue_entries), provider.name)
  2093. else:
  2094. logger.warning("Failed to send daily digest to %s: %s", provider.name, error)
  2095. async def check_and_send_digests(self):
  2096. """Check all providers and send digests if it's their scheduled time."""
  2097. from backend.app.core.database import async_session
  2098. current_time = datetime.now().strftime("%H:%M")
  2099. # Avoid duplicate checks within the same minute
  2100. if current_time == self._last_digest_check:
  2101. return
  2102. self._last_digest_check = current_time
  2103. async with async_session() as db:
  2104. # Find all providers with digest enabled at this time
  2105. result = await db.execute(
  2106. select(NotificationProvider).where(
  2107. NotificationProvider.enabled.is_(True),
  2108. NotificationProvider.daily_digest_enabled.is_(True),
  2109. NotificationProvider.daily_digest_time == current_time,
  2110. )
  2111. )
  2112. providers = result.scalars().all()
  2113. for provider in providers:
  2114. try:
  2115. await self.send_digest(provider.id)
  2116. except Exception as e:
  2117. logger.error("Error sending digest for provider %s: %s", provider.id, e)
  2118. def start_digest_scheduler(self):
  2119. """Start the background scheduler for daily digest notifications."""
  2120. if self._digest_scheduler_task is None:
  2121. self._digest_scheduler_task = asyncio.create_task(self._digest_scheduler_loop())
  2122. logger.info("Notification digest scheduler started")
  2123. def stop_digest_scheduler(self):
  2124. """Stop the background scheduler for daily digests."""
  2125. if self._digest_scheduler_task:
  2126. self._digest_scheduler_task.cancel()
  2127. self._digest_scheduler_task = None
  2128. logger.info("Notification digest scheduler stopped")
  2129. async def _digest_scheduler_loop(self):
  2130. """Background loop that checks for scheduled digests every minute."""
  2131. while True:
  2132. try:
  2133. await self.check_and_send_digests()
  2134. except Exception as e:
  2135. logger.error("Error in digest scheduler: %s", e)
  2136. # Wait until the next minute
  2137. await asyncio.sleep(60)
  2138. # Global instance
  2139. notification_service = NotificationService()