This commit is contained in:
Mateusz Gruszczyński
2026-08-24 23:11:41 +02:00
parent 4ab878d587
commit 76a6f050ae
24 changed files with 600 additions and 108 deletions
+96 -8
View File
@@ -2,9 +2,9 @@ use std::{net::IpAddr, sync::atomic::Ordering, time::{Duration, Instant}};
use axum::{
body::Body,
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}},
http::{header, HeaderValue, StatusCode},
http::{header, HeaderMap, HeaderValue, StatusCode},
middleware::{self, Next},
response::{Html, Response},
response::{Redirect, Response},
routing::{get, post},
Json, Router,
};
@@ -15,20 +15,22 @@ use rand::{rngs::OsRng, RngCore};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use serde_json::{json, Value};
use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer};
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
use uuid::Uuid;
use crate::{
engine,
error::AppError,
home_assistant,
influxdb,
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
notifications,
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
protocol::merge_discovered,
state::AppState,
};
const INDEX_HTML: &str = include_str!("../web/index.html");
const APP_JS: &str = include_str!("../web/app.js");
const THEME_INIT_JS: &str = include_str!("../web/theme-init.js");
const STYLES_CSS: &str = include_str!("../web/styles.css");
const MANIFEST: &str = include_str!("../web/manifest.webmanifest");
const SERVICE_WORKER: &str = include_str!("../web/sw.js");
@@ -67,6 +69,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
.route("/api/integrations/notifications/test", post(test_notifications))
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
let home_assistant_api = Router::new()
@@ -76,12 +79,13 @@ pub fn router(state: AppState) -> Router {
.route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control))
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
Router::new()
let app = Router::new()
.route("/api/health", get(health))
.route("/ws", get(websocket))
.route("/", get(index))
.route("/index.html", get(index))
.route("/app.js", get(app_js))
.route("/theme-init.js", get(theme_init_js))
.route("/styles.css", get(styles_css))
.route("/manifest.webmanifest", get(manifest))
.route("/sw.js", get(service_worker))
@@ -91,9 +95,25 @@ pub fn router(state: AppState) -> Router {
.merge(protected)
.merge(home_assistant_api)
.fallback(index)
;
let app = if state.config.base_path.is_empty() {
app
} else {
let base = state.config.base_path.clone();
let redirect_to = format!("{base}/");
Router::new()
.route(&base, get(move || {
let redirect_to = redirect_to.clone();
async move { Redirect::permanent(&redirect_to) }
}))
.nest(&base, app)
};
app
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http())
.layer(middleware::from_fn(security_headers))
.layer(middleware::from_fn_with_state(state.clone(), debug_api_requests))
.with_state(state)
}
@@ -466,7 +486,7 @@ impl ZoneInput {
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None,
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
demand: false, last_action_at: None,
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
created_at, updated_at: Utc::now(),
}
}
@@ -503,6 +523,8 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.effective_setpoint = existing.effective_setpoint;
zone.device_setpoint = existing.device_setpoint;
zone.demand = existing.demand;
zone.demand_since = existing.demand_since;
zone.target_alerted_at = existing.target_alerted_at;
zone.last_action_at = existing.last_action_at;
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
@@ -1129,6 +1151,15 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
}
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
if input.notifications.pushover_app_token.trim().is_empty() { input.notifications.pushover_app_token = old.notifications.pushover_app_token; }
if input.notifications.pushover_user_key.trim().is_empty() { input.notifications.pushover_user_key = old.notifications.pushover_user_key; }
if input.notifications.slack_webhook_url.trim().is_empty() { input.notifications.slack_webhook_url = old.notifications.slack_webhook_url; }
if input.notifications.discord_webhook_url.trim().is_empty() { input.notifications.discord_webhook_url = old.notifications.discord_webhook_url; }
input.notifications.cooldown_seconds = input.notifications.cooldown_seconds.clamp(30, 86_400);
input.notifications.communication_failure_threshold = input.notifications.communication_failure_threshold.clamp(2, 100);
input.notifications.target_timeout_minutes = input.notifications.target_timeout_minutes.clamp(5, 24 * 60);
if !matches!(input.notifications.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest("notification mode must be problems or important".into())); }
if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); }
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
normalize_sensor_aliases(&mut input);
@@ -1316,6 +1347,29 @@ async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<Ha
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id})))
}
async fn test_notifications(State(state): State<AppState>, Json(mut input): Json<NotificationSettings>) -> Result<Json<Value>, AppError> {
let old = state.settings.read().await.notifications.clone();
if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; }
if input.pushover_user_key.trim().is_empty() { input.pushover_user_key = old.pushover_user_key; }
if input.slack_webhook_url.trim().is_empty() { input.slack_webhook_url = old.slack_webhook_url; }
if input.discord_webhook_url.trim().is_empty() { input.discord_webhook_url = old.discord_webhook_url; }
notifications::test(&state, input).await.map_err(AppError::Device)?;
Ok(Json(json!({"ok": true})))
}
async fn security_headers(request: Request, next: Next) -> Response {
let is_api = request.uri().path().contains("/api/");
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(header::HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff"));
headers.insert(header::HeaderName::from_static("x-frame-options"), HeaderValue::from_static("SAMEORIGIN"));
headers.insert(header::HeaderName::from_static("referrer-policy"), HeaderValue::from_static("same-origin"));
headers.insert(header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'"));
headers.insert(header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()"));
if is_api { headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); }
response
}
fn public_settings(settings: &RuntimeSettings) -> Value {
json!({
"controller_id": settings.controller_id,
@@ -1333,6 +1387,21 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
"suppress_device_beep": settings.suppress_device_beep,
"debug": settings.debug,
"night_mode": settings.night_mode,
"notifications": {
"enabled": settings.notifications.enabled,
"mode": settings.notifications.mode,
"provider": settings.notifications.provider,
"pushover_app_token": "",
"pushover_user_key": "",
"pushover_configured": !settings.notifications.pushover_app_token.trim().is_empty() && !settings.notifications.pushover_user_key.trim().is_empty(),
"slack_webhook_url": "",
"slack_configured": !settings.notifications.slack_webhook_url.trim().is_empty(),
"discord_webhook_url": "",
"discord_configured": !settings.notifications.discord_webhook_url.trim().is_empty(),
"cooldown_seconds": settings.notifications.cooldown_seconds,
"communication_failure_threshold": settings.notifications.communication_failure_threshold,
"target_timeout_minutes": settings.notifications.target_timeout_minutes,
},
"influxdb": {
"enabled": settings.influxdb.enabled,
"version": settings.influxdb.version,
@@ -1399,8 +1468,27 @@ async fn websocket_loop(state: AppState, mut socket: WebSocket) {
}
}
async fn index() -> Html<&'static str> { Html(INDEX_HTML) }
async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
let base = if !state.config.base_path.is_empty() {
state.config.base_path.clone()
} else {
forwarded_prefix(&headers).unwrap_or_default()
};
let body = INDEX_HTML.replace("__GREE_BASE_PATH__", &base);
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
response
}
fn forwarded_prefix(headers: &HeaderMap) -> Option<String> {
let raw = headers.get("x-forwarded-prefix")?.to_str().ok()?.split(',').next()?.trim();
if raw.is_empty() || raw == "/" { return Some(String::new()); }
if raw.contains('?') || raw.contains('#') || raw.split('/').any(|part| matches!(part, "." | "..")) { return None; }
Some(format!("/{}", raw.trim_matches('/')))
}
async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") }
async fn theme_init_js() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "public, max-age=86400") }
async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") }
async fn manifest() -> Response { static_response(MANIFEST, "application/manifest+json", "public, max-age=3600") }
async fn service_worker() -> Response { static_response(SERVICE_WORKER, "application/javascript; charset=utf-8", "no-cache") }
+15 -2
View File
@@ -1,7 +1,7 @@
use std::{env, net::SocketAddr, path::PathBuf};
use anyhow::{Context, Result};
use clap::Parser;
use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, RuntimeSettings};
use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, NotificationSettings, RuntimeSettings};
#[derive(Debug, Clone, Parser)]
#[command(author, version, about)]
@@ -12,6 +12,8 @@ pub struct Config {
pub database: PathBuf,
#[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")]
pub app_token: String,
#[arg(long, env = "GREE_CONTROLLER_BASE_PATH", default_value = "")]
pub base_path: String,
#[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = false)]
pub simulate: bool,
#[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = false)]
@@ -33,7 +35,8 @@ pub struct Config {
impl Config {
pub fn load() -> Result<Self> {
dotenvy::dotenv().ok();
let config = Self::parse();
let mut config = Self::parse();
config.base_path = normalize_base_path(&config.base_path)?;
if let Some(parent) = config.database.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create database directory {}", parent.display()))?;
@@ -61,6 +64,7 @@ impl Config {
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false),
},
notifications: NotificationSettings::default(),
night_mode: NightModeSettings {
enabled: env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED").unwrap_or(false),
start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START").unwrap_or_else(|_| "22:00".into()),
@@ -128,6 +132,15 @@ impl Config {
}
}
fn normalize_base_path(value: &str) -> Result<String> {
let value = value.trim();
if value.is_empty() || value == "/" { return Ok(String::new()); }
if value.contains('?') || value.contains('#') || value.split('/').any(|part| matches!(part, "." | "..")) {
anyhow::bail!("GREE_CONTROLLER_BASE_PATH must be a simple URL path without '.', '..', query or fragment");
}
Ok(format!("/{}", value.trim_matches('/')))
}
fn env_bool(name: &str) -> Option<bool> {
env::var(name).ok().map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
}
+33 -1
View File
@@ -198,6 +198,7 @@ async fn poll_device(state: &AppState, device: &mut Device) {
simulate_tick(device);
return;
}
let previous_failures = device.communication_failures;
if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(device).await {
Ok(bound) => {
@@ -207,6 +208,7 @@ async fn poll_device(state: &AppState, device: &mut Device) {
}
Err(err) => {
record_poll_failure(device, &err.to_string());
log_poll_health_transition(state, device, previous_failures).await;
return;
}
}
@@ -225,6 +227,18 @@ async fn poll_device(state: &AppState, device: &mut Device) {
Err(_) => record_poll_failure(device, &first_err.to_string()),
}
}
log_poll_health_transition(state, device, previous_failures).await;
}
async fn log_poll_health_transition(state: &AppState, device: &Device, previous_failures: u32) {
let threshold = state.settings.read().await.notifications.communication_failure_threshold.max(2);
if device.communication_failures >= threshold && previous_failures < threshold {
state.log("warn", "device.offline", &format!("{} did not respond {} times in a row", device.name, device.communication_failures), json!({
"device_id": device.id, "consecutive_failures": device.communication_failures, "threshold": threshold
}));
} else if device.communication_failures == 0 && previous_failures >= threshold {
state.log("info", "device.recovered", &format!("{} is responding again", device.name), json!({"device_id": device.id}));
}
}
fn simulate_tick(device: &mut Device) {
@@ -472,6 +486,24 @@ async fn control_zones(state: &AppState) -> Result<()> {
else { zone.demand }
}
};
if zone.demand && !previous_demand {
zone.demand_since = Some(Utc::now());
zone.target_alerted_at = None;
} else if !zone.demand {
zone.demand_since = None;
zone.target_alerted_at = None;
}
if zone.demand && zone.target_alerted_at.is_none() {
let timeout_minutes = settings.notifications.target_timeout_minutes.max(5) as i64;
if let Some(since) = zone.demand_since {
if (Utc::now() - since).num_minutes() >= timeout_minutes {
state.log("warn", "zone.target_timeout", &format!("Zone {} has not reached {:.1} C within {} minutes", zone.name, target, timeout_minutes), json!({
"zone_id": zone.id, "room_temperature": temp, "target_temperature": target, "minutes": timeout_minutes
}));
zone.target_alerted_at = Some(Utc::now());
}
}
}
// Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor
// stop naturally when we move the target to the satisfied side of room temperature.
@@ -1117,7 +1149,7 @@ mod tests {
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
manual_preset: None, manual_setpoint: None, manual_override_until: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
demand: false, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
}
}
+1
View File
@@ -6,6 +6,7 @@ mod error;
mod home_assistant;
mod influxdb;
mod models;
mod notifications;
mod protocol;
mod queries;
mod state;
+52
View File
@@ -326,6 +326,10 @@ pub struct Zone {
#[serde(default)]
pub demand: bool,
#[serde(default)]
pub demand_since: Option<DateTime<Utc>>,
#[serde(default)]
pub target_alerted_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_action_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -512,6 +516,52 @@ impl Default for InfluxDbSettings {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationSettings {
#[serde(default)]
pub enabled: bool,
/// problems = warnings/errors/anomalies, important = problems plus important state changes.
#[serde(default = "default_notification_mode")]
pub mode: String,
/// pushover, slack, discord
#[serde(default = "default_notification_provider")]
pub provider: String,
#[serde(default)]
pub pushover_app_token: String,
#[serde(default)]
pub pushover_user_key: String,
#[serde(default)]
pub slack_webhook_url: String,
#[serde(default)]
pub discord_webhook_url: String,
#[serde(default = "default_notification_cooldown")]
pub cooldown_seconds: u64,
#[serde(default = "default_notification_failure_threshold")]
pub communication_failure_threshold: u32,
#[serde(default = "default_notification_target_timeout")]
pub target_timeout_minutes: u32,
}
fn default_notification_mode() -> String { "problems".into() }
fn default_notification_provider() -> String { "pushover".into() }
fn default_notification_cooldown() -> u64 { 300 }
fn default_notification_failure_threshold() -> u32 { 3 }
fn default_notification_target_timeout() -> u32 { 60 }
impl Default for NotificationSettings {
fn default() -> Self {
Self {
enabled: false, mode: default_notification_mode(), provider: default_notification_provider(),
pushover_app_token: String::new(), pushover_user_key: String::new(),
slack_webhook_url: String::new(), discord_webhook_url: String::new(),
cooldown_seconds: default_notification_cooldown(),
communication_failure_threshold: default_notification_failure_threshold(),
target_timeout_minutes: default_notification_target_timeout(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DebugSettings {
#[serde(default)]
@@ -661,6 +711,8 @@ pub struct RuntimeSettings {
pub debug: DebugSettings,
#[serde(default)]
pub night_mode: NightModeSettings,
#[serde(default)]
pub notifications: NotificationSettings,
pub home_assistant: HomeAssistantSettings,
}
+83
View File
@@ -0,0 +1,83 @@
use std::{collections::HashMap, sync::{Mutex, OnceLock}};
use chrono::Utc;
use serde_json::{json, Value};
use crate::{models::NotificationSettings, state::AppState};
static LAST_SENT: OnceLock<Mutex<HashMap<String, i64>>> = OnceLock::new();
fn important_kind(kind: &str) -> bool {
matches!(kind,
"device.offline" | "device.recovered" | "automation.fired" | "automation.error" |
"zone.target_timeout" | "zone.action_error" | "house.mode" | "house.preset" |
"device.communication_error")
}
fn should_send(cfg: &NotificationSettings, level: &str, kind: &str) -> bool {
if !cfg.enabled { return false; }
if level == "error" || level == "warn" { return true; }
cfg.mode == "important" && important_kind(kind)
}
fn cooldown_key(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> String {
let entity = metadata.get("device_id").or_else(|| metadata.get("zone_id")).or_else(|| metadata.get("automation_id"))
.and_then(Value::as_str).unwrap_or("global");
format!("{}:{}:{}", cfg.provider, kind, entity)
}
fn take_cooldown(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> bool {
let now = Utc::now().timestamp();
let key = cooldown_key(cfg, kind, metadata);
let map = LAST_SENT.get_or_init(|| Mutex::new(HashMap::new()));
let Ok(mut map) = map.lock() else { return false; };
if let Some(last) = map.get(&key) {
if now - *last < cfg.cooldown_seconds.max(30) as i64 { return false; }
}
map.insert(key, now);
true
}
pub async fn dispatch(state: AppState, level: String, kind: String, message: String, metadata: Value) {
let cfg = state.settings.read().await.notifications.clone();
if !should_send(&cfg, &level, &kind) || !take_cooldown(&cfg, &kind, &metadata) { return; }
let title = format!("GREE Controller - {}", if level == "error" { "error" } else if level == "warn" { "warning" } else { "event" });
let result = match cfg.provider.as_str() {
"pushover" => send_pushover(&state, &cfg, &title, &message).await,
"slack" => send_webhook(&state, &cfg.slack_webhook_url, json!({"text": format!("*{}*\\n{}", title, message)})).await,
"discord" => send_webhook(&state, &cfg.discord_webhook_url, json!({"content": format!("**{}**\\n{}", title, message)})).await,
_ => Err("unsupported notification provider".into()),
};
if let Err(err) = result {
tracing::warn!(provider=%cfg.provider, error=%err, "notification delivery failed");
}
}
async fn send_pushover(state: &AppState, cfg: &NotificationSettings, title: &str, message: &str) -> Result<(), String> {
if cfg.pushover_app_token.trim().is_empty() || cfg.pushover_user_key.trim().is_empty() { return Err("Pushover credentials are incomplete".into()); }
let response = state.http.post("https://api.pushover.net/1/messages.json")
.form(&[("token", cfg.pushover_app_token.as_str()), ("user", cfg.pushover_user_key.as_str()), ("title", title), ("message", message)])
.send().await.map_err(|e| e.to_string())?;
if response.status().is_success() { Ok(()) } else { Err(format!("Pushover HTTP {}", response.status())) }
}
async fn send_webhook(state: &AppState, url: &str, body: Value) -> Result<(), String> {
let parsed = url::Url::parse(url).map_err(|_| "invalid webhook URL".to_string())?;
if parsed.scheme() != "https" { return Err("webhook URL must use HTTPS".into()); }
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
let allowed = host == "hooks.slack.com" || host == "discord.com" || host == "discordapp.com";
if !allowed { return Err("webhook host is not supported".into()); }
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(10))
.build().map_err(|e| e.to_string())?;
let response = client.post(parsed).json(&body).send().await.map_err(|e| e.to_string())?;
if response.status().is_success() { Ok(()) } else { Err(format!("webhook HTTP {}", response.status())) }
}
pub async fn test(state: &AppState, cfg: NotificationSettings) -> Result<(), String> {
match cfg.provider.as_str() {
"pushover" => send_pushover(state, &cfg, "GREE Controller", "Test notification").await,
"slack" => send_webhook(state, &cfg.slack_webhook_url, json!({"text":"GREE Controller - test notification"})).await,
"discord" => send_webhook(state, &cfg.discord_webhook_url, json!({"content":"GREE Controller - test notification"})).await,
_ => Err("unsupported notification provider".into()),
}
}
+7
View File
@@ -36,5 +36,12 @@ impl AppState {
"message": message,
"metadata": metadata,
}));
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let state = self.clone();
let level = level.to_string();
let kind = kind.to_string();
let message = message.to_string();
handle.spawn(async move { crate::notifications::dispatch(state, level, kind, message, metadata).await; });
}
}
}