From a407f209a8d648af5416da481a38fcce2c372d70 Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Sun, 25 Jan 2026 12:41:18 +0100 Subject: [PATCH] Messaging templates (#534) Messaging templates --- CHANGELOG.md | 12 + Cargo.lock | 6 +- README.md | 49 +- backend/Cargo.toml | 2 +- backend/src/api/endpoints/v1_api_config.rs | 19 +- backend/src/messaging.rs | 432 +++++++++++++++--- backend/src/model/config/base.rs | 4 + backend/src/model/config/messaging.rs | 62 ++- backend/src/model/messaging.rs | 80 ++++ backend/src/model/mod.rs | 7 +- backend/src/processing/playlist_watch.rs | 25 +- backend/src/processing/processor/playlist.rs | 39 +- backend/src/repository/alias_repository.rs | 4 +- backend/src/utils/file/config_reader.rs | 87 +++- backend/src/utils/network/request.rs | 22 +- backend/src/utils/network/xtream.rs | 41 +- backend/src/utils/telegram.rs | 3 + bin/release.sh | 2 + .../messaging_templates/discord_stats.templ | 37 ++ .../messaging_templates/telegram_stats.templ | 24 + frontend/Cargo.toml | 4 +- frontend/public/assets/i18n/en.json | 7 +- .../components/config/_messaging_config.scss | 22 + frontend/src/app/components/config/macros.rs | 23 + .../config/messaging_config_view.rs | 123 ++++- frontend/src/app/components/mod.rs | 2 + frontend/src/app/components/textarea.rs | 85 ++++ shared/Cargo.toml | 2 +- shared/src/model/config/base.rs | 4 + shared/src/model/config/messaging.rs | 22 +- shared/src/model/messaging.rs | 10 +- shared/src/model/mod.rs | 2 + {backend => shared}/src/model/stats.rs | 39 +- 33 files changed, 1074 insertions(+), 228 deletions(-) create mode 100644 backend/src/model/messaging.rs create mode 100644 config/messaging_templates/discord_stats.templ create mode 100644 config/messaging_templates/telegram_stats.templ create mode 100644 frontend/src/app/components/textarea.rs rename {backend => shared}/src/model/stats.rs (65%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dee6bdde..714dba515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog + ## 3.3.0 (2026-01-03) ## ⚠️ Breaking Changes @@ -101,6 +102,17 @@ - **Optimization**: Significant EPG memory reduction. - **Optimization**: Improved EPG parsing performance. +## ⚙️ Messaging Refactoring +- **Structured Messaging**: Transitioned from JSON-string-based notifications to a strictly typed messaging pipeline. +- **Backend Model Migration**: Moved complex messaging models (`WatchChanges`, `ProcessingStats`) from the shared crate to the backend to reduce shared-library overhead. +- **Unified API**: Consolidated all notification types into a single, type-safe `send_message` function. +- **Template Improvements**: + - Added per-message-type templates for Telegram, Discord, and REST messaging channels with support for Info, Stats, Error, and Watch notifications. + - Renamed template context fields for better clarity (e.g., `event` → `processing`). + - Improved data accessibility in Handlebars templates with optimized context mapping (e.g., `{{processing.stats}}` or shorthand `{{stats}}`). + - Implemented template loading from files and HTTP/HTTPS URIs with automatic discovery from configuration directories. + - Added UI components for managing per-type templates with textarea editor support. + # 3.2.0 (2025-11-14) - Added `name` attribute to Staged Input. - Real-time active provider connection monitoring (dashboard + websocket) diff --git a/Cargo.lock b/Cargo.lock index c81947a8d..0dec0dad1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,7 +1156,7 @@ dependencies = [ [[package]] name = "frontend" -version = "3.2.51" +version = "3.2.52" dependencies = [ "anyhow", "base64", @@ -4015,7 +4015,7 @@ dependencies = [ [[package]] name = "shared" -version = "3.2.51" +version = "3.2.52" dependencies = [ "base64", "bitflags 2.10.0", @@ -4592,7 +4592,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tuliprox" -version = "3.2.51" +version = "3.2.52" dependencies = [ "arc-swap", "async-compression", diff --git a/README.md b/README.md index b05709836..cdfe315b7 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Options: --force-library-rescan Force full library rescan --dbx Database file type: xtream --dbm Database file type: m3u + --dbe Database file type: epg ``` ## 1. `config.yml` @@ -146,15 +147,19 @@ messaging: chat_ids: - '' - ':' + templates: # templates per message kind + stats: 'file:///path/to/stats_telegram.templ' discord: url: '' - template: '{"content": "{{message}}"}' # optional handlebars template + templates: + info: '{"content": "{{message}}"}' rest: url: '' method: 'POST' # optional, default POST headers: - 'Content-Type: application/json' - template: '{"text": "{{message}}"}' # optional handlebars template + templates: + error: '{"text": "Error: {{message}}"}' pushover: token: @@ -163,20 +168,36 @@ messaging: ``` ### 1.4.1 Messaging Templating -For `discord` and `rest` messaging, you can use [Handlebars](https://handlebarsjs.com/) templates to format the message body. +For `discord`, `telegram` and `rest` messaging, you can use [Handlebars](https://handlebarsjs.com/) templates to format the message body. -**Context Variables**: -- `message`: The raw message content or event summary. +**Loading Templates:** +Templates can be provided in two ways: +1. **Raw String**: The template content is written directly in the configuration. +2. **URI**: A link to a file (`file://...`) or an external resource (`http(s)://...`). +> **Note**: When saving through the Web UI, raw template strings are automatically moved to individual files in `/config/messaging_templates/` and referenced via `file://` to keep the configuration file clean. + +**Context Variables:** +- `message`: The text content for `info` and `error` notifications. - `kind`: The type of notification (`info`, `stats`, `error`, `watch`). - `timestamp`: Current UTC timestamp in RFC3339 format. -- `event`: If the message is a JSON string (like `watch` events), it is parsed and available as an object. +- `stats`: A list of processed source statistics (available for `stats` kind). + - Each item contains `inputs` (list of `InputStats`) and `targets` (list of `TargetStats`). +- `watch`: Change details for groups (available for `watch` kind). +- `processing`: Detailed internal processing state. + - `errors`: Combined error messages from a processing run. -**Example REST Template (JSON)**: +**Example Multi-Source Telegram Template**: ```handlebars -{ - "summary": "Tuliprox {{kind}}: {{message}}", - "occurred_at": "{{timestamp}}" -} +*🔄 Playlist Update Report* + +{{#each stats}} +*📥 Source Stats* +{{#each inputs}} +• *{{name}}* (`{{type}}`) + ⏱️ Took: `{{took}}` | ❌ Errors: `{{errors}}` + 📊 `{{raw.groups}}`/`{{raw.channels}}` ➔ *`{{processed.groups}}`*/*`{{processed.channels}}`* +{{/each}} +{{/each}} ``` **Example Discord Template (Complex Embed)**: @@ -187,6 +208,11 @@ For `discord` and `rest` messaging, you can use [Handlebars](https://handlebarsj "title": "Event: {{kind}}", "description": "{{message}}", "color": 3447003, + "fields": [ + {{#each stats}} + { "name": "Source {{@index}}", "value": "Processed {{#each inputs}}{{name}} {{/each}}", "inline": false } + {{/each}} + ], "footer": { "text": "Reported at {{timestamp}}" } }] } @@ -744,6 +770,7 @@ library: # Show db content ./tuliprox --dbx /opt/tuliprox/data/all_channels/xtream/video.db ./tuliprox --dbm /opt/tuliprox/data/all_channels/m3u.db +./tuliprox --dbe /opt/tuliprox/data/all_channels/xtream/epg.db ``` **API Endpoints**: diff --git a/backend/Cargo.toml b/backend/Cargo.toml index bc8fa22a8..74788f563 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuliprox" -version = "3.2.51" +version = "3.2.52" edition = "2021" rust-version = "1.89.0" diff --git a/backend/src/api/endpoints/v1_api_config.rs b/backend/src/api/endpoints/v1_api_config.rs index 841545a69..663d0102a 100644 --- a/backend/src/api/endpoints/v1_api_config.rs +++ b/backend/src/api/endpoints/v1_api_config.rs @@ -1,16 +1,16 @@ +use crate::api::api_utils::{internal_server_error, try_unwrap_body}; use crate::api::model::AppState; use crate::model::{ApiProxyConfig, InputSource}; +use crate::utils::request::{download_text_content}; +use crate::utils::{persist_messaging_templates, prepare_sources_batch, prepare_users}; +use crate::utils; use axum::response::IntoResponse; use axum::Router; +use log::error; use serde_json::json; +use shared::error::{TuliproxError}; use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigDto, SourcesConfigDto}; use std::sync::Arc; -use log::{error}; -use shared::error::TuliproxError; -use crate::api::api_utils::{try_unwrap_body, internal_server_error}; -use crate::{utils}; -use crate::utils::{prepare_sources_batch, prepare_users}; -use crate::utils::request::download_text_content; pub(in crate::api::endpoints) async fn intern_save_config_api_proxy(backup_dir: &str, api_proxy: &ApiProxyConfigDto, file_path: &str) -> Option { match utils::save_api_proxy(file_path, backup_dir, api_proxy).await { @@ -36,9 +36,14 @@ async fn intern_save_config_main(file_path: &str, backup_dir: &str, cfg: &Config async fn save_config_main( axum::extract::State(app_state): axum::extract::State>, - axum::extract::Json(cfg): axum::extract::Json, + axum::extract::Json(mut cfg): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { if cfg.is_valid() { + if let Err(err) = persist_messaging_templates(&app_state, &mut cfg).await { + error!("Failed to persist messaging templates: {err}"); + return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); + } + let paths = app_state.app_config.paths.load(); let file_path = paths.config_file_path.as_str(); let config = app_state.app_config.config.load(); diff --git a/backend/src/messaging.rs b/backend/src/messaging.rs index 712ff3eb9..46fe7c2d3 100644 --- a/backend/src/messaging.rs +++ b/backend/src/messaging.rs @@ -1,53 +1,103 @@ -use crate::model::MessagingConfig; +use crate::model::{AppConfig, InputSource, MessagingConfig, MessageContent, TemplateContext}; use crate::utils::{telegram_create_instance, telegram_send_message, SendMessageOption, SendMessageParseMode}; use chrono::Utc; -use handlebars::Handlebars; +use handlebars::{Context, Handlebars, Helper, HelperResult, Output, RenderContext}; use log::{debug, error}; use reqwest::{header, Method}; -use serde_json::{json, Value}; -use shared::model::MsgKind; -use shared::utils::json_str_to_markdown; +use serde_json::json; +use shared::model::{InputFetchMethod, MsgKind}; +use shared::utils::{json_str_to_markdown, Internable}; use std::borrow::Cow; +use std::collections::HashMap; use std::str::FromStr; -use std::sync::LazyLock; +use std::sync::{Arc, LazyLock}; +use crate::utils::request::download_text_content; fn is_enabled(kind: MsgKind, cfg: &MessagingConfig) -> bool { cfg.notify_on.contains(&kind) } -static HANDLEBARS: LazyLock = LazyLock::new(Handlebars::new); -fn render_template(template: Option<&str>, msg: &str, kind: MsgKind) -> String { +static HANDLEBARS: LazyLock = LazyLock::new(|| { + let mut h = Handlebars::new(); + h.register_helper("json_escape", Box::new(|h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output| -> HelperResult { + let param = h.param(0).and_then(|v| v.value().as_str()).unwrap_or(""); + let escaped = serde_json::to_string(param).unwrap_or_else(|_| "".to_string()); + if escaped.len() >= 2 { + out.write(&escaped[1..escaped.len()-1])?; + } + Ok(()) + })); + h +}); + +async fn render_template(app_config: &Arc, http_client: &reqwest::Client, template: Option<&str>, content: &MessageContent) -> String { let timestamp = Utc::now().to_rfc3339(); + let kind = content.kind().to_string(); - let mut data = json!({ - "message": msg, - "kind": kind.to_string(), - "timestamp": timestamp, - }); + let mut template_context = TemplateContext { + kind, + timestamp, + message: None, + stats: None, + watch: None, + processing: None, + flat_stats: None, + }; - if let Ok(json_val) = serde_json::from_str::(msg) { - if let Some(obj) = data.as_object_mut() { - obj.insert("event".to_string(), json_val); + match content { + MessageContent::Info(msg) | MessageContent::Error(msg) => { + template_context.message = Some(msg); + } + MessageContent::Watch(changes) => { + template_context.watch = Some(changes); + } + MessageContent::ProcessingStats(stats) => { + template_context.processing = Some(stats.clone()); + if let Some(stats) = &stats.stats { + template_context.stats = Some(stats); + if let Some(first_source) = stats.first() { + if let Some(first_input) = first_source.inputs.first() { + template_context.flat_stats = Some(first_input.clone()); + } + } + } + if let Some(errors) = &stats.errors { + template_context.message = Some(errors); + } } } match template { - Some(t) => { - match HANDLEBARS.render_template(t, &data) { + Some(template_content_or_uri) => { + let t = resolve_template(app_config, http_client, template_content_or_uri).await; + + match HANDLEBARS.render_template(&t, &template_context) { Ok(rendered) => rendered, Err(e) => { error!("Failed to render template: {e}"); - msg.to_string() + match content { + MessageContent::Info(s) | MessageContent::Error(s) => s.clone(), + MessageContent::Watch(w) => serde_json::to_string(w).unwrap_or_default(), + MessageContent::ProcessingStats(ps) => serde_json::to_string(ps).unwrap_or_default(), + } } } } - None => msg.to_string(), + None => { + match content { + MessageContent::Info(s) | MessageContent::Error(s) => s.clone(), + MessageContent::Watch(w) => serde_json::to_string(w).unwrap_or_default(), + MessageContent::ProcessingStats(ps) => serde_json::to_string(ps).unwrap_or_default(), + } + } } } -async fn send_rest_message(client: &reqwest::Client, msg: &str, kind: MsgKind, messaging: &MessagingConfig) { +async fn send_rest_message(app_config: &Arc, client: &reqwest::Client, content: &MessageContent, messaging: &MessagingConfig) { if let Some(rest) = &messaging.rest { - let body = render_template(rest.template.as_deref(), msg, kind); + let kind = content.kind(); + let template = rest.templates.get(&kind).map(String::as_str); + let body = render_template(app_config, client, template, content).await; let method = Method::from_str(&rest.method).unwrap_or(Method::POST); let mut rb = client.request(method, &rest.url); @@ -74,12 +124,21 @@ async fn send_rest_message(client: &reqwest::Client, msg: &str, kind: MsgKind, m } } -async fn send_discord_message(client: &reqwest::Client, msg: &str, kind: MsgKind, messaging: &MessagingConfig) { +async fn send_discord_message(app_config: &Arc, client: &reqwest::Client, content: &MessageContent, messaging: &MessagingConfig) { if let Some(discord) = &messaging.discord { - let body = if let Some(template) = &discord.template { - render_template(Some(template), msg, kind) + let kind = content.kind(); + let template = discord.templates.get(&kind).map(String::as_str); + + let body = if let Some(templ) = template { + render_template(app_config, client, Some(templ), content).await } else { - json!({ "content": msg }).to_string() + // Default json formatting + let msg_str = match content { + MessageContent::Info(s) | MessageContent::Error(s) => s.clone(), + MessageContent::Watch(s) => serde_json::to_string(s).unwrap_or_default(), + MessageContent::ProcessingStats(ps) => serde_json::to_string(ps).unwrap_or_default(), + }; + json!({ "content": msg_str }).to_string() }; match client @@ -101,34 +160,60 @@ async fn send_discord_message(client: &reqwest::Client, msg: &str, kind: MsgKind } } -async fn send_telegram_message(client: &reqwest::Client, msg: &str, messaging: &MessagingConfig, json: bool) { - // TODO use proxy settings +async fn send_telegram_message(app_config: &Arc, client: &reqwest::Client, content: &MessageContent, messaging: &MessagingConfig) { if let Some(telegram) = &messaging.telegram { + let kind = content.kind(); + let template = telegram.templates.get(&kind).map(String::as_str); + + let msg = if let Some(templ) = template { + render_template(app_config, client, Some(templ), content).await + } else { + let serialized; + match content { + MessageContent::Info(s) | MessageContent::Error(s) => s.clone(), + MessageContent::Watch(s) => { + serialized = serde_json::to_string_pretty(s).unwrap_or_default(); + serialized + } + MessageContent::ProcessingStats(ps) => { + serialized = serde_json::to_string_pretty(ps).unwrap_or_default(); + serialized + } + } + }; + let (message, options) = { - if json && telegram.markdown { - if let Ok(md) = json_str_to_markdown(msg) { + if telegram.markdown { + if let Ok(md) = json_str_to_markdown(&msg) { (Cow::Owned(md), Some(SendMessageOption { parse_mode: SendMessageParseMode::MarkdownV2 })) } else { - (Cow::Borrowed(msg), None) + // If it's already rendered markdown (from template), just use it + (Cow::Borrowed(&msg), Some(SendMessageOption { parse_mode: SendMessageParseMode::MarkdownV2 })) } } else { - (Cow::Borrowed(msg), None) + (Cow::Borrowed(&msg), None) } }; for chat_id in &telegram.chat_ids { let bot = telegram_create_instance(&telegram.bot_token, chat_id); - telegram_send_message(client, &bot, &message, options.as_ref()).await; + telegram_send_message(app_config, client, &bot, &message, options.as_ref()).await; } } } -async fn send_pushover_message(client: &reqwest::Client, msg: &str, messaging: &MessagingConfig) { +async fn send_pushover_message(_app_config: &Arc, client: &reqwest::Client, content: &MessageContent, messaging: &MessagingConfig) { if let Some(pushover) = &messaging.pushover { + let msg = match content { + MessageContent::Info(s) | MessageContent::Error(s) => s.clone(), + MessageContent::Watch(s) => serde_json::to_string_pretty(s).unwrap_or_default(), + MessageContent::ProcessingStats(ps) => serde_json::to_string_pretty(ps).unwrap_or_default(), + }; + let encoded_message: String = url::form_urlencoded::Serializer::new(String::new()) .append_pair("token", pushover.token.as_str()) .append_pair("user", pushover.user.as_str()) - .append_pair("message", msg) + .append_pair("message", &msg) .finish(); match client .post(&pushover.url) @@ -149,63 +234,266 @@ async fn send_pushover_message(client: &reqwest::Client, msg: &str, messaging: & } } -async fn dispatch_send_message(client: &reqwest::Client, kind: MsgKind, cfg: Option<&MessagingConfig>, msg: &str, json: bool) { - if let Some(messaging) = cfg { +async fn dispatch_send_message(app_config: &Arc, client: &reqwest::Client, content: MessageContent) { + let cfg = app_config.config.load(); + let msg_cfg = cfg.messaging.as_ref(); + if let Some(messaging) = msg_cfg { + let kind = content.kind(); if is_enabled(kind, messaging) { tokio::join!( - send_telegram_message(client, msg, messaging, json), - send_rest_message(client, msg, kind, messaging), - send_pushover_message(client, msg, messaging), - send_discord_message(client, msg, kind, messaging) + send_telegram_message(app_config, client, &content, messaging), + send_rest_message(app_config, client, &content, messaging), + send_pushover_message(app_config, client, &content, messaging), + send_discord_message(app_config, client, &content, messaging) ); } } } -pub async fn send_message_json(client: &reqwest::Client, kind: MsgKind, cfg: Option<&MessagingConfig>, msg: &str) { - dispatch_send_message(client, kind, cfg, msg, true).await; +pub async fn send_message(app_config: &Arc, client: &reqwest::Client, content: MessageContent) { + dispatch_send_message(app_config, client, content).await; } -pub async fn send_message(client: &reqwest::Client, kind: MsgKind, cfg: Option<&MessagingConfig>, msg: &str) { - dispatch_send_message(client, kind, cfg, msg, false).await; +async fn resolve_template<'a>(app_config: &'a Arc, http_client: &'a reqwest::Client, template: &'a str) -> Cow<'a, str> { + let url = template.to_string(); + + let input_source = InputSource { + name: "Template".intern(), + url, + username: None, + password: None, + method: InputFetchMethod::GET, + headers: HashMap::default(), + }; + if let Ok((content, _response_url)) = download_text_content( + app_config, + http_client, + &input_source, + None, + None, + false, + ).await { + Cow::Owned(content) + } else { + Cow::Borrowed(template) + } } #[cfg(test)] mod tests { + use arc_swap::ArcSwap; + use crate::model::ProcessingStats; use super::*; - use shared::model::MsgKind; + use shared::model::{ConfigPaths}; - #[test] - fn test_render_template_simple() { - let msg = "Hello World"; - let kind = MsgKind::Info; - let rendered = render_template(Some("Message: {{message}}, Kind: {{kind}}"), msg, kind); - assert!(rendered.contains("Message: Hello World")); - assert!(rendered.contains("Kind: Info")); + fn create_app_config() -> Arc { + Arc::new(AppConfig { + config: Arc::new(Default::default()), + sources: Arc::new(Default::default()), + hdhomerun: Arc::new(Default::default()), + api_proxy: Arc::new(Default::default()), + file_locks: Arc::new(Default::default()), + paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { + config_path: "".to_string(), + config_file_path: "".to_string(), + sources_file_path: "".to_string(), + mapping_file_path: None, + mapping_files_used: None, + api_proxy_file_path: "".to_string(), + custom_stream_response_path: None, + })), + custom_stream_response: Arc::new(Default::default()), + access_token_secret: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32], + encrypt_secret: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16], + }) } - #[test] - fn test_render_template_json() { - let msg = r#"{"name": "test", "value": 123}"#; - let kind = MsgKind::Watch; - let rendered = render_template(Some("Added: {{event.name}}"), msg, kind); - assert_eq!(rendered, "Added: test"); + #[tokio::test] + async fn test_render_template_simple() { + let msg = "Hello World".to_string(); + let content = MessageContent::Info(msg); + let app_cfg = create_app_config(); + let client = reqwest::Client::new(); + let output = render_template(&app_cfg, &client, Some("Message: {{message}}, Kind: {{kind}}"), &content).await; + + assert!(output.contains("Message: Hello World")); + assert!(output.contains("Kind: Info")); } - #[test] - fn test_render_template_none() { - let msg = "Hello World"; - let kind = MsgKind::Info; - let rendered = render_template(None, msg, kind); - assert_eq!(rendered, "Hello World"); + #[tokio::test] + async fn test_render_template_processing_stats() { + let stats = ProcessingStats { + stats: None, + errors: Some("test error".to_string()), + }; + let content = MessageContent::ProcessingStats(stats); + let app_cfg = create_app_config(); + let client = reqwest::Client::new(); + let output = render_template(&app_cfg, &client, Some("Error: {{processing.errors}}"), &content).await; + assert_eq!(output, "Error: test error"); } - #[test] - fn test_render_template_invalid_syntax() { - let msg = "Hello World"; - let kind = MsgKind::Info; - // Unclosed handlebars expression - let rendered = render_template(Some("Message: {{message"), msg, kind); - assert_eq!(rendered, "Hello World"); + #[tokio::test] + async fn test_render_discord_template() { + use shared::model::{SourceStats, InputStats, InputType, PlaylistStats, TargetStats}; + + let input_stats = InputStats { + name: "Test Input".to_string(), + input_type: InputType::M3u, + error_count: 5, + raw_stats: PlaylistStats { group_count: 100, channel_count: 1000 }, + processed_stats: PlaylistStats { group_count: 50, channel_count: 500 }, + secs_took: 125, + }; + + let source_stats = SourceStats { + inputs: vec![input_stats], + targets: vec![TargetStats::success("Target 1")], + }; + + // Add a second source for testing multi-source rendering + let input_stats2 = InputStats { + name: "Input 2".to_string(), + input_type: InputType::Xtream, + error_count: 0, + raw_stats: PlaylistStats { group_count: 200, channel_count: 2000 }, + processed_stats: PlaylistStats { group_count: 180, channel_count: 1800 }, + secs_took: 300, + }; + let source_stats2 = SourceStats { + inputs: vec![input_stats2], + targets: vec![TargetStats::success("Target 2")], + }; + + let stats = ProcessingStats { + stats: Some(vec![source_stats, source_stats2]), + errors: Some("Some global error message".to_string()), + }; + + let content = MessageContent::ProcessingStats(stats); + let app_cfg = create_app_config(); + let client = reqwest::Client::new(); + + // Use the absolute path for the template + let template = r#" + { + "username": "Tuliprox", + "avatar_url": "https://raw.githubusercontent.com/euzu/tuliprox/refs/heads/develop/frontend/public/assets/tuliprox-logo.svg", + "embeds": [ + { + "title": "🔄 Playlist Update Report", + "color": 3310335, + "fields": [ + {{#each stats}} + { + "name": "📥 Source Stats", + "value": "{{#each inputs}}**{{name}}** (`{{type}}`)\n⏱️ Took: `{{took}}` | ❌ Errors: `{{errors}}` \n📊 `{{raw.groups}}`/`{{raw.channels}}` ➔ **`{{processed.groups}}`**/**`{{processed.channels}}`**\n{{#unless @last}}\n{{/unless}}{{/each}}", + "inline": false + }, + { + "name": "🚀 Targets", + "value": "{{#each targets}}✅ `{{target}}`{{#unless @last}}\n{{/unless}}{{/each}}", + "inline": false + }{{#unless @last}},{{/unless}} + {{/each}} + {{#if processing.errors}} + {{#if stats}},{{/if}} + { + "name": "❌ Processing Errors", + "value": "```{{processing.errors}}```", + "inline": false + } + {{/if}} + ], + "footer": { + "text": "Tuliprox • Automated Task", + "icon_url": "https://raw.githubusercontent.com/euzu/tuliprox/refs/heads/develop/frontend/public/assets/tuliprox-logo.svg" + }, + "timestamp": "{{timestamp}}" + } + ] + } + "#; + + let output = render_template(&app_cfg, &client, Some(template), &content).await; + + println!("{output}"); + + // Verify some expected strings in the output + assert!(output.contains("\"username\": \"Tuliprox\"")); + assert!(output.contains("Test Input")); + assert!(output.contains("Input 2")); + assert!(output.contains("📥 Source Stats")); + assert!(output.contains("❌ Processing Errors")); + assert!(output.contains("Some global error message")); + assert!(output.contains("Target 1")); + assert!(output.contains("Target 2")); + assert!(output.contains("2:05 mins")); // 125 secs + assert!(output.contains("5:00 mins")); // 300 secs + } + + #[tokio::test] + async fn test_render_telegram_template() { + use shared::model::{SourceStats, InputStats, InputType, PlaylistStats, TargetStats}; + + let input_stats = InputStats { + name: "Telegram Input".to_string(), + input_type: InputType::Xtream, + error_count: 2, + raw_stats: PlaylistStats { group_count: 50, channel_count: 500 }, + processed_stats: PlaylistStats { group_count: 45, channel_count: 450 }, + secs_took: 45, + }; + + let source_stats = SourceStats { + inputs: vec![input_stats], + targets: vec![TargetStats::success("Target T1")], + }; + + let stats = ProcessingStats { + stats: Some(vec![source_stats]), + errors: Some("An error occurred during sync".to_string()), + }; + + let content = MessageContent::ProcessingStats(stats); + let app_cfg = create_app_config(); + let client = reqwest::Client::new(); + + let template = r#" + *🔄 Playlist Update Report* + + {{#each stats}} + *📥 Source Stats* + {{#each inputs}} + • *{{name}}* (`{{type}}`) + ⏱️ Took: `{{took}}` | ❌ Errors: `{{errors}}` + 📊 `{{raw.groups}}`/`{{raw.channels}}` ➔ *`{{processed.groups}}`*/*`{{processed.channels}}`* + {{/each}} + + *🚀 Targets* + {{#each targets}} + ✅ `{{target}}` + {{/each}} + {{/each}} + + {{#if processing.errors}} + *❌ Processing Errors* + ``` + {{processing.errors}} + ``` + {{/if}} + + _Timestamp: {{timestamp}}_ + "#; + let output = render_template(&app_cfg, &client, Some(template), &content).await; + + println!("Telegram Output:\n{}", output); + + assert!(output.contains("🔄 Playlist Update Report")); + assert!(output.contains("Telegram Input")); + assert!(output.contains("⏱️ Took: `45 secs`")); + assert!(output.contains("❌ Errors: `2`")); + assert!(output.contains("Target T1")); + assert!(output.contains("An error occurred during sync")); } } diff --git a/backend/src/model/config/base.rs b/backend/src/model/config/base.rs index 99bfab6d9..6573c12d9 100644 --- a/backend/src/model/config/base.rs +++ b/backend/src/model/config/base.rs @@ -85,6 +85,10 @@ impl Config { library.prepare()?; } + if let Some(messaging) = self.messaging.as_mut() { + messaging.prepare(config_path); + } + Ok(()) } diff --git a/backend/src/model/config/messaging.rs b/backend/src/model/config/messaging.rs index 65770ed5c..fea873f73 100644 --- a/backend/src/model/config/messaging.rs +++ b/backend/src/model/config/messaging.rs @@ -1,12 +1,20 @@ use log::warn; use crate::model::macros; use shared::model::{DiscordMessagingConfigDto, MessagingConfigDto, MsgKind, PushoverMessagingConfigDto, RestMessagingConfigDto, TelegramMessagingConfigDto}; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct TelegramMessagingConfig { pub bot_token: String, pub chat_ids: Vec, pub markdown: bool, + pub templates: std::collections::HashMap, +} + +impl TelegramMessagingConfig { + pub fn prepare(&mut self, templates_dir: &Path) { + discover_templates("telegram", &mut self.templates, templates_dir); + } } macros::from_impl!(TelegramMessagingConfig); @@ -16,6 +24,7 @@ impl From<&TelegramMessagingConfigDto> for TelegramMessagingConfig { bot_token: dto.bot_token.clone(), chat_ids: dto.chat_ids.clone(), markdown: dto.markdown, + templates: dto.templates.clone(), } } } @@ -26,6 +35,7 @@ impl From<&TelegramMessagingConfig> for TelegramMessagingConfigDto { bot_token: instance.bot_token.clone(), chat_ids: instance.chat_ids.clone(), markdown: instance.markdown, + templates: instance.templates.clone(), } } } @@ -35,7 +45,13 @@ pub struct RestMessagingConfig { pub url: String, pub method: String, pub headers: std::collections::HashMap, - pub template: Option, + pub templates: std::collections::HashMap, +} + +impl RestMessagingConfig { + pub fn prepare(&mut self, templates_dir: &Path) { + discover_templates("rest", &mut self.templates, templates_dir); + } } macros::from_impl!(RestMessagingConfig); @@ -53,7 +69,7 @@ impl From<&RestMessagingConfigDto> for RestMessagingConfig { url: dto.url.clone(), method: dto.method.clone().unwrap_or_else(|| "POST".to_string()), headers, - template: dto.template.clone(), + templates: dto.templates.clone(), } } } @@ -67,7 +83,7 @@ impl From<&RestMessagingConfig> for RestMessagingConfigDto { url: model.url.clone(), method: Some(model.method.clone()), headers, - template: model.template.clone(), + templates: model.templates.clone(), } } } @@ -75,7 +91,13 @@ impl From<&RestMessagingConfig> for RestMessagingConfigDto { #[derive(Debug, Clone)] pub struct DiscordMessagingConfig { pub url: String, - pub template: Option, + pub templates: std::collections::HashMap, +} + +impl DiscordMessagingConfig { + pub fn prepare(&mut self, templates_dir: &Path) { + discover_templates("discord", &mut self.templates, templates_dir); + } } macros::from_impl!(DiscordMessagingConfig); @@ -83,7 +105,7 @@ impl From<&DiscordMessagingConfigDto> for DiscordMessagingConfig { fn from(dto: &DiscordMessagingConfigDto) -> Self { Self { url: dto.url.clone(), - template: dto.template.clone(), + templates: dto.templates.clone(), } } } @@ -92,7 +114,7 @@ impl From<&DiscordMessagingConfig> for DiscordMessagingConfigDto { fn from(instance: &DiscordMessagingConfig) -> Self { Self { url: instance.url.clone(), - template: instance.template.clone(), + templates: instance.templates.clone(), } } } @@ -134,6 +156,21 @@ pub struct MessagingConfig { pub discord: Option, } +impl MessagingConfig { + pub fn prepare(&mut self, config_path: &str) { + let templates_dir = PathBuf::from(config_path).join("messaging_templates"); + if let Some(t) = &mut self.telegram { + t.prepare(&templates_dir); + } + if let Some(r) = &mut self.rest { + r.prepare(&templates_dir); + } + if let Some(d) = &mut self.discord { + d.prepare(&templates_dir); + } + } +} + macros::from_impl!(MessagingConfig); impl From<&MessagingConfigDto> for MessagingConfig { fn from(dto: &MessagingConfigDto) -> Self { @@ -157,4 +194,17 @@ impl From<&MessagingConfig> for MessagingConfigDto { discord: instance.discord.as_ref().map(Into::into), } } +} + +fn discover_templates(prefix: &str, templates: &mut std::collections::HashMap, templates_dir: &Path) { + let variants = [MsgKind::Info, MsgKind::Stats, MsgKind::Error, MsgKind::Watch]; + for kind in variants { + if let std::collections::hash_map::Entry::Vacant(e) = templates.entry(kind) { + let filename = kind.template_filename(prefix); + let file_path = templates_dir.join(filename); + if file_path.exists() { + e.insert(format!("file://{}", file_path.to_string_lossy())); + } + } + } } \ No newline at end of file diff --git a/backend/src/model/messaging.rs b/backend/src/model/messaging.rs new file mode 100644 index 000000000..2e7ddb741 --- /dev/null +++ b/backend/src/model/messaging.rs @@ -0,0 +1,80 @@ +use serde::{Deserialize, Serialize}; +use shared::model::{MsgKind, SourceStats, InputStats}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchChanges { + pub target: String, + pub group: String, + pub added: Vec, + pub removed: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ProcessingStats { + #[serde(skip_serializing_if = "Option::is_none")] + pub stats: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub errors: Option, +} + +impl ProcessingStats { + pub fn new_stats(stats: Vec) -> Self { + Self { stats: Some(stats), errors: None } + } + + pub fn new_error(error: String) -> Self { + Self { stats: None, errors: Some(error) } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data")] +pub enum MessageContent { + Info(String), + Error(String), + Watch(WatchChanges), + ProcessingStats(ProcessingStats), +} + +impl MessageContent { + pub fn event_stats(stats: Vec) -> Self { + Self::ProcessingStats(ProcessingStats::new_stats(stats)) + } + + pub fn event_error(error: String) -> Self { + Self::ProcessingStats(ProcessingStats::new_error(error)) + } + + pub fn kind(&self) -> MsgKind { + match self { + Self::Info(_) => MsgKind::Info, + Self::Error(_) => MsgKind::Error, + Self::Watch(_) => MsgKind::Watch, + Self::ProcessingStats(e) => { + if e.errors.is_some() && e.stats.is_none() { + MsgKind::Error + } else { + MsgKind::Stats + } + } + } + } +} + +#[derive(Serialize)] +pub struct TemplateContext<'a> { + pub kind: String, + pub timestamp: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub stats: Option<&'a Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub watch: Option<&'a WatchChanges>, + // For manual error json or other json events embedded in string + #[serde(skip_serializing_if = "Option::is_none")] + pub processing: Option, + // Flattened stats for first input convenience + #[serde(flatten)] + pub flat_stats: Option, +} diff --git a/backend/src/model/mod.rs b/backend/src/model/mod.rs index 68090e34b..fc355bb62 100644 --- a/backend/src/model/mod.rs +++ b/backend/src/model/mod.rs @@ -1,6 +1,5 @@ mod playlist; mod mapping; -mod stats; mod xmltv; mod xtream; mod healthcheck; @@ -8,10 +7,11 @@ mod playlist_categories; mod config; mod input_source; pub mod media_properties; +pub mod messaging; pub use self::playlist::*; pub use self::mapping::*; -pub use self::stats::*; +pub use shared::model::{format_elapsed_time, InputStats, PlaylistStats, SourceStats, TargetStats}; pub use self::xmltv::*; pub use self::xtream::*; pub use self::healthcheck::*; @@ -19,4 +19,5 @@ pub use self::playlist_categories::*; pub use shared::model::xtream_const::*; pub use self::config::*; pub use self::input_source::*; -pub use self::media_properties::*; \ No newline at end of file +pub use self::media_properties::*; +pub use self::messaging::*; \ No newline at end of file diff --git a/backend/src/processing/playlist_watch.rs b/backend/src/processing/playlist_watch.rs index ad11a11d2..ba1d4da05 100644 --- a/backend/src/processing/playlist_watch.rs +++ b/backend/src/processing/playlist_watch.rs @@ -1,14 +1,14 @@ use std::collections::BTreeSet; use std::sync::Arc; use std::path::{Path}; -use log::{error, info}; -use shared::model::{MsgKind, PlaylistGroup}; +use log::{error}; +use shared::model::{PlaylistGroup}; use crate::messaging::{send_message}; -use crate::model::Config; +use crate::model::{AppConfig, MessageContent, WatchChanges}; use crate::utils; use crate::utils::{binary_deserialize, binary_serialize, file_exists_async}; -pub async fn process_group_watch(client: &reqwest::Client, cfg: &Config, target_name: &str, pl: &PlaylistGroup) { +pub async fn process_group_watch(app_config: &Arc, client: &reqwest::Client, target_name: &str, pl: &PlaylistGroup) { let mut new_tree = BTreeSet::new(); pl.channels.iter().for_each(|chan| { let header = &chan.header; @@ -17,6 +17,7 @@ pub async fn process_group_watch(client: &reqwest::Client, cfg: &Config, target_ }); let watch_filename = format!("{}/{}.bin", utils::sanitize_filename(target_name), utils::sanitize_filename(&pl.title)); + let cfg = app_config.config.load(); match utils::get_file_path(&cfg.working_dir, Some(std::path::PathBuf::from(&watch_filename))) { Some(path) => { let save_path = path.as_path(); @@ -28,7 +29,7 @@ pub async fn process_group_watch(client: &reqwest::Client, cfg: &Config, target_ let removed_difference: BTreeSet> = loaded_tree.difference(&new_tree).cloned().collect(); if !added_difference.is_empty() || !removed_difference.is_empty() { changed = true; - handle_watch_notification(client, cfg, &added_difference, &removed_difference, target_name, &pl.title).await; + handle_watch_notification(app_config, client, &added_difference, &removed_difference, target_name, &pl.title).await; } } else { error!("failed to load watch_file {}", &path.to_str().unwrap_or_default()); @@ -52,15 +53,7 @@ pub async fn process_group_watch(client: &reqwest::Client, cfg: &Config, target_ } } -#[derive(Debug, serde::Serialize)] -struct WatchChanges { - pub target: String, - pub group: String, - pub added: Vec, - pub removed: Vec, -} - -async fn handle_watch_notification(client: &reqwest::Client, cfg: &Config, added: &BTreeSet>, removed: &BTreeSet>, target_name: &str, group_name: &str) { +async fn handle_watch_notification(app_config: &Arc, client: &reqwest::Client, added: &BTreeSet>, removed: &BTreeSet>, target_name: &str, group_name: &str) { let added = added.iter().map(std::string::ToString::to_string).collect::>(); let removed = removed.iter().map(std::string::ToString::to_string).collect::>(); if !added.is_empty() || !removed.is_empty() { @@ -72,9 +65,7 @@ async fn handle_watch_notification(client: &reqwest::Client, cfg: &Config, added removed }; - let msg = serde_json::to_string_pretty(&changes).unwrap_or_else(|_| "Error: Failed to serialize watch changes".to_string()); - info!("{}", &msg); - send_message(client, MsgKind::Watch, cfg.messaging.as_ref(), &msg).await; + send_message(app_config, client, MessageContent::Watch(changes)).await; } } diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index f5452cc64..2119d4375 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -1,4 +1,4 @@ -use crate::model::{AppConfig, Config, ConfigFavourites, ConfigInput, ConfigRename, ReverseProxyDisabledHeaderConfig, TVGuide}; +use crate::model::{AppConfig, ConfigFavourites, ConfigInput, ConfigRename, ReverseProxyDisabledHeaderConfig, TVGuide}; use crate::utils::m3u; use crate::utils::xtream; use crate::utils::{epg, StepMeasureCallback}; @@ -9,7 +9,8 @@ use tokio::sync::{Mutex, OwnedRwLockWriteGuard, RwLock}; use tokio::task::JoinSet; use crate::api::model::{EventManager, EventMessage, PlaylistStorageState, UpdateGuard}; -use crate::messaging::send_message_json; +use crate::messaging::{send_message}; +use crate::model::messaging::{MessageContent}; use crate::model::Epg; @@ -37,7 +38,7 @@ use log::{debug, error, info, log_enabled, warn, Level}; use shared::error::{get_errors_notify_message, notify_err, TuliproxError}; use shared::foundation::filter::{get_field_value, set_field_value, Filter, ValueAccessor, ValueProvider}; use shared::model::xtream_const::XTREAM_CLUSTER; -use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, MsgKind, +use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistUpdateState, ProcessingOrder, XtreamCluster}; use shared::utils::{create_alias_uuid, default_as_default, interner_gc, Internable}; @@ -762,8 +763,7 @@ async fn process_playlist_for_target(ctx: &PlaylistProcessingContext, map_playlist_counter(target, &mut flat_new_playlist); step.tick("assigning channel counter"); - let config = ctx.config.config.load(); - if process_watch(&config, &ctx.client, target, &flat_new_playlist).await { + if process_watch(&ctx.config, &ctx.client, target, &flat_new_playlist).await { step.tick("group watches"); } let result = persist_playlist(&ctx.config, &mut flat_new_playlist, flatten_tvguide(&new_epg).as_ref(), target, ctx.playlist_state.as_ref()).await; @@ -842,7 +842,7 @@ async fn process_epg(processed_fetched_playlists: &mut Vec>) (new_epg, new_playlist) } -async fn process_watch(cfg: &Config, client: &reqwest::Client, target: &ConfigTarget, new_playlist: &[PlaylistGroup]) -> bool { +async fn process_watch(app_config: &Arc, client: &reqwest::Client, target: &ConfigTarget, new_playlist: &[PlaylistGroup]) -> bool { if let Some(watches) = &target.watch { if default_as_default().eq_ignore_ascii_case(&target.name) { error!("can't watch a target with no unique name"); @@ -853,7 +853,7 @@ async fn process_watch(cfg: &Config, client: &reqwest::Client, target: &ConfigTa new_playlist .iter() .filter(|pl| watches.iter().any(|r| r.is_match(&pl.title))) - .map(|pl| process_group_watch(client, cfg, &target.name, pl)) + .map(|pl| process_group_watch(app_config, client, &target.name, pl)) ).for_each_concurrent(16, |f| f).await; true @@ -898,25 +898,14 @@ pub async fn exec_processing(client: &reqwest::Client, app_config: Arc { - match serde_json::to_string(&serde_json::Value::Object( - serde_json::map::Map::from_iter([("stats".to_string(), val)]))) { - Ok(stats_msg) => { - // print stats - info!("{stats_msg}"); - // send stats - send_message_json(client, MsgKind::Stats, messaging, stats_msg.as_str()).await; - } - Err(err) => error!("Failed to serialize playlist stats {err}"), - } - } - Err(err) => error!("Failed to serialize playlist stats {err}") + // print stats + if let Ok(stats_msg) = serde_json::to_string(&stats) { + info!("stats: {stats_msg}"); } + // send stats + send_message(&app_config, client, MessageContent::event_stats(stats)).await; } // send errors @@ -924,9 +913,7 @@ pub async fn exec_processing(client: &reqwest::Client, app_config: Arc Result<(PathBuf, Vec), Error> { - match get_local_csv_file_content(file_path).await { + match get_local_file_content(file_path).await { Ok(content) => Ok(( file_path.to_path_buf(), csv_read_inputs_from_reader( diff --git a/backend/src/utils/file/config_reader.rs b/backend/src/utils/file/config_reader.rs index 7f38db19f..57b6e202f 100644 --- a/backend/src/utils/file/config_reader.rs +++ b/backend/src/utils/file/config_reader.rs @@ -5,19 +5,15 @@ use crate::repository::{ csv_read_inputs, csv_write_inputs, get_api_user_db_path, is_csv_file, load_api_user, }; use crate::utils; -use crate::utils::file_reader; +use crate::utils::{file_exists_async, file_reader}; use crate::utils::sys_utils::exit; use crate::utils::{open_file, read_mappings_file, EnvResolvingReader, FileLockManager}; -use arc_swap::access::Access; use arc_swap::{ArcSwap, ArcSwapAny}; use chrono::Local; use log::{error, info, warn}; use serde::Serialize; use shared::error::{info_err, info_err_res, TuliproxError}; -use shared::model::{ - ApiProxyConfigDto, AppConfigDto, ConfigDto, ConfigInputAliasDto, ConfigPaths, - HdHomeRunDeviceOverview, InputType, SourcesConfigDto, TargetUserDto, -}; +use shared::model::{ApiProxyConfigDto, AppConfigDto, ConfigDto, ConfigInputAliasDto, ConfigPaths, HdHomeRunDeviceOverview, InputType, MsgKind, SourcesConfigDto, TargetUserDto}; use shared::utils::CONSTANTS; use std::env; use std::fs::File; @@ -25,6 +21,9 @@ use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::fs; +use shared::concat_string; +use crate::utils::request::{is_uri}; +use url::Url; enum EitherReader { Left(L), @@ -53,7 +52,7 @@ pub async fn read_api_proxy_config( config: &AppConfig, resolve_env: bool, ) -> Result, TuliproxError> { - let paths = > as Access>::load(&config.paths); + let paths = config.paths.load(); let api_proxy_file_path = paths.api_proxy_file_path.as_str(); if let Some(api_proxy_dto) = read_api_proxy_file(api_proxy_file_path, resolve_env)? { let mut errors = vec![]; @@ -371,7 +370,7 @@ pub fn read_api_proxy_file( } pub async fn read_api_proxy(config: &AppConfig, resolve_env: bool) -> Option { - let paths = > as Access>::load(&config.paths); + let paths = config.paths.load(); match read_api_proxy_file(paths.api_proxy_file_path.as_str(), resolve_env) { Ok(Some(api_proxy_dto)) => { let mut errors = vec![]; @@ -506,16 +505,14 @@ pub async fn persist_source_config( let source_file = { source_file_path.and_then(|p| p.to_str()).map_or_else( || { - let paths = > as Access>::load( - &app_state.app_config.paths, - ); + let paths = app_state.app_config.paths.load(); paths.sources_file_path.clone() }, ToString::to_string, ) }; let backup_dir = { - let config = > as Access>::load(&app_state.app_config.config); + let config = app_state.app_config.config.load(); config.get_backup_dir().to_string() }; @@ -569,7 +566,7 @@ pub async fn validate_and_persist_source_config( ) -> Result { { let mut new_dto = dto.clone(); - let config = > as Access>::load(&app_state.app_config.config); + let config = app_state.app_config.config.load(); new_dto.prepare(true, config.get_hdhr_device_overview().as_ref())?; } @@ -592,6 +589,70 @@ pub fn resolve_env_var(value: &str) -> String { .to_string() } +pub async fn persist_messaging_templates(app_state: &Arc, cfg: &mut ConfigDto) -> Result<(), TuliproxError> { + let templates_dir = { + let paths = app_state.app_config.paths.load(); + PathBuf::from(&paths.config_path).join("messaging_templates") + }; + + if let Some(messaging) = &mut cfg.messaging { + // Discord + if let Some(discord) = &mut messaging.discord { + for (kind, template) in &mut discord.templates { + *template = persist_single_template("discord", Some(kind), template, &templates_dir).await?; + } + } + // Telegram + if let Some(telegram) = &mut messaging.telegram { + for (kind, template) in &mut telegram.templates { + *template = persist_single_template("telegram", Some(kind), template, &templates_dir).await?; + } + } + // Rest + if let Some(rest) = &mut messaging.rest { + for (kind, template) in &mut rest.templates { + *template = persist_single_template("rest", Some(kind), template, &templates_dir).await?; + } + } + } + Ok(()) +} + + +async fn persist_single_template(prefix: &str, kind: Option<&MsgKind>, template: &str, templates_dir: &Path) -> Result { + if template.is_empty() || is_uri(template) { + return Ok(template.to_string()); + } + + // Treat existing file paths as file URLs + if tokio::fs::metadata(template).await.is_ok() { + return Url::from_file_path(template) + .map(|u| u.to_string()) + .map_err(|_| info_err!("Failed to convert path to file URL: {}", template)); + } + + // It's a raw string, persist it + if !file_exists_async(templates_dir).await { + tokio::fs::create_dir_all(templates_dir) + .await + .map_err(|e| info_err!("Messaging templates dir: failed to create dir: {} {e}", templates_dir.display()))?; + } + + let filename = if let Some(k) = kind { + k.template_filename(prefix) + } else { + concat_string!(prefix, "_default.templ") + }; + + let file_path = templates_dir.join(filename); + fs::write(&file_path, template).await.map_err(|e| info_err!("Failed to write template file: {e}"))?; + + Url::from_file_path(&file_path) + .map(|u| u.to_string()) + .map_err(|_| info_err!("Failed to convert persisted path to file URL: {}", file_path.display())) +} + + #[cfg(test)] mod tests { use crate::utils::resolve_env_var; diff --git a/backend/src/utils/network/request.rs b/backend/src/utils/network/request.rs index 8b9cc42f7..deba39d83 100644 --- a/backend/src/utils/network/request.rs +++ b/backend/src/utils/network/request.rs @@ -287,7 +287,7 @@ pub async fn get_input_text_content( } } - match get_local_csv_file_content(&filepath).await { + match get_local_file_content(&filepath).await { Ok(content) => Some(content), Err(err) => { return notify_err_res!("Failed : {}", err); @@ -504,8 +504,10 @@ pub fn get_request_headers( headers } -pub async fn get_local_csv_file_content(file_path: &Path) -> Result { - // Datei öffnen +// read local file content and return it as a string. +// Gzipped file content is supported. +pub async fn get_local_file_content(file_path: &Path) -> Result { + // open file let file = File::open(file_path).await.map_err(|err| { std::io::Error::new( ErrorKind::NotFound, @@ -515,21 +517,21 @@ pub async fn get_local_csv_file_content(file_path: &Path) -> Result= 2 && is_gzip(&buffer[0..2]); let mut decoded = String::new(); if is_gzipped { - // Async Gzip Decoder verwenden + // Use async gzip decoder let mut gzip_decoder = async_compression::tokio::bufread::GzipDecoder::new(buf_reader); gzip_decoder .read_to_string(&mut decoded) .await .map_err(|e| std::io::Error::other(format!("Failed to decode gzip content: {e}")))?; } else { - // Plaintext lesen + // read plaintext buf_reader .read_to_string(&mut decoded) .await @@ -806,7 +808,7 @@ pub async fn download_text_content( let result = if let Ok(url) = input.url.parse::() { let result = if url.scheme() == "file" { match url.to_file_path() { - Ok(file_path) => get_local_csv_file_content(&file_path) + Ok(file_path) => get_local_file_content(&file_path) .await .map(|c| (c, url.to_string())), Err(()) => Err(string_to_io_error(format!( @@ -1097,6 +1099,12 @@ pub fn is_file_url(url: &str) -> bool { .unwrap_or(false) } +pub fn is_uri(url: &str) -> bool { + Url::parse(url) + .map(|u| u.scheme().eq_ignore_ascii_case("file") || u.scheme().eq_ignore_ascii_case("http") || u.scheme().eq_ignore_ascii_case("https")) + .unwrap_or(false) +} + #[cfg(test)] mod tests { use shared::utils::{get_base_url_from_str, replace_url_extension, sanitize_sensitive_info}; diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index a91f4b004..f0563d05b 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -1,20 +1,23 @@ use crate::api::model::AppState; use crate::messaging::send_message; -use crate::model::{is_input_expired, xtream_mapping_option_from_target_options, AppConfig, Config, ConfigInput, ConfigTarget, XtreamLoginInfo, XtreamTargetOutput}; +use crate::model::{is_input_expired, xtream_mapping_option_from_target_options, AppConfig, + ConfigInput, ConfigTarget, MessageContent, XtreamLoginInfo, XtreamTargetOutput}; use crate::model::{InputSource, ProxyUserCredentials}; use crate::processing::parser::xtream; use crate::processing::parser::xtream::parse_xtream_series_info; use crate::repository::BPlusTreeUpdate; -use crate::repository::{get_target_id_mapping, rewrite_provider_series_info_episode_virtual_id, ProviderEpisodeKey}; -use crate::repository::{ensure_input_storage_path, get_input_storage_path, get_target_storage_path}; use crate::repository::VirtualIdRecord; +use crate::repository::{ensure_input_storage_path, get_input_storage_path, get_target_storage_path}; use crate::repository::{get_live_cat_collection_path, get_series_cat_collection_path, get_vod_cat_collection_path, xtream_get_file_path, CategoryEntry}; +use crate::repository::{get_target_id_mapping, rewrite_provider_series_info_episode_virtual_id, ProviderEpisodeKey}; use crate::repository::{persist_input_vod_info, persists_input_series_info, write_playlist_batch_item_upsert, write_playlist_item_update}; use crate::utils::{file_exists_async, request}; use chrono::{DateTime, Utc}; use log::{error, info, warn}; -use shared::error::{TuliproxError}; -use shared::model::{MsgKind, PlaylistEntry, PlaylistGroup, ProxyUserStatus, SeriesStreamProperties, StreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, XtreamSeriesInfo, XtreamVideoInfo, XtreamVideoInfoDoc}; +use shared::error::TuliproxError; +use shared::model::{PlaylistEntry, PlaylistGroup, ProxyUserStatus, SeriesStreamProperties, + StreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, + XtreamSeriesInfo, XtreamVideoInfo, XtreamVideoInfoDoc}; use shared::utils::{extract_extension_from_url, get_i64_from_serde_value, get_string_from_serde_value, sanitize_sensitive_info, Internable}; use std::collections::HashMap; use std::io::Error; @@ -137,7 +140,7 @@ pub async fn get_xtream_stream_info(client: &reqwest::Client, Ok(info) => { // parse series info let series_stream_props = SeriesStreamProperties::from_info(&info, pli); - + if let Ok(storage_path) = get_input_storage_path(&input.name, working_dir) { // update input db if let Err(err) = persists_input_series_info(app_config, &storage_path, cluster, &input.name, provider_id, &series_stream_props).await { @@ -155,7 +158,7 @@ pub async fn get_xtream_stream_info(client: &reqwest::Client, let mut provider_series: HashMap, Vec> = HashMap::new(); { let (mut target_id_mapping, _file_lock) = get_target_id_mapping(&app_state.app_config, &target_path, target.use_memory_cache).await?; - + if let Some(parent_id) = pli.get_provider_id() { let category_id = pli.get_category_id().unwrap_or(0); for episode in &mut episodes { @@ -286,8 +289,6 @@ async fn xtream_login(app_config: &Arc, client: &reqwest::Client, inp exp_date: None, }; - let cfg = app_config.config.load(); - if let Some(user_info) = content.get("user_info") { if let Some(status_value) = user_info.get("status") { if let Some(status) = get_string_from_serde_value(status_value) { @@ -295,8 +296,7 @@ async fn xtream_login(app_config: &Arc, client: &reqwest::Client, inp login_info.status = Some(cur_status); if !matches!(cur_status, ProxyUserStatus::Active | ProxyUserStatus::Trial) { warn!("User status for user {username} is {cur_status:?}"); - send_message(client, MsgKind::Info, cfg.messaging.as_ref(), - &format!("User status for user {username} is {cur_status:?}")).await; + send_message(app_config, client, MessageContent::Error(format!("User status for user {username} is {cur_status:?}"))).await; } } } @@ -305,7 +305,7 @@ async fn xtream_login(app_config: &Arc, client: &reqwest::Client, inp if let Some(exp_value) = user_info.get("exp_date") { if let Some(expiration_timestamp) = get_i64_from_serde_value(exp_value) { login_info.exp_date = Some(expiration_timestamp); - notify_account_expire(login_info.exp_date, &cfg, client, username, &input.name).await; + notify_account_expire(login_info.exp_date, app_config, client, username, &input.name).await; } } } @@ -317,7 +317,7 @@ async fn xtream_login(app_config: &Arc, client: &reqwest::Client, inp } } -pub async fn notify_account_expire(exp_date: Option, cfg: &Config, client: &reqwest::Client, +pub async fn notify_account_expire(exp_date: Option, app_config: &Arc, client: &reqwest::Client, username: &str, input_name: &str) { if let Some(expiration_timestamp) = exp_date { let now_secs = Utc::now().timestamp(); // UTC-Time @@ -328,21 +328,19 @@ pub async fn notify_account_expire(exp_date: Option, cfg: &Config, client: if let Some(datetime) = DateTime::::from_timestamp(expiration_timestamp, 0) { let formatted = datetime.format("%Y-%m-%d %H:%M:%S").to_string(); warn!("User account for user {username} expires {formatted}"); - send_message(client, MsgKind::Info, cfg.messaging.as_ref(), - &format!("User account for user {username} expires {formatted}")).await; + send_message(app_config, client, MessageContent::Info(format!("User account for user {username} expires {formatted}"))).await; } } } else { warn!("User account for user {username} is expired"); - send_message(client, MsgKind::Info, cfg.messaging.as_ref(), - &format!("User account for user {username} for provider {input_name} is expired")).await; + send_message(app_config, client, MessageContent::Info( + format!("User account for user {username} for provider {input_name} is expired"))).await; } } } pub async fn download_xtream_playlist(app_config: &Arc, client: &reqwest::Client, input: &ConfigInput, clusters: Option<&[XtreamCluster]>) -> (Vec, Vec, bool) { - let cfg = app_config.config.load(); let input_source: InputSource = { match input.staged.as_ref() { None => input.into(), @@ -356,7 +354,7 @@ pub async fn download_xtream_playlist(app_config: &Arc, client: &reqw let base_url = get_xtream_stream_url_base(&input_source.url, username, password); let input_source_login = input_source.with_url(base_url.clone()); - check_alias_user_state(&cfg, client, input).await; + check_alias_user_state(app_config, client, input).await; if let Err(err) = xtream_login(app_config, client, &input_source_login, username).await { error!("Could not log in with xtream user {username} for provider {}. {err}", input.name); @@ -366,6 +364,7 @@ pub async fn download_xtream_playlist(app_config: &Arc, client: &reqw let mut playlist_groups: Vec = Vec::with_capacity(128); let skip_cluster = get_skip_cluster(input); + let cfg = app_config.config.load(); let working_dir = &cfg.working_dir; let mut errors = vec![]; @@ -425,11 +424,11 @@ pub async fn download_xtream_playlist(app_config: &Arc, client: &reqw (playlist_groups, errors, cfg.disk_based_processing) } -async fn check_alias_user_state(cfg: &Arc, client: &reqwest::Client, input: &ConfigInput) { +async fn check_alias_user_state(app_config: &Arc, client: &reqwest::Client, input: &ConfigInput) { if let Some(aliases) = input.aliases.as_ref() { for alias in aliases { if is_input_expired(alias.exp_date) { - notify_account_expire(alias.exp_date, cfg, client, alias.username.as_ref() + notify_account_expire(alias.exp_date, app_config, client, alias.username.as_ref() .map_or("", |s| s.as_str()), &alias.name).await; } } diff --git a/backend/src/utils/telegram.rs b/backend/src/utils/telegram.rs index e6660f405..aa832b147 100644 --- a/backend/src/utils/telegram.rs +++ b/backend/src/utils/telegram.rs @@ -1,5 +1,7 @@ +use std::sync::Arc; use log::{debug, error}; use url::Url; +use crate::model::AppConfig; /// Requests will be sent according to bot instance. #[derive(Clone)] @@ -62,6 +64,7 @@ pub fn telegram_create_instance(bot_token: &str, chat_id: &str) -> BotInstance { } pub async fn telegram_send_message( + _app_config: &Arc, client: &reqwest::Client, instance: &BotInstance, msg: &str, diff --git a/bin/release.sh b/bin/release.sh index 0acb3b55c..7c922da99 100755 --- a/bin/release.sh +++ b/bin/release.sh @@ -86,6 +86,8 @@ for PLATFORM in "${!ARCHITECTURES[@]}"; do mkdir -p "$DIR" cp "$BIN" "$DIR" cp ../config/*.yml "$DIR" + mkdir -p "$DIR"/messaging_templates + cp ../config/messaging_templates/*.templ "$DIR"/messaging_templates cp -rf "${FRONTEND_BUILD_DIR}" "$DIR"/web cp -rf "${RESOURCES_DIR}"/*.ts "$DIR" diff --git a/config/messaging_templates/discord_stats.templ b/config/messaging_templates/discord_stats.templ new file mode 100644 index 000000000..566a5c64a --- /dev/null +++ b/config/messaging_templates/discord_stats.templ @@ -0,0 +1,37 @@ +{ + "username": "Tuliprox", + "avatar_url": "https://raw.githubusercontent.com/euzu/tuliprox/refs/heads/develop/frontend/public/assets/tuliprox-logo.svg", + "embeds": [ + { + "title": "🔄 Playlist Update Report", + "color": 3310335, + "fields": [ + {{#each stats}} + { + "name": "📥 Source Stats", + "value": "{{#each inputs}}**{{json_escape name}}** (`{{json_escape type}}`)\n⏱️ Took: `{{json_escape took}}` | ❌ Errors: `{{errors}}` \n📊 `{{raw.groups}}`/`{{raw.channels}}` ➔ **`{{processed.groups}}`**/**`{{processed.channels}}`**\n{{#unless @last}}\n{{/unless}}{{/each}}", + "inline": false + }, + { + "name": "🚀 Targets", + "value": "{{#each targets}}✅ `{{json_escape target}}`{{#unless @last}}\n{{/unless}}{{/each}}", + "inline": false + }{{#unless @last}},{{/unless}} + {{/each}} + {{#if processing.errors}} + {{#if stats}},{{/if}} + { + "name": "❌ Processing Errors", + "value": "```{{json_escape processing.errors}}```", + "inline": false + } + {{/if}} + ], + "footer": { + "text": "Tuliprox • Automated Task", + "icon_url": "https://raw.githubusercontent.com/euzu/tuliprox/refs/heads/develop/frontend/public/assets/tuliprox-logo.svg" + }, + "timestamp": "{{timestamp}}" + } + ] +} \ No newline at end of file diff --git a/config/messaging_templates/telegram_stats.templ b/config/messaging_templates/telegram_stats.templ new file mode 100644 index 000000000..e61d1e776 --- /dev/null +++ b/config/messaging_templates/telegram_stats.templ @@ -0,0 +1,24 @@ +*🔄 Playlist Update Report* + +{{#each stats}} +*📥 Source Stats* +{{#each inputs}} +• *{{name}}* (`{{type}}`) + ⏱️ Took: `{{took}}` | ❌ Errors: `{{errors}}` + 📊 `{{raw.groups}}`/`{{raw.channels}}` ➔ *`{{processed.groups}}`*/*`{{processed.channels}}`* +{{/each}} + +*🚀 Targets* +{{#each targets}} +✅ `{{target}}` +{{/each}} +{{/each}} + +{{#if processing.errors}} +*❌ Processing Errors* +``` +{{processing.errors}} +``` +{{/if}} + +_Timestamp: {{timestamp}}_ diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index da95f495d..28c83ac0c 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "frontend" -version = "3.2.51" +version = "3.2.52" edition = "2021" rust-version = "1.89.0" [dependencies] -shared = { version = "3.2.51", path = "../shared" } +shared = { version = "3.2.52", path = "../shared" } chrono = "0" yew = "0.21" yew-router = "0.18" diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index f7344005c..6d8663f24 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -406,7 +406,12 @@ "API_USER_AGENT": "User Agent", "ADVANCED": "Advanced", "TRAKT": "Trakt", - "SEASON": "Season" + "SEASON": "Season", + "SPECIFIC_TEMPLATES": "Specific Templates", + "MSG_KIND_INFO": "Info", + "MSG_KIND_STATS": "Stats", + "MSG_KIND_ERROR": "Error", + "MSG_KIND_WATCH": "Watch" }, "TITLE": { "USER_BOUQUET_EDITOR": "Playlist Category Selection" diff --git a/frontend/scss/app/components/config/_messaging_config.scss b/frontend/scss/app/components/config/_messaging_config.scss index e2a58d05f..4307f5e67 100644 --- a/frontend/scss/app/components/config/_messaging_config.scss +++ b/frontend/scss/app/components/config/_messaging_config.scss @@ -3,4 +3,26 @@ flex-flow: row wrap; gap: var(--gap-default); box-sizing: border-box; +} + +.tp__messaging-config__templates { + margin-top: var(--gap-default); + display: flex; + flex-direction: column; + gap: var(--gap-small); + + h3 { + margin: var(--gap-small) 0; + font-size: 1rem; + color: var(--text-color-secondary); + } +} + +.tp__messaging-config__templates-view { + margin-top: var(--gap-small); + h3 { + margin: var(--gap-small) 0; + font-size: 0.9rem; + color: var(--text-color-secondary); + } } \ No newline at end of file diff --git a/frontend/src/app/components/config/macros.rs b/frontend/src/app/components/config/macros.rs index a624aa748..168da3e1d 100644 --- a/frontend/src/app/components/config/macros.rs +++ b/frontend/src/app/components/config/macros.rs @@ -168,6 +168,29 @@ macro_rules! edit_field_text_option { }}; } +#[macro_export] +macro_rules! edit_field_textarea_option { + ($instance:expr, $label:expr, $field:ident, $action:path) => {{ + let instance = $instance.clone(); + html! { +
+ <$crate::app::components::TextArea + label={$label} + name={stringify!($field)} + value={instance.form.$field.as_ref().map_or_else(String::new, |v|v.to_string())} + on_change={Callback::from(move |value: String| { + instance.dispatch($action(if value.is_empty() { + None + } else { + Some(value) + })); + })} + /> +
+ } + }}; +} + #[macro_export] macro_rules! edit_field_text { ($instance:expr, $label:expr, $field:ident, $action:path) => { diff --git a/frontend/src/app/components/config/messaging_config_view.rs b/frontend/src/app/components/config/messaging_config_view.rs index 664744051..c40f1df07 100644 --- a/frontend/src/app/components/config/messaging_config_view.rs +++ b/frontend/src/app/components/config/messaging_config_view.rs @@ -1,10 +1,8 @@ use crate::app::components::config::config_page::{ConfigForm, LABEL_MESSAGING_CONFIG}; use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::{Card, Chip, RadioButtonGroup}; +use crate::app::components::{Card, Chip, RadioButtonGroup, TextArea}; use crate::app::ConfigContext; -use crate::{config_field, config_field_bool, config_field_bool_empty, config_field_child, - config_field_empty, config_field_hide, config_field_optional, edit_field_bool, - edit_field_list, edit_field_text, edit_field_text_option, generate_form_reducer}; +use crate::{config_field, config_field_bool, config_field_bool_empty, config_field_child, config_field_custom, config_field_empty, config_field_hide, config_field_optional, edit_field_bool, edit_field_list, edit_field_text, edit_field_text_option, generate_form_reducer}; use shared::model::{DiscordMessagingConfigDto, MessagingConfigDto, MsgKind, PushoverMessagingConfigDto, RestMessagingConfigDto, TelegramMessagingConfigDto}; use std::rc::Rc; @@ -25,7 +23,6 @@ const LABEL_USER: &str = "LABEL.USER"; const LABEL_DISCORD: &str = "LABEL.DISCORD"; const LABEL_METHOD: &str = "LABEL.METHOD"; const LABEL_HEADERS: &str = "LABEL.HEADERS"; -const LABEL_BODY_TEMPLATE: &str = "LABEL.BODY_TEMPLATE"; const LABEL_WEBHOOK_URL: &str = "LABEL.WEBHOOK_URL"; const LABEL_ADD_HEADER: &str = "LABEL.ADD_HEADER"; @@ -36,6 +33,7 @@ generate_form_reducer!( BotToken => bot_token: String, ChatIds => chat_ids: Vec, Markdown => markdown: bool, + Templates => templates: std::collections::HashMap, } ); @@ -46,7 +44,7 @@ generate_form_reducer!( Url => url: String, Method => method: Option, Headers => headers: Vec, - Template => template: Option, + Templates => templates: std::collections::HashMap, } ); @@ -55,7 +53,7 @@ generate_form_reducer!( action_name: DiscordMessagingConfigFormAction, fields { Url => url: String, - Template => template: Option, + Templates => templates: std::collections::HashMap, } ); @@ -83,30 +81,30 @@ pub fn MessagingConfigView() -> Html { let config_ctx = use_context::().expect("ConfigContext not found"); let config_view_ctx = use_context::().expect("ConfigViewContext not found"); - let telegram_state: UseReducerHandle = + let telegram_state = use_reducer(|| TelegramMessagingConfigFormState { form: TelegramMessagingConfigDto::default(), modified: false, }); - let rest_state: UseReducerHandle = + let rest_state = use_reducer(|| RestMessagingConfigFormState { form: RestMessagingConfigDto::default(), modified: false, }); - let pushover_state: UseReducerHandle = + let pushover_state = use_reducer(|| PushoverMessagingConfigFormState { form: PushoverMessagingConfigDto::default(), modified: false, }); - let discord_state: UseReducerHandle = + let discord_state = use_reducer(|| DiscordMessagingConfigFormState { form: DiscordMessagingConfigDto::default(), modified: false, }); - let messaging_state: UseReducerHandle = + let messaging_state = use_reducer(|| MessagingConfigFormState { form: MessagingConfigDto::default(), modified: false, @@ -201,6 +199,23 @@ pub fn MessagingConfigView() -> Html { }); } + let render_templates_view = |templates: &std::collections::HashMap| { + if templates.is_empty() { + html! {} + } else { + html! { +
+

{translate.t("LABEL.TEMPLATES")}

+ { for templates.iter().map(|(kind, template)| { + html! { + { config_field_custom!(translate.t(&format!("LABEL.MSG_KIND_{}", kind.to_string().to_uppercase())), template.clone()) } + } + })} +
+ } + } + }; + let render_telegram = |telegram: Option<&TelegramMessagingConfigDto>| match telegram { Some(entry) => html! { @@ -220,6 +235,7 @@ pub fn MessagingConfigView() -> Html { } })} { config_field_bool!(entry, translate.t(LABEL_MARKDOWN), markdown) } + { render_templates_view(&entry.templates) } }, None => html! { @@ -238,7 +254,6 @@ pub fn MessagingConfigView() -> Html {

{translate.t(LABEL_REST)}

{ config_field!(entry, translate.t(LABEL_URL), url) } { config_field_optional!(entry, translate.t(LABEL_METHOD), method) } - { config_field_optional!(entry, translate.t(LABEL_BODY_TEMPLATE), template) } { config_field_child!(translate.t(LABEL_HEADERS), { html! {
@@ -252,6 +267,7 @@ pub fn MessagingConfigView() -> Html {
} })} + { render_templates_view(&entry.templates) } }, None => html! { @@ -267,7 +283,7 @@ pub fn MessagingConfigView() -> Html {

{translate.t(LABEL_DISCORD)}

{ config_field_hide!(entry, translate.t(LABEL_WEBHOOK_URL), url) } - { config_field_optional!(entry, translate.t(LABEL_BODY_TEMPLATE), template) } + { render_templates_view(&entry.templates) }
}, None => html! { @@ -355,6 +371,31 @@ pub fn MessagingConfigView() -> Html { { edit_field_text!(telegram_state, translate.t(LABEL_BOT_TOKEN), bot_token, TelegramMessagingConfigFormAction::BotToken, true) } { edit_field_list!(telegram_state, translate.t(LABEL_CHAT_IDS), chat_ids, TelegramMessagingConfigFormAction::ChatIds, translate.t("LABEL.ADD_CHAT_ID")) } { edit_field_bool!(telegram_state, translate.t(LABEL_MARKDOWN), markdown, TelegramMessagingConfigFormAction::Markdown) } +
+

{translate.t("LABEL.TEMPLATES")}

+ { for notify_on_options.iter().map(|kind| { + let kind_str = translate.t(&format!("LABEL.MSG_KIND_{}", kind.to_string().to_uppercase())); + let current_val = telegram_state.form.templates.get(kind).cloned().unwrap_or_default(); + let telegram_state = telegram_state.clone(); + let kind = *kind; + html! { +