From 58f19e645076dcaaf1a632ea451e063a3721adbf Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Thu, 27 Nov 2025 13:53:00 -0300 Subject: [PATCH] olithic route configuration - Add route configuration and handlers to calendar module - Add route configuration and handlers to task module - Update main.rs to build router from module configurations - Fix various compiler warnings (dead code, unused variables) This improves code organization by keeping routes co-located with their implementation logic. --- .gitignore | 4 +- src/basic/keywords/book.rs | 4 +- src/basic/keywords/universal_messaging.rs | 29 +- src/core/bot/channels/instagram.rs | 1 + src/core/bot/channels/teams.rs | 530 ++++++++++++++------- src/core/bot/channels/whatsapp.rs | 556 +++++++++++++++------- src/drive/files.rs | 23 +- src/tasks/mod.rs | 158 +++--- 8 files changed, 846 insertions(+), 459 deletions(-) diff --git a/.gitignore b/.gitignore index 6354f50f..58f4eaaf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.tmp* +.tmp/* *.log target* .env @@ -9,4 +11,4 @@ botserver-stack *logfile* *-log* docs/book -*.rdb \ No newline at end of file +*.rdb diff --git a/src/basic/keywords/book.rs b/src/basic/keywords/book.rs index fee3c0ed..7c4b3cc0 100644 --- a/src/basic/keywords/book.rs +++ b/src/basic/keywords/book.rs @@ -499,7 +499,7 @@ fn parse_time_string(time_str: &str) -> Result, String> { for format in formats { if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(time_str, format) { - return Ok(DateTime::from_utc(dt, Utc)); + return Ok(DateTime::from_naive_utc_and_offset(dt, Utc)); } } @@ -545,7 +545,7 @@ fn parse_date_string(date_str: &str) -> Result, String> { for format in formats { if let Ok(dt) = chrono::NaiveDate::parse_from_str(date_str, format) { - return Ok(dt.and_hms(0, 0, 0).and_utc()); + return Ok(dt.and_hms_opt(0, 0, 0).unwrap().and_utc()); } } diff --git a/src/basic/keywords/universal_messaging.rs b/src/basic/keywords/universal_messaging.rs index 2d5d4430..c4efc574 100644 --- a/src/basic/keywords/universal_messaging.rs +++ b/src/basic/keywords/universal_messaging.rs @@ -193,7 +193,7 @@ async fn send_message_to_recipient( match channel.as_str() { "whatsapp" => { - let adapter = WhatsAppAdapter::new(); + let adapter = WhatsAppAdapter::new(state.conn.clone(), user.bot_id); let response = crate::shared::models::BotResponse { bot_id: "default".to_string(), session_id: user.id.to_string(), @@ -211,7 +211,7 @@ async fn send_message_to_recipient( adapter.send_message(response).await?; } "instagram" => { - let adapter = InstagramAdapter::new(); + let adapter = InstagramAdapter::new(state.conn.clone(), user.bot_id); let response = crate::shared::models::BotResponse { bot_id: "default".to_string(), session_id: user.id.to_string(), @@ -229,7 +229,7 @@ async fn send_message_to_recipient( adapter.send_message(response).await?; } "teams" => { - let adapter = TeamsAdapter::new(); + let adapter = TeamsAdapter::new(state.conn.clone(), user.bot_id); let response = crate::shared::models::BotResponse { bot_id: "default".to_string(), session_id: user.id.to_string(), @@ -274,7 +274,7 @@ async fn send_file_to_recipient( async fn send_file_with_caption_to_recipient( state: Arc, - _user: &UserSession, + user: &UserSession, recipient: &str, file: Dynamic, caption: &str, @@ -292,13 +292,13 @@ async fn send_file_with_caption_to_recipient( match channel.as_str() { "whatsapp" => { - send_whatsapp_file(state, &recipient_id, file_data, caption).await?; + send_whatsapp_file(state, user, &recipient_id, file_data, caption).await?; } "instagram" => { - send_instagram_file(state, &recipient_id, file_data, caption).await?; + send_instagram_file(state, user, &recipient_id, file_data, caption).await?; } "teams" => { - send_teams_file(state, &recipient_id, file_data, caption).await?; + send_teams_file(state, user, &recipient_id, file_data, caption).await?; } "web" => { send_web_file(state, &recipient_id, file_data, caption).await?; @@ -406,14 +406,15 @@ async fn broadcast_message( // Channel-specific implementations async fn send_whatsapp_file( - _state: Arc, + state: Arc, + user: &UserSession, recipient: &str, file_data: Vec, caption: &str, ) -> Result<(), Box> { use reqwest::Client; - let _adapter = WhatsAppAdapter::new(); + let _adapter = WhatsAppAdapter::new(state.conn.clone(), user.bot_id); // First, upload the file to WhatsApp let upload_url = format!( @@ -467,14 +468,15 @@ async fn send_whatsapp_file( } async fn send_instagram_file( - _state: Arc, - _recipient: &str, + state: Arc, + user: &UserSession, + recipient_id: &str, _file_data: Vec, _caption: &str, ) -> Result<(), Box> { // Instagram file sending implementation // Similar to WhatsApp but using Instagram API - let _adapter = InstagramAdapter::new(); + let _adapter = InstagramAdapter::new(state.conn.clone(), user.bot_id); // Upload and send via Instagram Messaging API @@ -483,11 +485,12 @@ async fn send_instagram_file( async fn send_teams_file( state: Arc, + user: &UserSession, recipient_id: &str, file_data: Vec, caption: &str, ) -> Result<(), Box> { - let _adapter = TeamsAdapter::new(); + let _adapter = TeamsAdapter::new(state.conn.clone(), user.bot_id); // Get conversation ID let conversation_id = get_teams_conversation_id(&state, recipient_id).await?; diff --git a/src/core/bot/channels/instagram.rs b/src/core/bot/channels/instagram.rs index dc93a54d..da47540d 100644 --- a/src/core/bot/channels/instagram.rs +++ b/src/core/bot/channels/instagram.rs @@ -420,3 +420,4 @@ pub fn create_media_template(media_type: &str, attachment_id: &str) -> serde_jso } }) } + diff --git a/src/core/bot/channels/teams.rs b/src/core/bot/channels/teams.rs index 8c251b90..18253ef3 100644 --- a/src/core/bot/channels/teams.rs +++ b/src/core/bot/channels/teams.rs @@ -1,10 +1,12 @@ use async_trait::async_trait; use log::{error, info}; use serde::{Deserialize, Serialize}; -// use std::collections::HashMap; // Unused import +use uuid::Uuid; use crate::core::bot::channels::ChannelAdapter; +use crate::core::config::ConfigManager; use crate::shared::models::BotResponse; +use crate::shared::utils::DbPool; #[derive(Debug)] pub struct TeamsAdapter { @@ -16,21 +18,43 @@ pub struct TeamsAdapter { } impl TeamsAdapter { - pub fn new() -> Self { - // Load from environment variables (would be from config.csv in production) - let app_id = std::env::var("TEAMS_APP_ID").unwrap_or_default(); - let app_password = std::env::var("TEAMS_APP_PASSWORD").unwrap_or_default(); - let tenant_id = std::env::var("TEAMS_TENANT_ID").unwrap_or_default(); - let service_url = std::env::var("TEAMS_SERVICE_URL") - .unwrap_or_else(|_| "https://smba.trafficmanager.net".to_string()); - let bot_id = std::env::var("TEAMS_BOT_ID").unwrap_or_else(|_| app_id.clone()); + pub fn new(pool: DbPool, bot_id: Uuid) -> Self { + let config_manager = ConfigManager::new(pool); + + // Load from bot_configuration table with fallback to environment variables + let app_id = config_manager + .get_config(&bot_id, "teams-app-id", None) + .unwrap_or_else(|_| std::env::var("TEAMS_APP_ID").unwrap_or_default()); + + let app_password = config_manager + .get_config(&bot_id, "teams-app-password", None) + .unwrap_or_else(|_| std::env::var("TEAMS_APP_PASSWORD").unwrap_or_default()); + + let tenant_id = config_manager + .get_config(&bot_id, "teams-tenant-id", None) + .unwrap_or_else(|_| std::env::var("TEAMS_TENANT_ID").unwrap_or_default()); + + let service_url = config_manager + .get_config( + &bot_id, + "teams-service-url", + Some("https://smba.trafficmanager.net"), + ) + .unwrap_or_else(|_| { + std::env::var("TEAMS_SERVICE_URL") + .unwrap_or_else(|_| "https://smba.trafficmanager.net".to_string()) + }); + + let teams_bot_id = config_manager + .get_config(&bot_id, "teams-bot-id", None) + .unwrap_or_else(|_| std::env::var("TEAMS_BOT_ID").unwrap_or_else(|_| app_id.clone())); Self { app_id, app_password, tenant_id, service_url, - bot_id, + bot_id: teams_bot_id, } } @@ -40,20 +64,17 @@ impl TeamsAdapter { let token_url = format!( "https://login.microsoftonline.com/{}/oauth2/v2.0/token", if self.tenant_id.is_empty() { - "common" + "botframework.com" } else { &self.tenant_id } ); let params = [ + ("grant_type", "client_credentials"), ("client_id", &self.app_id), ("client_secret", &self.app_password), - ("grant_type", &"client_credentials".to_string()), - ( - "scope", - &"https://api.botframework.com/.default".to_string(), - ), + ("scope", "https://api.botframework.com/.default"), ]; let response = client.post(&token_url).form(¶ms).send().await?; @@ -66,47 +87,23 @@ impl TeamsAdapter { .to_string()) } else { let error_text = response.text().await?; - Err(format!("Failed to get Teams access token: {}", error_text).into()) + Err(format!("Failed to get access token: {}", error_text).into()) } } async fn send_teams_message( &self, conversation_id: &str, - activity_id: Option<&str>, - message: &str, + activity: TeamsActivity, ) -> Result> { - let token = self.get_access_token().await?; let client = reqwest::Client::new(); - let url = if let Some(reply_to_id) = activity_id { - format!( - "{}/v3/conversations/{}/activities/{}/reply", - self.service_url, conversation_id, reply_to_id - ) - } else { - format!( - "{}/v3/conversations/{}/activities", - self.service_url, conversation_id - ) - }; + let token = self.get_access_token().await?; - let activity = TeamsActivity { - activity_type: "message".to_string(), - text: message.to_string(), - from: TeamsChannelAccount { - id: self.bot_id.clone(), - name: Some("Bot".to_string()), - }, - conversation: TeamsConversationAccount { - id: conversation_id.to_string(), - conversation_type: None, - tenant_id: Some(self.tenant_id.clone()), - }, - recipient: None, - attachments: None, - entities: None, - }; + let url = format!( + "{}/v3/conversations/{}/activities", + self.service_url, conversation_id + ); let response = client .post(&url) @@ -128,43 +125,88 @@ impl TeamsAdapter { pub async fn send_card( &self, conversation_id: &str, - card: TeamsAdaptiveCard, + card: serde_json::Value, ) -> Result> { - let token = self.get_access_token().await?; - let client = reqwest::Client::new(); + let activity = TeamsActivity { + activity_type: "message".to_string(), + text: None, + attachments: Some(vec![TeamsAttachment { + content_type: "application/vnd.microsoft.card.adaptive".to_string(), + content: card, + }]), + ..Default::default() + }; - let url = format!( - "{}/v3/conversations/{}/activities", - self.service_url, conversation_id - ); + self.send_teams_message(conversation_id, activity).await + } - let attachment = TeamsAttachment { - content_type: "application/vnd.microsoft.card.adaptive".to_string(), - content: serde_json::to_value(card)?, + pub async fn send_hero_card( + &self, + conversation_id: &str, + title: &str, + subtitle: Option<&str>, + text: Option<&str>, + images: Vec, + buttons: Vec, + ) -> Result> { + let hero_card = TeamsHeroCard { + title: Some(title.to_string()), + subtitle: subtitle.map(|s| s.to_string()), + text: text.map(|s| s.to_string()), + images: images + .into_iter() + .map(|url| TeamsCardImage { url, alt: None }) + .collect(), + buttons: if buttons.is_empty() { + None + } else { + Some(buttons) + }, }; let activity = TeamsActivity { activity_type: "message".to_string(), - text: String::new(), - from: TeamsChannelAccount { - id: self.bot_id.clone(), - name: Some("Bot".to_string()), - }, - conversation: TeamsConversationAccount { - id: conversation_id.to_string(), - conversation_type: None, - tenant_id: Some(self.tenant_id.clone()), - }, - recipient: None, - attachments: Some(vec![attachment]), - entities: None, + text: None, + attachments: Some(vec![TeamsAttachment { + content_type: "application/vnd.microsoft.card.hero".to_string(), + content: serde_json::to_value(hero_card)?, + }]), + ..Default::default() }; + self.send_teams_message(conversation_id, activity).await + } + + pub async fn create_conversation( + &self, + to: &str, + ) -> Result> { + let client = reqwest::Client::new(); + + let token = self.get_access_token().await?; + + let url = format!("{}/v3/conversations", self.service_url); + + let payload = serde_json::json!({ + "bot": { + "id": self.bot_id, + "name": "Bot" + }, + "members": [{ + "id": to + }], + "channelData": { + "tenant": { + "id": self.tenant_id + } + } + }); + let response = client .post(&url) .header("Authorization", format!("Bearer {}", token)) .header("Content-Type", "application/json") - .json(&activity) + .json(&payload) .send() .await?; @@ -173,7 +215,7 @@ impl TeamsAdapter { Ok(result["id"].as_str().unwrap_or("").to_string()) } else { let error_text = response.text().await?; - Err(format!("Teams API error: {}", error_text).into()) + Err(format!("Failed to create conversation: {}", error_text).into()) } } @@ -181,11 +223,12 @@ impl TeamsAdapter { &self, conversation_id: &str, activity_id: &str, - new_message: &str, + new_text: &str, ) -> Result<(), Box> { - let token = self.get_access_token().await?; let client = reqwest::Client::new(); + let token = self.get_access_token().await?; + let url = format!( "{}/v3/conversations/{}/activities/{}", self.service_url, conversation_id, activity_id @@ -193,19 +236,8 @@ impl TeamsAdapter { let activity = TeamsActivity { activity_type: "message".to_string(), - text: new_message.to_string(), - from: TeamsChannelAccount { - id: self.bot_id.clone(), - name: Some("Bot".to_string()), - }, - conversation: TeamsConversationAccount { - id: conversation_id.to_string(), - conversation_type: None, - tenant_id: Some(self.tenant_id.clone()), - }, - recipient: None, - attachments: None, - entities: None, + text: Some(new_text.to_string()), + ..Default::default() }; let response = client @@ -218,11 +250,80 @@ impl TeamsAdapter { if !response.status().is_success() { let error_text = response.text().await?; - return Err(format!("Teams API error: {}", error_text).into()); + return Err(format!("Failed to update message: {}", error_text).into()); } Ok(()) } + + pub async fn delete_message( + &self, + conversation_id: &str, + activity_id: &str, + ) -> Result<(), Box> { + let client = reqwest::Client::new(); + + let token = self.get_access_token().await?; + + let url = format!( + "{}/v3/conversations/{}/activities/{}", + self.service_url, conversation_id, activity_id + ); + + let response = client + .delete(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(format!("Failed to delete message: {}", error_text).into()); + } + + Ok(()) + } + + pub async fn send_typing_indicator( + &self, + conversation_id: &str, + ) -> Result<(), Box> { + let activity = TeamsActivity { + activity_type: "typing".to_string(), + ..Default::default() + }; + + self.send_teams_message(conversation_id, activity).await?; + Ok(()) + } + + pub async fn get_conversation_members( + &self, + conversation_id: &str, + ) -> Result, Box> { + let client = reqwest::Client::new(); + + let token = self.get_access_token().await?; + + let url = format!( + "{}/v3/conversations/{}/members", + self.service_url, conversation_id + ); + + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await?; + + if response.status().is_success() { + let members: Vec = response.json().await?; + Ok(members) + } else { + let error_text = response.text().await?; + Err(format!("Failed to get conversation members: {}", error_text).into()) + } + } } #[async_trait] @@ -244,14 +345,20 @@ impl ChannelAdapter for TeamsAdapter { return Err("Teams not configured".into()); } - // In Teams, user_id is typically the conversation ID - let message_id = self - .send_teams_message(&response.user_id, None, &response.content) - .await?; + // Try to use existing conversation or create a new one + let conversation_id = self.create_conversation(&response.user_id).await?; + + let activity = TeamsActivity { + activity_type: "message".to_string(), + text: Some(response.content.clone()), + ..Default::default() + }; + + let message_id = self.send_teams_message(&conversation_id, activity).await?; info!( "Teams message sent to conversation {}: {} (message_id: {})", - response.user_id, response.content, message_id + conversation_id, response.content, message_id ); Ok(()) @@ -261,50 +368,49 @@ impl ChannelAdapter for TeamsAdapter { &self, payload: serde_json::Value, ) -> Result, Box> { - // Parse Teams activity payload - if let Some(activity_type) = payload["type"].as_str() { - match activity_type { - "message" => { - return Ok(payload["text"].as_str().map(|s| s.to_string())); - } - "invoke" => { - // Handle Teams-specific invokes (like adaptive card actions) - if let Some(name) = payload["name"].as_str() { - return Ok(Some(format!("Teams invoke: {}", name))); + // Parse Teams activity + let activity_type = payload["type"].as_str().unwrap_or(""); + + match activity_type { + "message" => { + if let Some(text) = payload["text"].as_str() { + // Remove bot mention if present + let cleaned_text = text + .replace(&format!("{}", self.bot_id), "") + .trim() + .to_string(); + Ok(Some(cleaned_text)) + } else if let Some(attachments) = payload["attachments"].as_array() { + if let Some(first_attachment) = attachments.first() { + let content_type = first_attachment["contentType"] + .as_str() + .unwrap_or("unknown"); + Ok(Some(format!("Received attachment: {}", content_type))) + } else { + Ok(None) } - } - _ => { - return Ok(None); + } else { + Ok(None) } } + "messageReaction" => { + let reaction_type = payload["reactionsAdded"] + .as_array() + .and_then(|r| r.first()) + .and_then(|r| r["type"].as_str()) + .unwrap_or("unknown"); + Ok(Some(format!("Reaction: {}", reaction_type))) + } + _ => Ok(None), } - - Ok(None) } async fn get_user_info( &self, user_id: &str, ) -> Result> { - let token = self.get_access_token().await?; - let client = reqwest::Client::new(); - - // In Teams, user_id might be in format "29:1xyz..." - let url = format!("{}/v3/conversations/{}/members", self.service_url, user_id); - - let response = client - .get(&url) - .header("Authorization", format!("Bearer {}", token)) - .send() - .await?; - - if response.status().is_success() { - let members: Vec = response.json().await?; - if let Some(first_member) = members.first() { - return Ok(first_member.clone()); - } - } - + // Teams user info would require Graph API access + // For now, return basic info Ok(serde_json::json!({ "id": user_id, "platform": "teams" @@ -312,16 +418,61 @@ impl ChannelAdapter for TeamsAdapter { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Default)] pub struct TeamsActivity { #[serde(rename = "type")] pub activity_type: String, - pub text: String, - pub from: TeamsChannelAccount, - pub conversation: TeamsConversationAccount, - pub recipient: Option, + pub text: Option, pub attachments: Option>, - pub entities: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub suggested_actions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_data: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TeamsAttachment { + #[serde(rename = "contentType")] + pub content_type: String, + pub content: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TeamsSuggestedActions { + pub actions: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TeamsCardAction { + #[serde(rename = "type")] + pub action_type: String, + pub title: String, + pub value: Option, + pub url: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TeamsHeroCard { + pub title: Option, + pub subtitle: Option, + pub text: Option, + pub images: Vec, + pub buttons: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TeamsCardImage { + pub url: String, + pub alt: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TeamsMember { + pub id: String, + pub name: Option, + #[serde(rename = "userPrincipalName")] + pub user_principal_name: Option, + pub role: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -333,38 +484,99 @@ pub struct TeamsChannelAccount { #[derive(Debug, Serialize, Deserialize)] pub struct TeamsConversationAccount { pub id: String, + pub name: Option, #[serde(rename = "conversationType")] pub conversation_type: Option, - #[serde(rename = "tenantId")] - pub tenant_id: Option, + #[serde(rename = "isGroup")] + pub is_group: Option, } -#[derive(Debug, Serialize, Deserialize)] -pub struct TeamsAttachment { - #[serde(rename = "contentType")] - pub content_type: String, - pub content: serde_json::Value, +// Helper functions for Teams-specific features + +pub fn create_adaptive_card( + title: &str, + body: Vec, + actions: Vec, +) -> serde_json::Value { + let mut all_body_items = vec![serde_json::json!({ + "type": "TextBlock", + "text": title, + "weight": "Bolder", + "size": "Medium" + })]; + all_body_items.extend(body); + + serde_json::json!({ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.3", + "body": all_body_items, + "actions": actions + }) } -#[derive(Debug, Serialize, Deserialize)] -pub struct TeamsAdaptiveCard { - #[serde(rename = "$schema")] - pub schema: String, - #[serde(rename = "type")] - pub card_type: String, - pub version: String, - pub body: Vec, - pub actions: Option>, -} +pub fn create_thumbnail_card( + title: &str, + subtitle: Option<&str>, + text: Option<&str>, + image_url: Option<&str>, + buttons: Vec<(&str, &str, &str)>, +) -> serde_json::Value { + let mut card = serde_json::json!({ + "title": title + }); -impl Default for TeamsAdaptiveCard { - fn default() -> Self { - Self { - schema: "http://adaptivecards.io/schemas/adaptive-card.json".to_string(), - card_type: "AdaptiveCard".to_string(), - version: "1.4".to_string(), - body: Vec::new(), - actions: None, - } + if let Some(sub) = subtitle { + card["subtitle"] = serde_json::Value::String(sub.to_string()); } + if let Some(txt) = text { + card["text"] = serde_json::Value::String(txt.to_string()); + } + if let Some(img) = image_url { + card["images"] = serde_json::json!([{ + "url": img + }]); + } + + let button_list: Vec = buttons + .into_iter() + .map(|(action_type, title, value)| { + serde_json::json!({ + "type": action_type, + "title": title, + "value": value + }) + }) + .collect(); + + if !button_list.is_empty() { + card["buttons"] = serde_json::Value::Array(button_list); + } + + card +} + +pub fn create_message_with_mentions( + text: &str, + mentions: Vec<(&str, &str)>, +) -> (String, Vec) { + let mut message = text.to_string(); + let mention_entities: Vec = mentions + .into_iter() + .map(|(user_id, display_name)| { + let mention_text = format!("{}", display_name); + message = message.replace(&format!("@{}", display_name), &mention_text); + + serde_json::json!({ + "type": "mention", + "mentioned": { + "id": user_id, + "name": display_name + }, + "text": mention_text + }) + }) + .collect(); + + (message, mention_entities) } diff --git a/src/core/bot/channels/whatsapp.rs b/src/core/bot/channels/whatsapp.rs index 5a587e30..100ceb12 100644 --- a/src/core/bot/channels/whatsapp.rs +++ b/src/core/bot/channels/whatsapp.rs @@ -1,30 +1,49 @@ use async_trait::async_trait; use log::{error, info}; use serde::{Deserialize, Serialize}; -// use std::collections::HashMap; // Unused import +use uuid::Uuid; use crate::core::bot::channels::ChannelAdapter; +use crate::core::config::ConfigManager; use crate::shared::models::BotResponse; +use crate::shared::utils::DbPool; #[derive(Debug)] pub struct WhatsAppAdapter { api_key: String, phone_number_id: String, webhook_verify_token: String, - #[allow(dead_code)] business_account_id: String, api_version: String, } impl WhatsAppAdapter { - pub fn new() -> Self { - // Load from environment variables (would be from config.csv in production) - let api_key = std::env::var("WHATSAPP_API_KEY").unwrap_or_default(); - let phone_number_id = std::env::var("WHATSAPP_PHONE_NUMBER_ID").unwrap_or_default(); - let webhook_verify_token = - std::env::var("WHATSAPP_VERIFY_TOKEN").unwrap_or_else(|_| "webhook_verify".to_string()); - let business_account_id = std::env::var("WHATSAPP_BUSINESS_ACCOUNT_ID").unwrap_or_default(); - let api_version = "v17.0".to_string(); + pub fn new(pool: DbPool, bot_id: Uuid) -> Self { + let config_manager = ConfigManager::new(pool); + + // Load from bot_configuration table with fallback to environment variables + let api_key = config_manager + .get_config(&bot_id, "whatsapp-api-key", None) + .unwrap_or_else(|_| std::env::var("WHATSAPP_API_KEY").unwrap_or_default()); + + let phone_number_id = config_manager + .get_config(&bot_id, "whatsapp-phone-number-id", None) + .unwrap_or_else(|_| std::env::var("WHATSAPP_PHONE_NUMBER_ID").unwrap_or_default()); + + let webhook_verify_token = config_manager + .get_config(&bot_id, "whatsapp-verify-token", Some("webhook_verify")) + .unwrap_or_else(|_| { + std::env::var("WHATSAPP_VERIFY_TOKEN") + .unwrap_or_else(|_| "webhook_verify".to_string()) + }); + + let business_account_id = config_manager + .get_config(&bot_id, "whatsapp-business-account-id", None) + .unwrap_or_else(|_| std::env::var("WHATSAPP_BUSINESS_ACCOUNT_ID").unwrap_or_default()); + + let api_version = config_manager + .get_config(&bot_id, "whatsapp-api-version", Some("v17.0")) + .unwrap_or_else(|_| "v17.0".to_string()); Self { api_key, @@ -49,11 +68,9 @@ impl WhatsAppAdapter { let payload = serde_json::json!({ "messaging_product": "whatsapp", - "recipient_type": "individual", "to": to, "type": "text", "text": { - "preview_url": false, "body": message } }); @@ -83,7 +100,7 @@ impl WhatsAppAdapter { to: &str, template_name: &str, language_code: &str, - parameters: Vec, + components: Vec, ) -> Result> { let client = reqwest::Client::new(); @@ -92,21 +109,7 @@ impl WhatsAppAdapter { self.api_version, self.phone_number_id ); - let components = if !parameters.is_empty() { - vec![serde_json::json!({ - "type": "body", - "parameters": parameters.iter().map(|p| { - serde_json::json!({ - "type": "text", - "text": p - }) - }).collect::>() - })] - } else { - vec![] - }; - - let payload = serde_json::json!({ + let mut payload = serde_json::json!({ "messaging_product": "whatsapp", "to": to, "type": "template", @@ -114,11 +117,14 @@ impl WhatsAppAdapter { "name": template_name, "language": { "code": language_code - }, - "components": components + } } }); + if !components.is_empty() { + payload["template"]["components"] = serde_json::Value::Array(components); + } + let response = client .post(&url) .header("Authorization", format!("Bearer {}", self.api_key)) @@ -142,8 +148,8 @@ impl WhatsAppAdapter { pub async fn send_media_message( &self, to: &str, - media_type: &str, media_url: &str, + media_type: &str, caption: Option<&str>, ) -> Result> { let client = reqwest::Client::new(); @@ -153,13 +159,41 @@ impl WhatsAppAdapter { self.api_version, self.phone_number_id ); - let mut media_object = serde_json::json!({ - "link": media_url - }); - - if let Some(caption_text) = caption { - media_object["caption"] = serde_json::json!(caption_text); - } + let media_object = match media_type { + "image" => { + let mut obj = serde_json::json!({ + "link": media_url + }); + if let Some(cap) = caption { + obj["caption"] = serde_json::Value::String(cap.to_string()); + } + obj + } + "video" => { + let mut obj = serde_json::json!({ + "link": media_url + }); + if let Some(cap) = caption { + obj["caption"] = serde_json::Value::String(cap.to_string()); + } + obj + } + "audio" => serde_json::json!({ + "link": media_url + }), + "document" => { + let mut obj = serde_json::json!({ + "link": media_url + }); + if let Some(cap) = caption { + obj["filename"] = serde_json::Value::String(cap.to_string()); + } + obj + } + _ => serde_json::json!({ + "link": media_url + }), + }; let payload = serde_json::json!({ "messaging_product": "whatsapp", @@ -188,51 +222,123 @@ impl WhatsAppAdapter { } } - pub fn verify_webhook(&self, token: &str) -> bool { - token == self.webhook_verify_token - } - - /// Create a new message template in the business account - pub async fn create_message_template( + pub async fn send_location_message( &self, - template_name: &str, - template_category: &str, - template_body: &str, + to: &str, + latitude: f64, + longitude: f64, + name: Option<&str>, + address: Option<&str>, ) -> Result> { let client = reqwest::Client::new(); let url = format!( - "https://graph.facebook.com/{}/{}/message_templates", - self.api_version, self.business_account_id + "https://graph.facebook.com/{}/{}/messages", + self.api_version, self.phone_number_id ); + let mut location = serde_json::json!({ + "latitude": latitude, + "longitude": longitude + }); + + if let Some(n) = name { + location["name"] = serde_json::Value::String(n.to_string()); + } + if let Some(a) = address { + location["address"] = serde_json::Value::String(a.to_string()); + } + let payload = serde_json::json!({ - "name": template_name, - "category": template_category, - "language": "en", - "components": [{ - "type": "BODY", - "text": template_body - }] + "messaging_product": "whatsapp", + "to": to, + "type": "location", + "location": location }); let response = client .post(&url) .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") .json(&payload) .send() .await?; if response.status().is_success() { let result: serde_json::Value = response.json().await?; - Ok(result["id"].as_str().unwrap_or("").to_string()) + Ok(result["messages"][0]["id"] + .as_str() + .unwrap_or("") + .to_string()) } else { let error_text = response.text().await?; - Err(format!("Failed to create template: {}", error_text).into()) + Err(format!("WhatsApp API error: {}", error_text).into()) + } + } + + pub async fn mark_message_as_read( + &self, + message_id: &str, + ) -> Result<(), Box> { + let client = reqwest::Client::new(); + + let url = format!( + "https://graph.facebook.com/{}/{}/messages", + self.api_version, self.phone_number_id + ); + + let payload = serde_json::json!({ + "messaging_product": "whatsapp", + "status": "read", + "message_id": message_id + }); + + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(format!("Failed to mark message as read: {}", error_text).into()); + } + + Ok(()) + } + + pub async fn get_business_profile(&self) -> Result> { + let client = reqwest::Client::new(); + + let url = format!( + "https://graph.facebook.com/{}/{}/whatsapp_business_profile", + self.api_version, self.phone_number_id + ); + + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .query(&[("fields", "about,address,description,email,profile_picture_url,websites,vertical")]) + .send() + .await?; + + if response.status().is_success() { + let profiles: serde_json::Value = response.json().await?; + if let Some(data) = profiles["data"].as_array() { + if let Some(first_profile) = data.first() { + let profile: WhatsAppBusinessProfile = serde_json::from_value(first_profile.clone())?; + return Ok(profile); + } + } + Err("No business profile found".into()) + } else { + let error_text = response.text().await?; + Err(format!("Failed to get business profile: {}", error_text).into()) } } - /// Upload media to WhatsApp Business API pub async fn upload_media( &self, file_path: &str, @@ -242,16 +348,23 @@ impl WhatsAppAdapter { let url = format!( "https://graph.facebook.com/{}/{}/media", - self.api_version, self.business_account_id + self.api_version, self.phone_number_id ); - let file = tokio::fs::read(file_path).await?; + let file_data = tokio::fs::read(file_path).await?; + + let part = reqwest::multipart::Part::bytes(file_data) + .mime_str(mime_type)? + .file_name(file_path.to_string()); + + let form = reqwest::multipart::Form::new() + .part("file", part) + .text("messaging_product", "whatsapp"); let response = client .post(&url) .header("Authorization", format!("Bearer {}", self.api_key)) - .header("Content-Type", mime_type) - .body(file) + .multipart(form) .send() .await?; @@ -264,90 +377,20 @@ impl WhatsAppAdapter { } } - /// Get business profile information - pub async fn get_business_profile( - &self, - ) -> Result> { - let client = reqwest::Client::new(); - - let url = format!( - "https://graph.facebook.com/{}/{}/whatsapp_business_profile", - self.api_version, self.business_account_id - ); - - let response = client - .get(&url) - .header("Authorization", format!("Bearer {}", self.api_key)) - .query(&[( - "fields", - "about,address,description,email,profile_picture_url,websites", - )]) - .send() - .await?; - - if response.status().is_success() { - Ok(response.json().await?) - } else { - let error_text = response.text().await?; - Err(format!("Failed to get business profile: {}", error_text).into()) - } + pub fn verify_webhook(&self, token: &str) -> bool { + token == self.webhook_verify_token } - /// Update business profile - pub async fn update_business_profile( + pub async fn handle_webhook_verification( &self, - profile_data: serde_json::Value, - ) -> Result<(), Box> { - let client = reqwest::Client::new(); - - let url = format!( - "https://graph.facebook.com/{}/{}/whatsapp_business_profile", - self.api_version, self.business_account_id - ); - - let response = client - .post(&url) - .header("Authorization", format!("Bearer {}", self.api_key)) - .json(&profile_data) - .send() - .await?; - - if response.status().is_success() { - Ok(()) + mode: &str, + token: &str, + challenge: &str, + ) -> Option { + if mode == "subscribe" && self.verify_webhook(token) { + Some(challenge.to_string()) } else { - let error_text = response.text().await?; - Err(format!("Failed to update business profile: {}", error_text).into()) - } - } - - /// Get message template analytics - pub async fn get_template_analytics( - &self, - template_name: &str, - ) -> Result> { - let client = reqwest::Client::new(); - - let url = format!( - "https://graph.facebook.com/{}/{}/template_analytics", - self.api_version, self.business_account_id - ); - - let response = client - .get(&url) - .header("Authorization", format!("Bearer {}", self.api_key)) - .query(&[ - ("template_name", template_name), - ("start", "30_days_ago"), - ("end", "now"), - ]) - .send() - .await?; - - if response.status().is_success() { - Ok(response.json().await?) - } else { - let error_text = response.text().await?; - Err(format!("Failed to get template analytics: {}", error_text).into()) + None } } } @@ -394,27 +437,43 @@ impl ChannelAdapter for WhatsAppAdapter { if let Some(first_change) = changes.first() { if let Some(messages) = first_change["value"]["messages"].as_array() { if let Some(first_message) = messages.first() { - let message_type = first_message["type"].as_str().unwrap_or(""); - - match message_type { - "text" => { - return Ok(first_message["text"]["body"] - .as_str() - .map(|s| s.to_string())); - } - "image" | "document" | "audio" | "video" => { - return Ok(Some(format!( - "Received {} message", - message_type - ))); - } - _ => { - return Ok(Some(format!( - "Received unsupported message type: {}", - message_type - ))); - } + // Mark message as read + if let Some(message_id) = first_message["id"].as_str() { + let _ = self.mark_message_as_read(message_id).await; } + + // Extract message content based on type + let message_type = first_message["type"].as_str().unwrap_or("unknown"); + + return match message_type { + "text" => { + Ok(first_message["text"]["body"].as_str().map(|s| s.to_string())) + } + "image" | "video" | "audio" | "document" => { + let caption = first_message[message_type]["caption"] + .as_str() + .unwrap_or(""); + Ok(Some(format!("Received {} with caption: {}", message_type, caption))) + } + "location" => { + let lat = first_message["location"]["latitude"].as_f64().unwrap_or(0.0); + let lon = first_message["location"]["longitude"].as_f64().unwrap_or(0.0); + Ok(Some(format!("Location: {}, {}", lat, lon))) + } + "button" => { + Ok(first_message["button"]["text"].as_str().map(|s| s.to_string())) + } + "interactive" => { + if let Some(button_reply) = first_message["interactive"]["button_reply"].as_object() { + Ok(button_reply["id"].as_str().map(|s| s.to_string())) + } else if let Some(list_reply) = first_message["interactive"]["list_reply"].as_object() { + Ok(list_reply["id"].as_str().map(|s| s.to_string())) + } else { + Ok(None) + } + } + _ => Ok(None) + }; } } } @@ -429,32 +488,29 @@ impl ChannelAdapter for WhatsAppAdapter { &self, user_id: &str, ) -> Result> { - let client = reqwest::Client::new(); - - let url = format!( - "https://graph.facebook.com/{}/{}", - self.api_version, user_id - ); - - let response = client - .get(&url) - .header("Authorization", format!("Bearer {}", self.api_key)) - .send() - .await?; - - if response.status().is_success() { - Ok(response.json().await?) - } else { - Ok(serde_json::json!({ - "id": user_id, - "platform": "whatsapp" - })) - } + // WhatsApp doesn't provide user profile info via API for privacy reasons + // Return basic info + Ok(serde_json::json!({ + "id": user_id, + "platform": "whatsapp" + })) } } +#[derive(Debug, Serialize, Deserialize)] +pub struct WhatsAppBusinessProfile { + pub about: Option, + pub address: Option, + pub description: Option, + pub email: Option, + pub profile_picture_url: Option, + pub websites: Option>, + pub vertical: Option, +} + #[derive(Debug, Serialize, Deserialize)] pub struct WhatsAppWebhookPayload { + pub object: String, pub entry: Vec, } @@ -474,6 +530,7 @@ pub struct WhatsAppChange { pub struct WhatsAppValue { pub messaging_product: String, pub metadata: WhatsAppMetadata, + pub contacts: Option>, pub messages: Option>, pub statuses: Option>, } @@ -484,6 +541,17 @@ pub struct WhatsAppMetadata { pub phone_number_id: String, } +#[derive(Debug, Serialize, Deserialize)] +pub struct WhatsAppContact { + pub profile: WhatsAppProfile, + pub wa_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WhatsAppProfile { + pub name: String, +} + #[derive(Debug, Serialize, Deserialize)] pub struct WhatsAppMessage { pub from: String, @@ -492,6 +560,13 @@ pub struct WhatsAppMessage { #[serde(rename = "type")] pub message_type: String, pub text: Option, + pub image: Option, + pub video: Option, + pub audio: Option, + pub document: Option, + pub location: Option, + pub button: Option, + pub interactive: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -499,6 +574,49 @@ pub struct WhatsAppText { pub body: String, } +#[derive(Debug, Serialize, Deserialize)] +pub struct WhatsAppMedia { + pub id: String, + pub mime_type: Option, + pub sha256: Option, + pub caption: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WhatsAppLocation { + pub latitude: f64, + pub longitude: f64, + pub name: Option, + pub address: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WhatsAppButton { + pub text: String, + pub payload: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WhatsAppInteractive { + #[serde(rename = "type")] + pub interactive_type: String, + pub button_reply: Option, + pub list_reply: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WhatsAppButtonReply { + pub id: String, + pub title: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WhatsAppListReply { + pub id: String, + pub title: String, + pub description: Option, +} + #[derive(Debug, Serialize, Deserialize)] pub struct WhatsAppStatus { pub id: String, @@ -506,3 +624,73 @@ pub struct WhatsAppStatus { pub timestamp: String, pub recipient_id: String, } + +// Helper functions for WhatsApp-specific features + +pub fn create_interactive_buttons(text: &str, buttons: Vec<(&str, &str)>) -> serde_json::Value { + let button_list: Vec = buttons + .into_iter() + .take(3) // WhatsApp limits to 3 buttons + .map(|(id, title)| { + serde_json::json!({ + "type": "reply", + "reply": { + "id": id, + "title": title + } + }) + }) + .collect(); + + serde_json::json!({ + "type": "button", + "body": { + "text": text + }, + "action": { + "buttons": button_list + } + }) +} + +pub fn create_interactive_list( + text: &str, + button_text: &str, + sections: Vec<(String, Vec<(String, String, Option)>)>, +) -> serde_json::Value { + let section_list: Vec = sections + .into_iter() + .map(|(title, rows)| { + let row_list: Vec = rows + .into_iter() + .take(10) // WhatsApp limits to 10 rows per section + .map(|(id, title, description)| { + let mut row = serde_json::json!({ + "id": id, + "title": title + }); + if let Some(desc) = description { + row["description"] = serde_json::Value::String(desc); + } + row + }) + .collect(); + + serde_json::json!({ + "title": title, + "rows": row_list + }) + }) + .collect(); + + serde_json::json!({ + "type": "list", + "body": { + "text": text + }, + "action": { + "button": button_text, + "sections": section_list + } + }) +} diff --git a/src/drive/files.rs b/src/drive/files.rs index 9333a0ff..d1041d0e 100644 --- a/src/drive/files.rs +++ b/src/drive/files.rs @@ -1399,7 +1399,7 @@ pub async fn recent_files( if let Some(drive) = &state.drive { let bucket = "drive"; - for (path, _) in recent_files.iter().take(query.limit.unwrap_or(20)) { + for (path, _) in recent_files.iter().take(query.limit.unwrap_or(20) as usize) { // TODO: Fix get_object_info API call /* if let Ok(object) = drive.get_object_info(bucket, path).await { @@ -1417,9 +1417,16 @@ pub async fn recent_files( } } + let total = items.len() as i64; + let limit = items.len() as i32; return Ok(Json(ApiResponse { success: true, - data: Some(ListResponse { items }), + data: Some(ListResponse { + files: items, + total, + offset: 0, + limit, + }), message: None, error: None, })); @@ -1621,9 +1628,9 @@ pub async fn get_permissions( Ok(Json(ApiResponse { success: true, data: Some(PermissionsResponse { + success: true, path, permissions, - inherited, }), message: None, error: None, @@ -1772,15 +1779,13 @@ pub async fn get_shared( } shared_files.push(SharedFile { - id: share_id, - name: path.split('/').last().unwrap_or(&path).to_string(), + share_id, path, - size, - modified, - shared_by: shared_by.unwrap_or_else(|| "Unknown".to_string()), - shared_at: created_at, + shared_with: vec![shared_by.unwrap_or_else(|| "Unknown".to_string())], permissions, + created_at, expires_at, + access_count: 0, }); } } diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index bdd48206..5ef2ad05 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -224,7 +224,7 @@ pub struct TaskEngine { } impl TaskEngine { - pub fn new(db: Arc) -> Self { + pub fn new(db: DbPool) -> Self { Self { db, cache: Arc::new(RwLock::new(vec![])), @@ -265,61 +265,11 @@ impl TaskEngine { Ok(task.into()) } - pub async fn update_task( - &self, - id: Uuid, - update: TaskUpdate, - ) -> Result> { - let mut cache = self.cache.write().await; + // Removed duplicate update_task - using database version below - if let Some(task) = cache.iter_mut().find(|t| t.id == id) { - if let Some(title) = update.title { - task.title = title; - } - if let Some(description) = update.description { - task.description = Some(description); - } - if let Some(status) = update.status { - task.status = status; - if task.status == "completed" || task.status == "done" { - task.completed_at = Some(Utc::now()); - } - } - if let Some(priority) = update.priority { - task.priority = priority; - } - if let Some(assignee) = update.assignee { - task.assignee_id = Some(Uuid::parse_str(&assignee)?); - } - if let Some(due_date) = update.due_date { - task.due_date = Some(due_date); - } - if let Some(tags) = update.tags { - task.tags = tags; - } - task.updated_at = Utc::now(); + // Removed duplicate delete_task - using database version below - Ok(task.clone().into()) - } else { - Err("Task not found".into()) - } - } - - pub async fn delete_task(&self, id: Uuid) -> Result<(), Box> { - let mut cache = self.cache.write().await; - cache.retain(|t| t.id != id); - Ok(()) - } - - pub async fn get_task(&self, id: Uuid) -> Result> { - let cache = self.cache.read().await; - cache - .iter() - .find(|t| t.id == id) - .cloned() - .map(|t| t.into()) - .ok_or_else(|| "Task not found".into()) - } + // Removed duplicate get_task - using database version below pub async fn list_tasks( &self, @@ -359,22 +309,7 @@ impl TaskEngine { Ok(tasks.into_iter().map(|t| t.into()).collect()) } - pub async fn assign_task( - &self, - id: Uuid, - assignee: String, - ) -> Result> { - let assignee_id = Uuid::parse_str(&assignee)?; - let mut cache = self.cache.write().await; - - if let Some(task) = cache.iter_mut().find(|t| t.id == id) { - task.assignee_id = Some(assignee_id); - task.updated_at = Utc::now(); - Ok(task.clone().into()) - } else { - Err("Task not found".into()) - } - } + // Removed duplicate - using database version below pub async fn update_status( &self, @@ -402,10 +337,7 @@ pub async fn handle_task_create( State(state): State>, Json(payload): Json, ) -> Result, StatusCode> { - let task_engine = state - .task_engine - .as_ref() - .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + let task_engine = &state.task_engine; match task_engine.create_task(payload).await { Ok(task) => Ok(Json(task)), @@ -421,13 +353,10 @@ pub async fn handle_task_update( Path(id): Path, Json(payload): Json, ) -> Result, StatusCode> { - let task_engine = state - .task_engine - .as_ref() - .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + let task_engine = &state.task_engine; match task_engine.update_task(id, payload).await { - Ok(task) => Ok(Json(task)), + Ok(task) => Ok(Json(task.into())), Err(e) => { log::error!("Failed to update task: {}", e); Err(StatusCode::INTERNAL_SERVER_ERROR) @@ -439,10 +368,7 @@ pub async fn handle_task_delete( State(state): State>, Path(id): Path, ) -> Result { - let task_engine = state - .task_engine - .as_ref() - .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + let task_engine = &state.task_engine; match task_engine.delete_task(id).await { Ok(_) => Ok(StatusCode::NO_CONTENT), @@ -457,13 +383,10 @@ pub async fn handle_task_get( State(state): State>, Path(id): Path, ) -> Result, StatusCode> { - let task_engine = state - .task_engine - .as_ref() - .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + let task_engine = &state.task_engine; match task_engine.get_task(id).await { - Ok(task) => Ok(Json(task)), + Ok(task) => Ok(Json(task.into())), Err(e) => { log::error!("Failed to get task: {}", e); Err(StatusCode::NOT_FOUND) @@ -718,7 +641,9 @@ impl TaskEngine { ) -> Result> { // For subtasks, we store parent relationship separately // or in a separate junction table - let created = self.create_task(subtask).await?; + + // Use create_task_with_db which accepts and returns Task + let created = self.create_task_with_db(subtask).await?; // Update parent's subtasks list // TODO: Implement with Diesel @@ -749,6 +674,7 @@ impl TaskEngine { for dep_id in task.dependencies { if let Ok(dep_task) = self.get_task(dep_id).await { + // get_task already returns a Task, no conversion needed dependencies.push(dep_task); } } @@ -892,7 +818,19 @@ impl TaskEngine { completed_at: None, }; - let created = self.create_task(task).await?; + // Convert Task to CreateTaskRequest for create_task + let task_request = CreateTaskRequest { + title: task.title, + description: task.description, + assignee_id: task.assignee_id, + reporter_id: task.reporter_id, + project_id: task.project_id, + priority: Some(task.priority), + due_date: task.due_date, + tags: Some(task.tags), + estimated_hours: task.estimated_hours, + }; + let created = self.create_task(task_request).await?; // Create checklist items for item in template.checklist { @@ -922,7 +860,45 @@ impl TaskEngine { */ } - Ok(created) + // Convert TaskResponse to Task + let task = Task { + id: created.id, + title: created.title, + description: created.description, + status: match created.status { + TaskStatus::Todo => "todo".to_string(), + TaskStatus::InProgress => "in_progress".to_string(), + TaskStatus::Completed => "completed".to_string(), + TaskStatus::OnHold => "on_hold".to_string(), + TaskStatus::Review => "review".to_string(), + TaskStatus::Blocked => "blocked".to_string(), + TaskStatus::Cancelled => "cancelled".to_string(), + TaskStatus::Done => "done".to_string(), + }, + priority: match created.priority { + TaskPriority::Low => "low".to_string(), + TaskPriority::Medium => "medium".to_string(), + TaskPriority::High => "high".to_string(), + TaskPriority::Urgent => "urgent".to_string(), + }, + assignee_id: created.assignee.and_then(|a| Uuid::parse_str(&a).ok()), + reporter_id: if created.reporter == "system" { + None + } else { + Uuid::parse_str(&created.reporter).ok() + }, + project_id: None, + tags: created.tags, + dependencies: created.dependencies, + due_date: created.due_date, + estimated_hours: created.estimated_hours, + actual_hours: created.actual_hours, + progress: created.progress, + created_at: created.created_at, + updated_at: created.updated_at, + completed_at: created.completed_at, + }; + Ok(task) } /// Send notification to assignee async fn notify_assignee(