use crate::{ClickUpClient, Result};
use serde::{Deserialize, Serialize};
pub struct WebhookManager {
client: ClickUpClient,
workspace_id: String,
}
impl WebhookManager {
pub fn new(client: ClickUpClient, workspace_id: String) -> Self {
Self {
client,
workspace_id,
}
}
pub fn from_token(api_token: String, workspace_id: String) -> Result<Self> {
let client = ClickUpClient::new(api_token)?;
Ok(Self::new(client, workspace_id))
}
pub async fn create_webhook(&self, config: &WebhookConfig) -> Result<Webhook> {
let endpoint = format!("/team/{}/webhook", self.workspace_id);
let body = serde_json::json!({
"endpoint": config.endpoint,
"events": config.events,
"status": config.status.as_ref().unwrap_or(&"active".to_string()),
});
let webhook: Webhook = self.client.post_json(&endpoint, &body).await?;
Ok(webhook)
}
pub async fn list_webhooks(&self) -> Result<Vec<Webhook>> {
let endpoint = format!("/team/{}/webhook", self.workspace_id);
#[derive(Deserialize)]
struct WebhooksResponse {
webhooks: Vec<Webhook>,
}
let response: WebhooksResponse = self.client.get_json(&endpoint).await?;
Ok(response.webhooks)
}
pub async fn update_webhook(
&self,
webhook_id: &str,
config: &WebhookConfig,
) -> Result<Webhook> {
let endpoint = format!("/webhook/{}", webhook_id);
let body = serde_json::json!({
"endpoint": config.endpoint,
"events": config.events,
"status": config.status.as_ref().unwrap_or(&"active".to_string()),
});
let webhook: Webhook = self.client.put_json(&endpoint, &body).await?;
Ok(webhook)
}
pub async fn delete_webhook(&self, webhook_id: &str) -> Result<()> {
let endpoint = format!("/webhook/{}", webhook_id);
#[derive(Deserialize)]
struct DeleteResponse {}
let _: DeleteResponse = self.client.delete_json(&endpoint).await?;
Ok(())
}
pub async fn find_webhook_by_endpoint(&self, endpoint_url: &str) -> Result<Option<Webhook>> {
let webhooks = self.list_webhooks().await?;
Ok(webhooks.into_iter().find(|w| w.endpoint == endpoint_url))
}
pub async fn webhook_exists(&self, endpoint_url: &str) -> Result<bool> {
Ok(self.find_webhook_by_endpoint(endpoint_url).await?.is_some())
}
pub async fn ensure_webhook(&self, config: &WebhookConfig) -> Result<Webhook> {
if let Some(existing) = self.find_webhook_by_endpoint(&config.endpoint).await? {
tracing::info!("Webhook já existe para {}, atualizando...", config.endpoint);
self.update_webhook(&existing.id, config).await
} else {
tracing::info!("Criando novo webhook para {}...", config.endpoint);
self.create_webhook(config).await
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookConfig {
pub endpoint: String,
pub events: Vec<WebhookEvent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Webhook {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub userid: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub team_id: Option<String>,
pub endpoint: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
pub events: Vec<WebhookEvent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub health: Option<WebhookHealth>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookHealth {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub fail_count: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "camelCase")]
pub enum WebhookEvent {
#[serde(rename = "taskCreated")]
TaskCreated,
#[serde(rename = "taskUpdated")]
TaskUpdated,
#[serde(rename = "taskDeleted")]
TaskDeleted,
#[serde(rename = "taskMoved")]
TaskMoved,
#[serde(rename = "taskStatusUpdated")]
TaskStatusUpdated,
#[serde(rename = "taskPriorityUpdated")]
TaskPriorityUpdated,
#[serde(rename = "taskAssigneeUpdated")]
TaskAssigneeUpdated,
#[serde(rename = "taskDueDateUpdated")]
TaskDueDateUpdated,
#[serde(rename = "taskTagUpdated")]
TaskTagUpdated,
#[serde(rename = "taskTimeEstimateUpdated")]
TaskTimeEstimateUpdated,
#[serde(rename = "taskTimeTracked")]
TaskTimeTracked,
#[serde(rename = "taskCommentPosted")]
TaskCommentPosted,
#[serde(rename = "taskCommentUpdated")]
TaskCommentUpdated,
#[serde(rename = "listCreated")]
ListCreated,
#[serde(rename = "listUpdated")]
ListUpdated,
#[serde(rename = "listDeleted")]
ListDeleted,
#[serde(rename = "folderCreated")]
FolderCreated,
#[serde(rename = "folderUpdated")]
FolderUpdated,
#[serde(rename = "folderDeleted")]
FolderDeleted,
#[serde(rename = "spaceCreated")]
SpaceCreated,
#[serde(rename = "spaceUpdated")]
SpaceUpdated,
#[serde(rename = "spaceDeleted")]
SpaceDeleted,
#[serde(rename = "goalCreated")]
GoalCreated,
#[serde(rename = "goalUpdated")]
GoalUpdated,
#[serde(rename = "goalDeleted")]
GoalDeleted,
#[serde(untagged)]
Other(String),
}
impl WebhookEvent {
pub fn all_task_events() -> Vec<Self> {
vec![
Self::TaskCreated,
Self::TaskUpdated,
Self::TaskDeleted,
Self::TaskMoved,
Self::TaskStatusUpdated,
Self::TaskPriorityUpdated,
Self::TaskAssigneeUpdated,
Self::TaskDueDateUpdated,
Self::TaskTagUpdated,
Self::TaskTimeEstimateUpdated,
Self::TaskTimeTracked,
Self::TaskCommentPosted,
Self::TaskCommentUpdated,
]
}
pub fn essential_task_events() -> Vec<Self> {
vec![Self::TaskCreated, Self::TaskUpdated, Self::TaskDeleted]
}
pub fn all_list_events() -> Vec<Self> {
vec![Self::ListCreated, Self::ListUpdated, Self::ListDeleted]
}
pub fn all_structure_events() -> Vec<Self> {
vec![
Self::SpaceCreated,
Self::SpaceUpdated,
Self::SpaceDeleted,
Self::FolderCreated,
Self::FolderUpdated,
Self::FolderDeleted,
Self::ListCreated,
Self::ListUpdated,
Self::ListDeleted,
]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookPayload {
pub webhook_id: String,
pub event: WebhookEvent,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub list_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub folder_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_id: Option<String>,
#[serde(flatten)]
pub data: serde_json::Value,
}
impl WebhookPayload {
pub fn verify_signature(signature: &str, secret: &str, body: &[u8]) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(body);
let result = mac.finalize();
let expected = hex::encode(result.into_bytes());
signature == expected
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_webhook_event_serialization() {
let event = WebhookEvent::TaskCreated;
let json = serde_json::to_string(&event).unwrap();
assert_eq!(json, r#""taskCreated""#);
}
#[test]
fn test_webhook_event_deserialization() {
let json = r#""taskStatusUpdated""#;
let event: WebhookEvent = serde_json::from_str(json).unwrap();
assert_eq!(event, WebhookEvent::TaskStatusUpdated);
}
#[test]
fn test_all_task_events() {
let events = WebhookEvent::all_task_events();
assert!(events.len() >= 10);
assert!(events.contains(&WebhookEvent::TaskCreated));
assert!(events.contains(&WebhookEvent::TaskUpdated));
}
#[test]
fn test_webhook_config_serialization() {
let config = WebhookConfig {
endpoint: "https://example.com/webhook".to_string(),
events: vec![WebhookEvent::TaskCreated, WebhookEvent::TaskUpdated],
status: Some("active".to_string()),
};
let json = serde_json::to_value(&config).unwrap();
assert_eq!(json["endpoint"], "https://example.com/webhook");
assert!(json["events"].is_array());
}
#[test]
fn test_verify_signature() {
let secret = "test_secret";
let body = b"test payload";
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let valid_signature = hex::encode(mac.finalize().into_bytes());
assert!(WebhookPayload::verify_signature(
&valid_signature,
secret,
body
));
assert!(!WebhookPayload::verify_signature("invalid", secret, body));
}
}