mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-11 10:22:10 +02:00
Merge pull request #385 from euzu/feature/telegram_markdown
Feature/telegram markdown
This commit is contained in:
@@ -11,6 +11,7 @@ Placing playlist into memory causes more RAM usage but reduces disk access.
|
||||
Output filters are applied after all transformations have been performed, therefore, all filter contents must refer to the final state of the playlist.
|
||||
- Added burst buffer to shared stream
|
||||
- Telegram message thread support. thread id can now be appended to chat-id like `chat-id:thread-id`.
|
||||
- Telegram supports markdown generation for structured json messages. simply set `markdown: true` in telegram config.
|
||||
|
||||
# 3.1.7 (2025-10-10)
|
||||
- Added Dark/Bright theme switch
|
||||
|
||||
@@ -121,6 +121,9 @@ Messaging is Opt-In, you need to set the `notify_on` message types which are
|
||||
|
||||
`telegram`, `rest` and `pushover.net` configurations are optional.
|
||||
|
||||
`telegram` supports markdown generation for structured json messages.
|
||||
`telegram` supports `message_thread_id` for group chats. Simply put thread_id behind chat_id separated by `:`. `'<telegram chat id>:<message thread id>'`
|
||||
|
||||
```yaml
|
||||
messaging:
|
||||
notify_on:
|
||||
@@ -128,6 +131,7 @@ messaging:
|
||||
- stats
|
||||
- error
|
||||
telegram:
|
||||
markdown: true
|
||||
bot_token: '<telegram bot token>'
|
||||
chat_ids:
|
||||
- '<telegram chat id>'
|
||||
|
||||
@@ -16,10 +16,7 @@ use axum::response::IntoResponse;
|
||||
use bytes::Bytes;
|
||||
use futures::stream;
|
||||
use log::{debug, error};
|
||||
use shared::model::{
|
||||
FieldGetAccessor, PlaylistEntry, PlaylistItemType, TargetType, UserConnectionPermission,
|
||||
XtreamCluster,
|
||||
};
|
||||
use shared::model::{FieldGetAccessor, PlaylistEntry, PlaylistItemType, TargetType, UserConnectionPermission, XtreamCluster};
|
||||
use shared::utils::{concat_path, extract_extension_from_url, sanitize_sensitive_info, HLS_EXT};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
+32
-10
@@ -1,9 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
use crate::model::{MessagingConfig};
|
||||
use std::borrow::Cow;
|
||||
use crate::model::MessagingConfig;
|
||||
use crate::utils::{telegram_create_instance, telegram_send_message, SendMessageOption, SendMessageParseMode};
|
||||
use log::{debug, error};
|
||||
use reqwest::{header};
|
||||
use reqwest::header;
|
||||
use shared::model::MsgKind;
|
||||
use crate::utils::{telegram_create_instance, telegram_send_message};
|
||||
use shared::utils::json_str_to_markdown;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn is_enabled(kind: MsgKind, cfg: &MessagingConfig) -> bool {
|
||||
cfg.notify_on.contains(&kind)
|
||||
@@ -29,12 +31,24 @@ fn send_http_post_request(client: &Arc<reqwest::Client>, msg: &str, messaging: &
|
||||
}
|
||||
}
|
||||
|
||||
fn send_telegram_message(client: &Arc<reqwest::Client>, msg: &str, messaging: &MessagingConfig) {
|
||||
fn send_telegram_message(client: &Arc<reqwest::Client>, msg: &str, messaging: &MessagingConfig, json: bool) {
|
||||
// TODO use proxy settings
|
||||
if let Some(telegram) = &messaging.telegram {
|
||||
let (message, options) = {
|
||||
if json && 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)
|
||||
}
|
||||
} else {
|
||||
(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, msg, None);
|
||||
telegram_send_message(client, &bot, &message, options.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,19 +76,27 @@ fn send_pushover_message(client: &Arc<reqwest::Client>, msg: &str, messaging: &M
|
||||
} else {
|
||||
error!("Failed to send text message to PUSHOVER, status code {}", response.status());
|
||||
}
|
||||
},
|
||||
}
|
||||
Err(e) => error!("Text message wasn't sent to PUSHOVER api because of: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_message(client: &Arc<reqwest::Client>, kind: &MsgKind, cfg: Option<&MessagingConfig>, msg: &str) {
|
||||
fn dispatch_send_message(client: &Arc<reqwest::Client>, kind: MsgKind, cfg: Option<&MessagingConfig>, msg: &str, json: bool) {
|
||||
if let Some(messaging) = cfg {
|
||||
if is_enabled(*kind, messaging) {
|
||||
send_telegram_message(client, msg, messaging);
|
||||
if is_enabled(kind, messaging) {
|
||||
send_telegram_message(client, msg, messaging, json);
|
||||
send_http_post_request(client, msg, messaging);
|
||||
send_pushover_message(client, msg, messaging);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_message_json(client: &Arc<reqwest::Client>, kind: MsgKind, cfg: Option<&MessagingConfig>, msg: &str) {
|
||||
dispatch_send_message(client, kind, cfg, msg, true);
|
||||
}
|
||||
|
||||
pub fn send_message(client: &Arc<reqwest::Client>, kind: MsgKind, cfg: Option<&MessagingConfig>, msg: &str) {
|
||||
dispatch_send_message(client, kind, cfg, msg, false);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::model::macros;
|
||||
pub struct TelegramMessagingConfig {
|
||||
pub bot_token: String,
|
||||
pub chat_ids: Vec<String>,
|
||||
pub markdown: bool,
|
||||
}
|
||||
|
||||
macros::from_impl!(TelegramMessagingConfig);
|
||||
@@ -13,6 +14,7 @@ impl From<&TelegramMessagingConfigDto> for TelegramMessagingConfig {
|
||||
Self {
|
||||
bot_token: dto.bot_token.clone(),
|
||||
chat_ids: dto.chat_ids.clone(),
|
||||
markdown: dto.markdown,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +24,7 @@ impl From<&TelegramMessagingConfig> for TelegramMessagingConfigDto {
|
||||
Self {
|
||||
bot_token: instance.bot_token.clone(),
|
||||
chat_ids: instance.chat_ids.clone(),
|
||||
markdown: instance.markdown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +82,12 @@ pub struct SourceStats {
|
||||
}
|
||||
|
||||
impl SourceStats {
|
||||
pub fn new(inputs: Vec<InputStats>, targets: Vec<TargetStats>)->Self {
|
||||
Self {inputs, targets}
|
||||
pub fn try_new(inputs: Vec<InputStats>, targets: Vec<TargetStats>) -> Option<Self> {
|
||||
if inputs.is_empty() || targets.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Self {inputs, targets})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,26 +52,28 @@ pub fn process_group_watch(client: &Arc<reqwest::Client>, cfg: &Config, target_n
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct WatchChanges {
|
||||
pub target: String,
|
||||
pub group: String,
|
||||
pub added: Vec<String>,
|
||||
pub removed: Vec<String>,
|
||||
}
|
||||
|
||||
fn handle_watch_notification(client: &Arc<reqwest::Client>, cfg: &Config, added: &BTreeSet<String>, removed: &BTreeSet<String>, target_name: &str, group_name: &str) {
|
||||
let added_entries = added.iter().map(std::string::ToString::to_string).collect::<Vec<String>>().join("\n\t");
|
||||
let removed_entries = removed.iter().map(std::string::ToString::to_string).collect::<Vec<String>>().join("\n\t");
|
||||
let added = added.iter().map(std::string::ToString::to_string).collect::<Vec<String>>();
|
||||
let removed = removed.iter().map(std::string::ToString::to_string).collect::<Vec<String>>();
|
||||
if !added.is_empty() || !removed.is_empty() {
|
||||
let changes = WatchChanges {
|
||||
target: target_name.to_string(),
|
||||
group: group_name.to_string(),
|
||||
added,
|
||||
removed
|
||||
};
|
||||
|
||||
let mut message = vec![];
|
||||
if !added_entries.is_empty() {
|
||||
message.push("added: [\n\t".to_string());
|
||||
message.push(added_entries);
|
||||
message.push("\n]\n".to_string());
|
||||
}
|
||||
if !removed_entries.is_empty() {
|
||||
message.push("removed: [\n\t".to_string());
|
||||
message.push(removed_entries);
|
||||
message.push("\n]\n".to_string());
|
||||
}
|
||||
|
||||
if !message.is_empty() {
|
||||
let msg = format!("Changes {}/{}\n{}", target_name, group_name, message.join(""));
|
||||
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);
|
||||
send_message(client, MsgKind::Watch, cfg.messaging.as_ref(), &msg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::messaging::send_message;
|
||||
use crate::api::model::{EventManager, EventMessage, PlaylistStorageState};
|
||||
use crate::messaging::send_message_json;
|
||||
use crate::model::Epg;
|
||||
use crate::model::{ConfigTarget, ProcessTargets};
|
||||
use crate::model::{Mapping};
|
||||
use crate::model::FetchedPlaylist;
|
||||
use crate::model::Mapping;
|
||||
use crate::model::{ConfigTarget, ProcessTargets};
|
||||
use crate::model::{InputStats, PlaylistStats, SourceStats, TargetStats};
|
||||
use crate::processing::parser::xmltv::flatten_tvguide;
|
||||
use crate::processing::playlist_watch::process_group_watch;
|
||||
@@ -34,7 +35,6 @@ use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputTy
|
||||
PlaylistGroup, PlaylistItem, PlaylistUpdateState, ProcessingOrder, UUIDType, XtreamCluster};
|
||||
use shared::utils::default_as_default;
|
||||
use std::time::Instant;
|
||||
use crate::api::model::{EventManager, EventMessage, PlaylistStorageState};
|
||||
|
||||
fn is_valid(pli: &PlaylistItem, filter: &Filter) -> bool {
|
||||
let provider = ValueProvider { pli };
|
||||
@@ -248,8 +248,8 @@ async fn playlist_download_from_input(client: &Arc<reqwest::Client>, config: &Ar
|
||||
|
||||
async fn process_source(client: Arc<reqwest::Client>, cfg: Arc<AppConfig>, source_idx: usize,
|
||||
user_targets: Arc<ProcessTargets>, event_manager: Option<Arc<EventManager>>,
|
||||
playlist_state: Option<&Arc<PlaylistStorageState>>
|
||||
)-> (Vec<InputStats>, Vec<TargetStats>, Vec<TuliproxError>) {
|
||||
playlist_state: Option<&Arc<PlaylistStorageState>>,
|
||||
) -> (Vec<InputStats>, Vec<TargetStats>, Vec<TuliproxError>) {
|
||||
let sources = cfg.sources.load();
|
||||
let mut errors = vec![];
|
||||
let mut input_stats = HashMap::<String, InputStats>::new();
|
||||
@@ -292,7 +292,7 @@ async fn process_source(client: Arc<reqwest::Client>, cfg: Arc<AppConfig>, sourc
|
||||
}
|
||||
let elapsed = start_time.elapsed().as_secs();
|
||||
input_stats.insert(input_name.clone(), create_input_stat(group_count, channel_count, error_list.len(),
|
||||
input.input_type, input_name, elapsed));
|
||||
input.input_type, input_name, elapsed));
|
||||
}
|
||||
}
|
||||
if source_downloaded {
|
||||
@@ -340,7 +340,7 @@ fn create_input_stat(group_count: usize, channel_count: usize, error_count: usiz
|
||||
}
|
||||
|
||||
async fn process_sources(client: Arc<reqwest::Client>, config: &Arc<AppConfig>, user_targets: Arc<ProcessTargets>,
|
||||
event_manager: Option<Arc<EventManager>>, playlist_state: Option<&Arc<PlaylistStorageState>>
|
||||
event_manager: Option<Arc<EventManager>>, playlist_state: Option<&Arc<PlaylistStorageState>>,
|
||||
) -> (Vec<SourceStats>, Vec<TuliproxError>) {
|
||||
let mut handle_list = vec![];
|
||||
let thread_num = config.config.load().threads;
|
||||
@@ -376,10 +376,11 @@ async fn process_sources(client: Arc<reqwest::Client>, config: &Arc<AppConfig>,
|
||||
let (input_stats, target_stats, mut res_errors) =
|
||||
process_source(Arc::clone(&http_client), cfg, index, usr_trgts, event_manager, playlist_state.as_ref()).await;
|
||||
shared_errors.lock().await.append(&mut res_errors);
|
||||
let process_stats = SourceStats::new(input_stats, target_stats);
|
||||
shared_stats.lock().await.push(process_stats);
|
||||
if let Some(process_stats) = SourceStats::try_new(input_stats, target_stats) {
|
||||
shared_stats.lock().await.push(process_stats);
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
Err(err) => error!("Could not create runtime !!! {err}"),
|
||||
}
|
||||
};
|
||||
@@ -391,8 +392,9 @@ async fn process_sources(client: Arc<reqwest::Client>, config: &Arc<AppConfig>,
|
||||
let (input_stats, target_stats, mut res_errors) =
|
||||
process_source(Arc::clone(&client), cfg, index, usr_trgts, event_manager, playlist_state).await;
|
||||
shared_errors.lock().await.append(&mut res_errors);
|
||||
let process_stats = SourceStats::new(input_stats, target_stats);
|
||||
shared_stats.lock().await.push(process_stats);
|
||||
if let Some(process_stats) = SourceStats::try_new(input_stats, target_stats) {
|
||||
shared_stats.lock().await.push(process_stats);
|
||||
}
|
||||
}
|
||||
drop(update_lock);
|
||||
}
|
||||
@@ -476,7 +478,7 @@ async fn process_playlist_for_target(app_config: &AppConfig,
|
||||
stats: &mut HashMap<String, InputStats>,
|
||||
errors: &mut Vec<TuliproxError>,
|
||||
event_manager: Option<Arc<EventManager>>,
|
||||
playlist_state: Option<&Arc<PlaylistStorageState>>
|
||||
playlist_state: Option<&Arc<PlaylistStorageState>>,
|
||||
) -> Result<(), Vec<TuliproxError>> {
|
||||
let pipe = get_processing_pipe(target);
|
||||
debug_if_enabled!("Processing order is {}", &target.processing_order);
|
||||
@@ -526,7 +528,7 @@ async fn process_playlist_for_target(app_config: &AppConfig,
|
||||
let mut flat_new_playlist = flatten_groups(new_playlist);
|
||||
step.tick("playlist merge");
|
||||
|
||||
if sort_playlist(target, &mut flat_new_playlist) {
|
||||
if sort_playlist(target, &mut flat_new_playlist) {
|
||||
step.tick("playlist sort");
|
||||
}
|
||||
assign_channel_no_playlist(&mut flat_new_playlist);
|
||||
@@ -577,7 +579,7 @@ async fn process_epg(processed_fetched_playlists: &mut Vec<FetchedPlaylist<'_>>)
|
||||
}
|
||||
|
||||
fn process_watch(cfg: &Config, client: &Arc<reqwest::Client>, target: &ConfigTarget, new_playlist: &Vec<PlaylistGroup>) -> bool {
|
||||
if let Some(watches) = &target.watch {
|
||||
if let Some(watches) = &target.watch {
|
||||
if default_as_default().eq_ignore_ascii_case(&target.name) {
|
||||
error!("cant watch a target with no unique name");
|
||||
} else {
|
||||
@@ -603,19 +605,29 @@ pub async fn exec_processing(client: Arc<reqwest::Client>, app_config: Arc<AppCo
|
||||
}
|
||||
let config = app_config.config.load();
|
||||
let messaging = config.messaging.as_ref();
|
||||
if let Ok(stats_msg) = serde_json::to_string(&serde_json::Value::Object(serde_json::map::Map::from_iter([("stats".to_string(), serde_json::to_value(stats).unwrap())]))) {
|
||||
// print stats
|
||||
info!("{stats_msg}");
|
||||
// send stats
|
||||
send_message(&client, &MsgKind::Stats, messaging, stats_msg.as_str());
|
||||
match serde_json::to_value(&stats) {
|
||||
Ok(val) => {
|
||||
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());
|
||||
}
|
||||
Err(err) => error!("Failed to serialize playlist stats {err}"),
|
||||
}
|
||||
}
|
||||
Err(err) => error!("Failed to serialize playlist stats {err}")
|
||||
}
|
||||
|
||||
// send errors
|
||||
if let Some(message) = get_errors_notify_message!(errors, 255) {
|
||||
if let Some(events) = event_manager {
|
||||
events.send_event(EventMessage::PlaylistUpdate(PlaylistUpdateState::Failure));
|
||||
}
|
||||
if let Ok(error_msg) = serde_json::to_string(&serde_json::Value::Object(serde_json::map::Map::from_iter([("errors".to_string(), serde_json::Value::String(message))]))) {
|
||||
send_message(&client, &MsgKind::Error, messaging, error_msg.as_str());
|
||||
send_message_json(&client, MsgKind::Error, messaging, error_msg.as_str());
|
||||
}
|
||||
} else if let Some(events) = event_manager {
|
||||
events.send_event(EventMessage::PlaylistUpdate(PlaylistUpdateState::Success));
|
||||
@@ -626,17 +638,17 @@ pub async fn exec_processing(client: Arc<reqwest::Client>, app_config: Arc<AppCo
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod tests {
|
||||
// #[test]
|
||||
// fn test_jaro_winkeler() {
|
||||
// let data = [("yessport5", "heyessport5gold"), ("yessport5", "heyesport5gold")];
|
||||
//
|
||||
// data.iter().for_each(|(first, second)|
|
||||
// println!("jaro_winkler {} = {} => {}", first, second, strsim::jaro_winkler(first, second)));
|
||||
// // println!("jaro {}", strsim::jaro(data.0, data.1));
|
||||
// // println!("levenhstein {}", strsim::levenshtein(data.0, data.1));
|
||||
// // println!("damerau_levenshtein {:?}", strsim::damerau_levenshtein(data.0, data.1));
|
||||
// // println!("osa distance {:?}", strsim::osa_distance(data.0, data.1));
|
||||
// // println!("sorensen dice {:?}", strsim::sorensen_dice(data.0, data.1));
|
||||
// }
|
||||
// #[test]
|
||||
// fn test_jaro_winkeler() {
|
||||
// let data = [("yessport5", "heyessport5gold"), ("yessport5", "heyesport5gold")];
|
||||
//
|
||||
// data.iter().for_each(|(first, second)|
|
||||
// println!("jaro_winkler {} = {} => {}", first, second, strsim::jaro_winkler(first, second)));
|
||||
// // println!("jaro {}", strsim::jaro(data.0, data.1));
|
||||
// // println!("levenhstein {}", strsim::levenshtein(data.0, data.1));
|
||||
// // println!("damerau_levenshtein {:?}", strsim::damerau_levenshtein(data.0, data.1));
|
||||
// // println!("osa distance {:?}", strsim::osa_distance(data.0, data.1));
|
||||
// // println!("sorensen dice {:?}", strsim::sorensen_dice(data.0, data.1));
|
||||
// }
|
||||
|
||||
// }
|
||||
@@ -15,7 +15,6 @@ pub use self::logging::*;
|
||||
pub use self::trakt::*;
|
||||
pub use self::telegram::*;
|
||||
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! debug_if_enabled {
|
||||
($fmt:expr, $( $args:expr ),*) => {
|
||||
|
||||
@@ -157,7 +157,7 @@ async fn xtream_login(cfg: &Config, client: &Arc<reqwest::Client>, input: &Input
|
||||
if let Ok(cur_status) = ProxyUserStatus::from_str(&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:?}"));
|
||||
send_message(client, MsgKind::Info, cfg.messaging.as_ref(), &format!("User status for user {username} is {cur_status:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,11 +175,11 @@ async fn xtream_login(cfg: &Config, client: &Arc<reqwest::Client>, input: &Input
|
||||
let datetime = DateTime::from_timestamp(expiration_timestamp, 0).unwrap();
|
||||
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}"));
|
||||
send_message(client, MsgKind::Info, cfg.messaging.as_ref(), &format!("User account for user {username} expires {formatted}"));
|
||||
}
|
||||
} else {
|
||||
warn!("User account for user {username} is expired");
|
||||
send_message(client, &MsgKind::Info, cfg.messaging.as_ref(), &format!("User account for user {username} is expired"));
|
||||
send_message(client, MsgKind::Info, cfg.messaging.as_ref(), &format!("User account for user {username} is expired"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ pub enum SendMessageParseMode {
|
||||
|
||||
/// Options which can be used with `sendMessage` API
|
||||
pub struct SendMessageOption {
|
||||
pub parse_mode: Option<SendMessageParseMode>,
|
||||
pub parse_mode: SendMessageParseMode,
|
||||
}
|
||||
|
||||
fn get_send_message_parse_mode_str(mode: &SendMessageParseMode) -> &'static str {
|
||||
@@ -39,7 +39,7 @@ fn get_send_message_parse_mode_str(mode: &SendMessageParseMode) -> &'static str
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct RequestObj {
|
||||
struct RequestObj {
|
||||
pub chat_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_thread_id: Option<String>,
|
||||
@@ -66,7 +66,7 @@ pub fn telegram_send_message(
|
||||
client: &Arc<reqwest::Client>,
|
||||
instance: &BotInstance,
|
||||
msg: &str,
|
||||
options: Option<SendMessageOption>,
|
||||
options: Option<&SendMessageOption>,
|
||||
) {
|
||||
let chat_id = instance.chat_id.to_string();
|
||||
let raw_url_str = format!("https://api.telegram.org/bot{}/sendMessage", instance.bot_token);
|
||||
@@ -77,13 +77,13 @@ pub fn telegram_send_message(
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let request_json_obj = RequestObj {
|
||||
chat_id: instance.chat_id.clone(),
|
||||
message_thread_id: instance.message_thread_id.clone(),
|
||||
text: msg.to_string(),
|
||||
parse_mode: options
|
||||
.and_then(|o| o.parse_mode)
|
||||
.map(|mode: SendMessageParseMode| get_send_message_parse_mode_str(&mode))
|
||||
.map(|o| get_send_message_parse_mode_str(&o.parse_mode))
|
||||
.map(ToString::to_string),
|
||||
};
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"NOTIFY_ON": "Notify on",
|
||||
"BOT_TOKEN": "Bot Token",
|
||||
"CHAT_IDS": "Chat Ids",
|
||||
"MARKDOWN": "Markdown",
|
||||
"URL": "Url",
|
||||
"USER": "User",
|
||||
"USERS": "Users",
|
||||
|
||||
@@ -2,10 +2,7 @@ use crate::app::components::config::config_page::ConfigForm;
|
||||
use crate::app::components::config::config_view_context::ConfigViewContext;
|
||||
use crate::app::components::config::macros::HasFormData;
|
||||
use crate::app::components::{Card, Chip, RadioButtonGroup};
|
||||
use crate::{
|
||||
config_field, config_field_child, config_field_empty, config_field_hide, config_field_optional,
|
||||
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_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::{MessagingConfigDto, MsgKind, PushoverMessagingConfigDto, RestMessagingConfigDto, TelegramMessagingConfigDto};
|
||||
use std::rc::Rc;
|
||||
use std::str::FromStr;
|
||||
@@ -19,6 +16,7 @@ const LABEL_PUSHOVER: &str = "LABEL.PUSHOVER";
|
||||
const LABEL_REST: &str = "LABEL.REST";
|
||||
const LABEL_BOT_TOKEN: &str = "LABEL.BOT_TOKEN";
|
||||
const LABEL_CHAT_IDS: &str = "LABEL.CHAT_IDS";
|
||||
const LABEL_MARKDOWN: &str = "LABEL.MARKDOWN";
|
||||
const LABEL_URL: &str = "LABEL.URL";
|
||||
const LABEL_TOKEN: &str = "LABEL.TOKEN";
|
||||
const LABEL_USER: &str = "LABEL.USER";
|
||||
@@ -29,6 +27,7 @@ generate_form_reducer!(
|
||||
fields {
|
||||
BotToken => bot_token: String,
|
||||
ChatIds => chat_ids: Vec<String>,
|
||||
Markdown => markdown: bool,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -183,6 +182,7 @@ pub fn MessagingConfigView() -> Html {
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
{ config_field_bool!(entry, translate.t(LABEL_MARKDOWN), markdown) }
|
||||
</Card>
|
||||
},
|
||||
None => html! {
|
||||
@@ -190,6 +190,7 @@ pub fn MessagingConfigView() -> Html {
|
||||
<h1>{translate.t(LABEL_TELEGRAM)}</h1>
|
||||
{ config_field_empty!(translate.t(LABEL_BOT_TOKEN)) }
|
||||
{ config_field_empty!(translate.t(LABEL_CHAT_IDS)) }
|
||||
{ config_field_bool_empty!(translate.t(LABEL_MARKDOWN)) }
|
||||
</Card>
|
||||
},
|
||||
};
|
||||
@@ -237,7 +238,7 @@ pub fn MessagingConfigView() -> Html {
|
||||
html! { <div class="tp__messaging-config-view__notify-on">
|
||||
{ for notify_on_options.iter().map(|t| {
|
||||
let is_selected = msg_state.form.notify_on.contains(t);
|
||||
let class = if is_selected { "tp__text-button primary" } else { "tp__text-button" };
|
||||
let class = if is_selected { "tp__text-button tp__button-primary" } else { "tp__text-button" };
|
||||
html! {
|
||||
<Chip label={t.to_string()} class={class}/>
|
||||
}}) }
|
||||
@@ -282,8 +283,9 @@ pub fn MessagingConfigView() -> Html {
|
||||
<div class="tp__messaging-config-view__body tp__config-view-page__body">
|
||||
<Card class="tp__config-view__card">
|
||||
<h1>{translate.t(LABEL_TELEGRAM)}</h1>
|
||||
{ edit_field_text!(telegram_state, translate.t(LABEL_BOT_TOKEN), bot_token, TelegramMessagingConfigFormAction::BotToken) }
|
||||
{ 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) }
|
||||
</Card>
|
||||
|
||||
<Card class="tp__config-view__card">
|
||||
|
||||
@@ -6,6 +6,8 @@ use crate::utils::is_blank_optional_string;
|
||||
pub struct TelegramMessagingConfigDto {
|
||||
pub bot_token: String,
|
||||
pub chat_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub markdown: bool,
|
||||
}
|
||||
|
||||
impl TelegramMessagingConfigDto {
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::io::{self, Read};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize};
|
||||
use serde_json::{self, Deserializer, Value};
|
||||
use crate::utils::humanize_snake_case;
|
||||
|
||||
fn read_skipping_ws(mut reader: impl Read) -> io::Result<u8> {
|
||||
loop {
|
||||
@@ -166,4 +167,59 @@ where
|
||||
{
|
||||
let value: Option<String> = Option::deserialize(deserializer)?;
|
||||
Ok(value.unwrap_or_default())
|
||||
}
|
||||
|
||||
const MARKDOWN_SPECIAL_CHARS: &str = r#"_*[]()~`>#+-=|{}.!\"#;
|
||||
|
||||
fn escape_markdown_v2(text: &str) -> String {
|
||||
let mut escaped = String::new();
|
||||
for c in text.chars() {
|
||||
if MARKDOWN_SPECIAL_CHARS.contains(c) {
|
||||
escaped.push('\\');
|
||||
}
|
||||
escaped.push(c);
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
fn json_to_markdown(value: &Value) -> String {
|
||||
fn format_value(v: &Value, indent: usize) -> String {
|
||||
let pad = " ".repeat(indent);
|
||||
match v {
|
||||
Value::Object(map) => {
|
||||
let mut entries: Vec<_> = map.iter().collect();
|
||||
entries.sort_by_key(|(k, _)| *k);
|
||||
entries.into_iter()
|
||||
.map(|(k, v)| {
|
||||
let formatted = format_value(v, indent + 1);
|
||||
let key = escape_markdown_v2(&humanize_snake_case(k));
|
||||
if v.is_object() || v.is_array() {
|
||||
format!("{pad}*{key}:*\n{formatted}")
|
||||
} else {
|
||||
format!("{pad}*{key}:* {formatted}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
},
|
||||
Value::Array(arr) => arr.iter()
|
||||
.map(|v| {
|
||||
let dash_pad = if indent > 0 { " ".repeat(indent - 1) } else { "".to_string() };
|
||||
format!("{dash_pad}\\- {}", format_value(v, indent + 1).trim())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
Value::String(s) => escape_markdown_v2(s),
|
||||
Value::Number(n) => escape_markdown_v2(&n.to_string()),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Null => "null".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
format_value(value, 0)
|
||||
}
|
||||
|
||||
pub fn json_str_to_markdown(json_str: &str) -> Result<String, serde_json::Error> {
|
||||
let value: Value = serde_json::from_str(json_str)?;
|
||||
Ok(json_to_markdown(&value))
|
||||
}
|
||||
@@ -106,6 +106,27 @@ pub fn mask_credentials(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn humanize_snake_case(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut capitalize_next = true;
|
||||
|
||||
for c in s.chars() {
|
||||
if c == '_' {
|
||||
result.push(' ');
|
||||
capitalize_next = true;
|
||||
} else if capitalize_next {
|
||||
for up in c.to_uppercase() {
|
||||
result.push(up);
|
||||
}
|
||||
capitalize_next = false;
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::collections::HashSet;
|
||||
|
||||
Reference in New Issue
Block a user