generalbots/botserver/src/attendance/webhooks.rs
Rodrigo Rodriguez (Pragmatismo) 037db5c381 feat: Major workspace reorganization and documentation update
- Add comprehensive documentation in botbook/ with 12 chapters
- Add botapp/ Tauri desktop application
- Add botdevice/ IoT device support
- Add botlib/ shared library crate
- Add botmodels/ Python ML models service
- Add botplugin/ browser extension
- Add botserver/ reorganized server code
- Add bottemplates/ bot templates
- Add bottest/ integration tests
- Add botui/ web UI server
- Add CI/CD workflows in .forgejo/workflows/
- Add AGENTS.md and PROD.md documentation
- Add dependency management scripts (DEPENDENCIES.sh/ps1)
- Remove legacy src/ structure and migrations
- Clean up temporary and backup files
2026-04-19 08:14:25 -03:00

329 lines
10 KiB
Rust

use axum::{extract::State, http::StatusCode, Json};
use chrono::Utc;
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::core::shared::schema::attendance_webhooks;
use crate::core::shared::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttendanceWebhook {
pub id: Uuid,
pub org_id: Uuid,
pub bot_id: Uuid,
pub webhook_url: String,
pub events: Vec<String>,
pub is_active: bool,
pub secret_key: Option<String>,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: Option<chrono::DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateWebhookRequest {
pub webhook_url: String,
pub events: Vec<String>,
pub secret_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateWebhookRequest {
pub webhook_url: Option<String>,
pub events: Option<Vec<String>>,
pub is_active: Option<bool>,
pub secret_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookPayload {
pub event: String,
pub timestamp: String,
pub bot_id: Uuid,
pub data: serde_json::Value,
}
fn get_bot_context(state: &AppState) -> (Uuid, Uuid) {
use diesel::prelude::*;
use crate::core::shared::schema::bots;
let Ok(mut conn) = state.conn.get() else {
return (Uuid::nil(), Uuid::nil());
};
let (bot_id, _bot_name) = crate::core::bot::get_default_bot(&mut conn);
let org_id = bots::table
.filter(bots::id.eq(bot_id))
.select(bots::org_id)
.first::<Option<Uuid>>(&mut conn)
.unwrap_or(None)
.unwrap_or(Uuid::nil());
(org_id, bot_id)
}
pub async fn list_webhooks(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<AttendanceWebhook>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let webhooks: Vec<AttendanceWebhook> = attendance_webhooks::table
.filter(attendance_webhooks::org_id.eq(org_id))
.filter(attendance_webhooks::bot_id.eq(bot_id))
.order(attendance_webhooks::created_at.desc())
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(webhooks))
}
pub async fn create_webhook(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateWebhookRequest>,
) -> Result<Json<AttendanceWebhook>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let id = Uuid::new_v4();
let now = Utc::now();
let webhook = AttendanceWebhook {
id,
org_id,
bot_id,
webhook_url: req.webhook_url,
events: req.events,
is_active: true,
secret_key: req.secret_key,
created_at: now,
updated_at: Some(now),
};
diesel::insert_into(attendance_webhooks::table)
.values(&webhook)
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
Ok(Json(webhook))
}
pub async fn get_webhook(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<AttendanceWebhook>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let webhook: AttendanceWebhook = attendance_webhooks::table
.filter(attendance_webhooks::id.eq(id))
.first(&mut conn)
.map_err(|_| (StatusCode::NOT_FOUND, "Webhook not found".to_string()))?;
Ok(Json(webhook))
}
pub async fn update_webhook(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateWebhookRequest>,
) -> Result<Json<AttendanceWebhook>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let now = Utc::now();
if let Some(webhook_url) = req.webhook_url {
diesel::update(attendance_webhooks::table.filter(attendance_webhooks::id.eq(id)))
.set(attendance_webhooks::webhook_url.eq(webhook_url))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(events) = req.events {
diesel::update(attendance_webhooks::table.filter(attendance_webhooks::id.eq(id)))
.set(attendance_webhooks::events.eq(events))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(is_active) = req.is_active {
diesel::update(attendance_webhooks::table.filter(attendance_webhooks::id.eq(id)))
.set(attendance_webhooks::is_active.eq(is_active))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(secret_key) = req.secret_key {
diesel::update(attendance_webhooks::table.filter(attendance_webhooks::id.eq(id)))
.set(attendance_webhooks::secret_key.eq(Some(secret_key)))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
diesel::update(attendance_webhooks::table.filter(attendance_webhooks::id.eq(id)))
.set(attendance_webhooks::updated_at.eq(Some(now)))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
get_webhook(State(state), Path(id)).await
}
pub async fn delete_webhook(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
diesel::delete(attendance_webhooks::table.filter(attendance_webhooks::id.eq(id)))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn test_webhook(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let webhook: AttendanceWebhook = attendance_webhooks::table
.filter(attendance_webhooks::id.eq(id))
.first(&mut conn)
.map_err(|_| (StatusCode::NOT_FOUND, "Webhook not found".to_string()))?;
let payload = WebhookPayload {
event: "test".to_string(),
timestamp: Utc::now().to_rfc3339(),
bot_id: webhook.bot_id,
data: serde_json::json!({ "message": "This is a test webhook" }),
};
let payload_json = serde_json::to_string(&payload).map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Serialization error: {e}"))
})?;
let client = reqwest::Client::new();
let mut request = client.post(&webhook.webhook_url);
if let Some(ref secret) = webhook.secret_key {
use std::time::Duration;
let signature = calculate_hmac_signature(secret, &payload_json);
request = request.header("X-Webhook-Signature", signature);
}
request = request
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(10))
.body(payload_json);
match request.send().await {
Ok(response) => {
let status = response.status();
let body = response.text().await.unwrap_or_default();
Ok(Json(serde_json::json!({
"success": status.is_success(),
"status_code": status.as_u16(),
"response": body
})))
}
Err(e) => {
log::error!("Webhook test failed: {}", e);
Ok(Json(serde_json::json!({
"success": false,
"error": e.to_string()
})))
}
}
}
fn calculate_hmac_signature(secret: &str, payload: &str) -> String {
use std::io::Write;
let mut mac = hmac_sha256::HMAC::new(secret.as_bytes());
mac.write_all(payload.as_bytes()).unwrap();
format!("{:x}", mac.finalize())
}
pub fn emit_webhook_event(
conn: &mut PgConnection,
bot_id: Uuid,
event: &str,
data: serde_json::Value,
) {
use crate::core::shared::schema::attendance_webhooks::dsl::*;
let webhooks: Vec<(Uuid, String, Vec<String>, Option<String>)> = attendance_webhooks
.filter(attendance_webhooks::bot_id.eq(bot_id))
.filter(attendance_webhooks::is_active.eq(true))
.select((id, webhook_url, events, secret_key))
.load(conn)
.unwrap_or_default();
for (webhook_id, webhook_url, events, secret) in webhooks {
if !events.contains(&event.to_string()) {
continue;
}
let payload = WebhookPayload {
event: event.to_string(),
timestamp: Utc::now().to_rfc3339(),
bot_id,
data: data.clone(),
};
let payload_json = serde_json::to_string(&payload).unwrap_or_default();
let mut request = reqwest::Client::new()
.post(&webhook_url)
.header("Content-Type", "application/json")
.timeout(std::time::Duration::from_secs(5))
.body(payload_json.clone());
if let Some(ref secret_key) = secret {
let signature = calculate_hmac_signature(secret_key, &payload_json);
request = request.header("X-Webhook-Signature", signature);
}
let webhook_url_clone = webhook_url.clone();
tokio::spawn(async move {
if let Err(e) = request.send().await {
log::error!("Failed to emit webhook {}: {}", webhook_url_clone, e);
} else {
log::info!("Webhook emitted successfully: {} event={}", webhook_url_clone, event);
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hmac_signature_generation() {
let secret = "test-secret";
let payload = r#"{"event":"test","data":{}}"#;
let signature = calculate_hmac_signature(secret, payload);
assert!(!signature.is_empty());
assert_eq!(signature.len(), 64);
}
}