pingap_webhook/
lib.rs

1// Copyright 2024-2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use async_trait::async_trait;
16use pingap_core::{
17    get_hostname, Notification, NotificationData, NotificationLevel,
18};
19use serde_json::{Map, Value};
20use std::time::Duration;
21use tracing::{error, info};
22
23pub const LOG_CATEGORY: &str = "webhook";
24
25pub struct WebhookNotificationSender {
26    url: String,
27    category: String,
28    notifications: Vec<String>,
29}
30
31impl WebhookNotificationSender {
32    pub fn new(
33        url: String,
34        category: String,
35        notifications: Vec<String>,
36    ) -> Self {
37        Self {
38            url,
39            category,
40            notifications,
41        }
42    }
43
44    /// Sends a notification via configured webhook
45    ///
46    /// Formats and sends the notification based on the webhook type (wecom, dingtalk, etc).
47    /// Will log success/failure and handle timeouts.
48    ///
49    /// # Arguments
50    /// * `params` - The notification parameters including category, level, message and optional remark
51    pub async fn send_notification(&self, params: NotificationData) {
52        info!(
53            category = LOG_CATEGORY,
54            notification = params.category,
55            title = params.title,
56            message = params.message,
57            "webhook notification"
58        );
59        let webhook_type = &self.category;
60        let url = &self.url;
61        if url.is_empty() {
62            return;
63        }
64        let found = self.notifications.contains(&params.category.to_string());
65        if !found {
66            return;
67        }
68        let category = params.category.to_string();
69        let level = params.level;
70        let ip = local_ip_list().join(";");
71
72        let client = reqwest::Client::new();
73        let mut data = serde_json::Map::new();
74        let hostname = get_hostname();
75        // TODO get app name from config
76        let name = "pingap".to_string();
77        let color_type = match level {
78            NotificationLevel::Error => "warning",
79            NotificationLevel::Warn => "warning",
80            _ => "comment",
81        };
82        let content = format!(
83            r###" <font color="{color_type}">{name}({level})</font>
84                >hostname: {hostname}
85                >ip: {ip}
86                >category: {category}
87                >message: {}"###,
88            params.message
89        );
90        match webhook_type.to_lowercase().as_str() {
91            "wecom" => {
92                let mut markdown_data = Map::new();
93                markdown_data
94                    .insert("content".to_string(), Value::String(content));
95                data.insert(
96                    "msgtype".to_string(),
97                    Value::String("markdown".to_string()),
98                );
99                data.insert(
100                    "markdown".to_string(),
101                    Value::Object(markdown_data),
102                );
103            },
104            "dingtalk" => {
105                let mut markdown_data = serde_json::Map::new();
106                markdown_data.insert(
107                    "title".to_string(),
108                    Value::String(category.to_string()),
109                );
110                markdown_data
111                    .insert("text".to_string(), Value::String(content));
112                data.insert(
113                    "msgtype".to_string(),
114                    Value::String("markdown".to_string()),
115                );
116                data.insert(
117                    "markdown".to_string(),
118                    Value::Object(markdown_data),
119                );
120            },
121            _ => {
122                data.insert("name".to_string(), Value::String(name));
123                data.insert(
124                    "level".to_string(),
125                    Value::String(level.to_string()),
126                );
127                data.insert(
128                    "hostname".to_string(),
129                    Value::String(hostname.to_string()),
130                );
131                data.insert("ip".to_string(), Value::String(ip));
132                data.insert("category".to_string(), Value::String(category));
133                data.insert(
134                    "message".to_string(),
135                    Value::String(params.message),
136                );
137            },
138        }
139
140        match client
141            .post(url)
142            .json(&data)
143            .timeout(Duration::from_secs(30))
144            .send()
145            .await
146        {
147            Ok(res) => {
148                if res.status().as_u16() < 400 {
149                    info!(category = LOG_CATEGORY, "send webhook success");
150                } else {
151                    error!(
152                        category = LOG_CATEGORY,
153                        status = res.status().to_string(),
154                        "send webhook fail"
155                    );
156                }
157            },
158            Err(e) => {
159                error!(
160                    category = LOG_CATEGORY,
161                    error = %e,
162                    "send webhook fail"
163                );
164            },
165        };
166    }
167}
168
169#[async_trait]
170impl Notification for WebhookNotificationSender {
171    async fn notify(&self, data: NotificationData) {
172        self.send_notification(data).await;
173    }
174}
175
176/// Returns a list of non-loopback IP addresses (both IPv4 and IPv6) for the local machine
177///
178/// # Returns
179/// A vector of IP addresses as strings
180fn local_ip_list() -> Vec<String> {
181    let mut ip_list = vec![];
182
183    if let Ok(value) = local_ip_address::local_ip() {
184        ip_list.push(value);
185    }
186    if let Ok(value) = local_ip_address::local_ipv6() {
187        ip_list.push(value);
188    }
189
190    ip_list
191        .iter()
192        .filter(|item| !item.is_loopback())
193        .map(|item| item.to_string())
194        .collect()
195}