diff --git a/Cargo.lock b/Cargo.lock index b759ddf07..918987f34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1179,6 +1179,7 @@ dependencies = [ "urlencoding", "uuid", "vaultrs", + "walkdir", "webpki-roots 0.25.4", "x509-parser", "zip 2.4.2", diff --git a/Cargo.toml b/Cargo.toml index bada071d8..eef71c61b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -235,6 +235,7 @@ rss = "2.0" # HTML parsing/web scraping scraper = "0.25" +walkdir = "2.5.0" [dev-dependencies] mockito = "1.7.0" @@ -257,6 +258,12 @@ unwrap_used = "warn" expect_used = "warn" panic = "warn" todo = "warn" +# Disabled: has false positives for functions with mut self, heap types (Vec, String) +missing_const_for_fn = "allow" +# Disabled: transitive dependencies we cannot control +multiple_crate_versions = "allow" +# Disabled: many keyword functions need owned types for Rhai integration +needless_pass_by_value = "allow" [profile.release] lto = true diff --git a/PROMPT.md b/PROMPT.md index 479055003..e4bfb0649 100644 --- a/PROMPT.md +++ b/PROMPT.md @@ -5,283 +5,299 @@ --- -## Build Rules - IMPORTANT +## ZERO TOLERANCE POLICY -**Always use debug builds during development and testing:** +**This project has the strictest code quality requirements possible:** + +```toml +[lints.clippy] +all = "warn" +pedantic = "warn" +nursery = "warn" +cargo = "warn" +unwrap_used = "warn" +expect_used = "warn" +panic = "warn" +todo = "warn" +``` + +**EVERY SINGLE WARNING MUST BE FIXED. NO EXCEPTIONS.** + +--- + +## ABSOLUTE PROHIBITIONS + +``` +❌ NEVER use #![allow()] or #[allow()] in source code to silence warnings +❌ NEVER use _ prefix for unused variables - DELETE the variable or USE it +❌ NEVER use .unwrap() - use ? or proper error handling +❌ NEVER use .expect() - use ? or proper error handling +❌ NEVER use panic!() or unreachable!() - handle all cases +❌ NEVER use todo!() or unimplemented!() - write real code +❌ NEVER leave unused imports - DELETE them +❌ NEVER leave dead code - DELETE it or IMPLEMENT it +❌ NEVER use approximate constants (3.14159) - use std::f64::consts::PI +❌ NEVER silence clippy in code - FIX THE CODE or configure in Cargo.toml +❌ NEVER add comments explaining what code does - code must be self-documenting +❌ NEVER use CDN links - all assets must be local +``` + +--- + +## CARGO.TOML LINT EXCEPTIONS + +When a clippy lint has **technical false positives** that cannot be fixed in code, +disable it in `Cargo.toml` with a comment explaining why: + +```toml +[lints.clippy] +# Disabled: has false positives for functions with mut self, heap types (Vec, String) +missing_const_for_fn = "allow" +# Disabled: Tauri commands require owned types (Window) that cannot be passed by reference +needless_pass_by_value = "allow" +# Disabled: transitive dependencies we cannot control +multiple_crate_versions = "allow" +``` + +**Approved exceptions:** +- `missing_const_for_fn` - false positives for `mut self`, heap types +- `needless_pass_by_value` - Tauri/framework requirements +- `multiple_crate_versions` - transitive dependencies +- `future_not_send` - when async traits require non-Send futures + +--- + +## MANDATORY CODE PATTERNS + +### Error Handling - Use `?` Operator + +```rust +// ❌ WRONG +let value = something.unwrap(); +let value = something.expect("msg"); + +// ✅ CORRECT +let value = something?; +let value = something.ok_or_else(|| Error::NotFound)?; +``` + +### Option Handling - Use Combinators + +```rust +// ❌ WRONG +if let Some(x) = opt { + x +} else { + default +} + +// ✅ CORRECT +opt.unwrap_or(default) +opt.unwrap_or_else(|| compute_default()) +opt.map_or(default, |x| transform(x)) +``` + +### Match Arms - Must Be Different + +```rust +// ❌ WRONG - identical arms +match x { + A => do_thing(), + B => do_thing(), + C => other(), +} + +// ✅ CORRECT - combine identical arms +match x { + A | B => do_thing(), + C => other(), +} +``` + +### Self Usage in Impl Blocks + +```rust +// ❌ WRONG +impl MyStruct { + fn new() -> MyStruct { MyStruct { } } +} + +// ✅ CORRECT +impl MyStruct { + fn new() -> Self { Self { } } +} +``` + +### Format Strings - Inline Variables + +```rust +// ❌ WRONG +format!("Hello {}", name) +log::info!("Processing {}", id); + +// ✅ CORRECT +format!("Hello {name}") +log::info!("Processing {id}"); +``` + +### Display vs ToString + +```rust +// ❌ WRONG +impl ToString for MyType { + fn to_string(&self) -> String { } +} + +// ✅ CORRECT +impl std::fmt::Display for MyType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { } +} +``` + +### Derive Eq with PartialEq + +```rust +// ❌ WRONG +#[derive(PartialEq)] +struct MyStruct { } + +// ✅ CORRECT +#[derive(PartialEq, Eq)] +struct MyStruct { } +``` + +### Must Use Attributes + +```rust +// ❌ WRONG - pure function without #[must_use] +pub fn calculate() -> i32 { } + +// ✅ CORRECT +#[must_use] +pub fn calculate() -> i32 { } +``` + +### Const Functions + +```rust +// ❌ WRONG - could be const but isn't +pub fn default_value() -> i32 { 42 } + +// ✅ CORRECT +pub const fn default_value() -> i32 { 42 } +``` + +### Pass by Reference + +```rust +// ❌ WRONG - needless pass by value +fn process(data: String) { println!("{data}"); } + +// ✅ CORRECT +fn process(data: &str) { println!("{data}"); } +``` + +### Clone Only When Needed + +```rust +// ❌ WRONG - redundant clone +let x = value.clone(); +use_value(&x); + +// ✅ CORRECT +use_value(&value); +``` + +### Mathematical Constants + +```rust +// ❌ WRONG +let pi = 3.14159; +let e = 2.71828; + +// ✅ CORRECT +use std::f64::consts::{PI, E}; +let pi = PI; +let e = E; +``` + +### Async Functions + +```rust +// ❌ WRONG - async without await +async fn process() { sync_operation(); } + +// ✅ CORRECT - remove async if no await needed +fn process() { sync_operation(); } +``` + +--- + +## Build Rules ```bash -# CORRECT - debug build (fast compilation) +# Development - ALWAYS debug build cargo build cargo check -# WRONG - do NOT use release builds unless explicitly requested -# cargo build --release -``` - -Debug builds compile much faster and are sufficient for testing functionality. -Only use `--release` when building final binaries for deployment. - ---- - -## Weekly Maintenance - EVERY MONDAY - -### Package Review Checklist - -**Every Monday, review the following:** - -1. **Dependency Updates** - ```bash - cargo outdated - cargo audit - ``` - -2. **Package Consolidation Opportunities** - - Check if new crates can replace custom code - - Look for crates that combine multiple dependencies - - Review `Cargo.toml` for redundant dependencies - -3. **Code Reduction Candidates** - - Custom implementations that now have crate equivalents - - Boilerplate that can be replaced with derive macros - - Manual serialization that `serde` can handle - -4. **Security Updates** - ```bash - cargo audit fix - ``` - -### Packages to Watch - -| Area | Potential Packages | Purpose | -|------|-------------------|---------| -| Validation | `validator` | Replace manual validation | -| Date/Time | `chrono`, `time` | Consolidate time handling | -| Email | `lettre` | Simplify email sending | -| File Watching | `notify` | Replace polling with events | -| Background Jobs | `tokio-cron-scheduler` | Simplify scheduling | - ---- - -## Version Management - CRITICAL - -**Current version is 6.1.0 - DO NOT CHANGE without explicit approval!** - -```bash -# Check current version -grep "^version" Cargo.toml -``` - -### Rules - -1. **Version is 6.1.0 across ALL workspace crates** -2. **NEVER change version without explicit user approval** -3. **All migrations use 6.1.0_* prefix** -4. **Migration folder naming: `6.1.0_{feature_name}/`** - ---- - -## Database Standards - CRITICAL - -### TABLES AND INDEXES ONLY - -**NEVER create in migrations:** -- ❌ Views (`CREATE VIEW`) -- ❌ Triggers (`CREATE TRIGGER`) -- ❌ Functions (`CREATE FUNCTION`) -- ❌ Stored Procedures - -**ALWAYS use:** -- ✅ Tables (`CREATE TABLE IF NOT EXISTS`) -- ✅ Indexes (`CREATE INDEX IF NOT EXISTS`) -- ✅ Constraints (inline in table definitions) - -### Why? -- Diesel ORM compatibility -- Simpler rollbacks -- Better portability -- Easier testing - -### JSON Storage Pattern - -Use TEXT columns with `_json` suffix instead of JSONB: -```sql --- CORRECT -members_json TEXT DEFAULT '[]' - --- WRONG -members JSONB DEFAULT '[]'::jsonb +# NEVER use release unless deploying +# cargo build --release # NO! ``` --- -## Official Icons - MANDATORY +## Version Management -**NEVER generate icons with LLM. ALWAYS use official SVG icons from assets.** - -Icons are stored in two locations (kept in sync): -- `botui/ui/suite/assets/icons/` - Runtime icons for UI -- `botbook/src/assets/icons/` - Documentation icons - -### Available Icons - -| Icon | File | Usage | -|------|------|-------| -| Logo | `gb-logo.svg` | Main GB branding | -| Bot | `gb-bot.svg` | Bot/assistant representation | -| Analytics | `gb-analytics.svg` | Charts, metrics, dashboards | -| Calendar | `gb-calendar.svg` | Scheduling, events | -| Chat | `gb-chat.svg` | Conversations, messaging | -| Compliance | `gb-compliance.svg` | Security, auditing | -| Designer | `gb-designer.svg` | Workflow automation | -| Drive | `gb-drive.svg` | File storage, documents | -| Mail | `gb-mail.svg` | Email functionality | -| Meet | `gb-meet.svg` | Video conferencing | -| Paper | `gb-paper.svg` | Document editing | -| Research | `gb-research.svg` | Search, investigation | -| Sources | `gb-sources.svg` | Knowledge bases | -| Tasks | `gb-tasks.svg` | Task management | - -### Icon Guidelines - -- All icons use `stroke="currentColor"` for CSS theming -- ViewBox: `0 0 24 24` -- Stroke width: `1.5` -- Rounded line caps and joins - -**DO NOT:** -- Generate new icons with AI/LLM -- Use emoji or unicode symbols as icons -- Use external icon libraries -- Create inline SVG content +**Version is 6.1.0 - NEVER CHANGE without explicit approval** --- -## Project Overview +## Database Standards -botserver is the core backend for General Bots - an open-source conversational AI platform built in Rust. It provides: - -- **Bootstrap System**: Auto-installs PostgreSQL, MinIO, Redis, LLM servers -- **Package Manager**: Manages bot deployments and service lifecycle -- **BASIC Interpreter**: Executes conversation scripts via Rhai -- **Multi-Channel Support**: Web, WhatsApp, Teams, Email -- **Knowledge Base**: Document ingestion with vector search - -### Workspace Structure +**TABLES AND INDEXES ONLY:** ``` -botserver/ # Main server (this project) -botlib/ # Shared library - types, utilities, HTTP client -botui/ # Web/Desktop UI (Axum + Tauri) -botapp/ # Desktop app wrapper (Tauri) -botbook/ # Documentation (mdBook) -botmodels/ # Data models visualization -botplugin/ # Browser extension +✅ CREATE TABLE IF NOT EXISTS +✅ CREATE INDEX IF NOT EXISTS +✅ Inline constraints + +❌ CREATE VIEW +❌ CREATE TRIGGER +❌ CREATE FUNCTION +❌ Stored Procedures ``` ---- - -## Database Migrations - -### Creating New Migrations - -```bash -# 1. Version is always 6.1.0 -# 2. List existing migrations -ls -la migrations/ - -# 3. Create new migration folder -mkdir migrations/6.1.0_my_feature - -# 4. Create up.sql and down.sql (TABLES AND INDEXES ONLY) -``` - -### Migration Structure - -``` -migrations/ -├── 6.0.0_initial_schema/ -├── 6.0.1_bot_memories/ -├── ... -├── 6.1.0_enterprise_features/ -│ ├── up.sql -│ └── down.sql -└── 6.1.0_next_feature/ # YOUR NEW MIGRATION - ├── up.sql - └── down.sql -``` - -### Migration Best Practices - -- Use `IF NOT EXISTS` for all CREATE TABLE statements -- Use `IF EXISTS` for all DROP statements in down.sql -- Always create indexes for foreign keys -- **NO triggers** - handle updated_at in application code -- **NO views** - use queries in application code -- **NO functions** - use application logic -- Use TEXT with `_json` suffix for JSON data (not JSONB) - ---- - -## LLM Workflow Strategy - -### Two Types of LLM Work - -1. **Execution Mode (Fazer)** - - Pre-annotate phrases and send for execution - - Focus on automation freedom - - Less concerned with code details - - Primary concern: Is the LLM destroying something? - - Trust but verify output doesn't break existing functionality - -2. **Review Mode (Conferir)** - - Read generated code with full attention - - Line-by-line verification - - Check for correctness, security, performance - - Validate against requirements - -### Development Process - -1. **One requirement at a time** with sequential commits -2. **Start with docs** - explain user behavior before coding -3. **Design first** - spend time on architecture -4. **On unresolved error** - stop and consult with web search enabled - -### LLM Fallback Strategy (After 3 attempts / 10 minutes) - -1. DeepSeek-V3-0324 (good architect, reliable) -2. gpt-5-chat (slower but thorough) -3. gpt-oss-120b (final validation) -4. Claude Web (for complex debugging, unit tests, UI) +**JSON Columns:** Use TEXT with `_json` suffix, not JSONB --- ## Code Generation Rules -### CRITICAL REQUIREMENTS - ``` - KISS, NO TALK, SECURED ENTERPRISE GRADE THREAD SAFE CODE ONLY -- Use rustc 1.90.0 (1159e78c4 2025-09-14) -- No placeholders, never comment/uncomment code, no explanations +- Use rustc 1.90.0+ +- No placeholders, no explanations, no comments - All code must be complete, professional, production-ready - REMOVE ALL COMMENTS FROM GENERATED CODE - Always include full updated code files - never partial - Only return files that have actual changes -- DO NOT WRITE ERROR HANDLING CODE - LET IT CRASH -- Return 0 warnings - review unused imports! -- NO DEAD CODE - implement real functionality, never use _ for unused +- Return 0 warnings - FIX ALL CLIPPY WARNINGS +- NO DEAD CODE - implement real functionality ``` -### Documentation Rules +--- + +## Documentation Rules ``` -- Rust code examples ONLY in docs/reference/architecture.md (gbapp chapter) +- Rust code examples ONLY in docs/reference/architecture.md - All other docs: BASIC, bash, JSON, SQL, YAML only -- Scan for ALL_CAPS.md files created at wrong places - delete or integrate to docs/ - Keep only README.md and PROMPT.md at project root level ``` -### Frontend Rules +--- + +## Frontend Rules ``` - Use HTMX to minimize JavaScript - delegate logic to Rust server @@ -290,237 +306,47 @@ migrations/ - Endpoints return HTML fragments, not JSON (for HTMX) ``` -### Rust Patterns +--- + +## Rust Patterns ```rust -// Use rand::rng() instead of rand::thread_rng() +// Random number generation let mut rng = rand::rng(); -// Use diesel for database (NOT sqlx) +// Database - ONLY diesel, never sqlx use diesel::prelude::*; -// All config from AppConfig - no hardcoded values +// Config from AppConfig - no hardcoded values let url = config.drive.endpoint.clone(); -// Logging (all-in-one-line, unique messages) -info!("Processing request id={} user={}", req_id, user_id); +// Logging - all-in-one-line, unique messages, inline vars +info!("Processing request id={id} user={user_id}"); ``` -### Dependency Management +--- + +## Dependencies ``` - Use diesel - remove any sqlx references -- After adding to Cargo.toml: cargo audit must show 0 warnings +- After adding to Cargo.toml: cargo audit must show 0 vulnerabilities - If audit fails, find alternative library -- Minimize redundancy - check existing libs before adding new ones -- Review src/ to identify reusable patterns and libraries -``` - -### botserver Specifics - -``` -- Sessions MUST be retrieved by id when session_id is present -- Never suggest installing software - bootstrap/package_manager handles it -- Configuration stored in .gbot/config and database bot_configuration table -- Pay attention to shared::utils and shared::models for reuse +- Minimize redundancy - check existing libs before adding ``` --- -## Documentation Validation - -### Chapter Validation Process - -For each documentation chapter: - -1. Read the chapter instructions step by step -2. Check if source code accomplishes each instruction -3. Verify paths exist and are correct -4. Ensure 100% alignment between docs and implementation -5. Fix either docs or code to match - -### Documentation Structure +## Key Files ``` -docs/ -├── api/ # API documentation (no Rust code) -├── guides/ # How-to guides (no Rust code) -└── reference/ - ├── architecture.md # ONLY place for Rust code examples - ├── basic-language.md # BASIC only - └── configuration.md # Config examples only -``` - ---- - -## Adding New Features - -### Adding a Rhai Keyword - -```rust -pub fn my_keyword(state: &AppState, engine: &mut Engine) { - let db = state.db_custom.clone(); - - engine.register_custom_syntax( - ["MY", "KEYWORD", "$expr$"], - true, - { - let db = db.clone(); - move |context, inputs| { - let value = context.eval_expression_tree(&inputs[0])?; - let binding = db.as_ref().unwrap(); - let fut = execute_my_keyword(binding, value); - - let result = tokio::task::block_in_place(|| - tokio::runtime::Handle::current().block_on(fut)) - .map_err(|e| format!("DB error: {}", e))?; - - Ok(Dynamic::from(result)) - } - } - ).unwrap(); -} - -pub async fn execute_my_keyword( - pool: &PgPool, - value: String, -) -> Result> { - info!("Executing my_keyword value={}", value); - - use diesel::prelude::*; - let result = diesel::insert_into(my_table::table) - .values(&NewRecord { value }) - .execute(pool)?; - - Ok(json!({ "rows_affected": result })) -} -``` - -### Adding a Data Model - -```rust -use chrono::{DateTime, Utc}; -use diesel::prelude::*; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Debug, Queryable, Selectable, Insertable, Serialize, Deserialize)] -#[diesel(table_name = crate::schema::users)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct User { - pub id: Uuid, - pub status: i16, - pub email: String, - pub age: Option, - pub metadata: Vec, - pub created_at: DateTime, -} -``` - -### Adding a Service/Endpoint (HTMX Pattern) - -```rust -use axum::{routing::get, Router, extract::State, response::Html}; -use askama::Template; - -#[derive(Template)] -#[template(path = "partials/items.html")] -struct ItemsTemplate { - items: Vec, -} - -pub fn configure() -> Router { - Router::new() - .route("/api/items", get(list_handler)) -} - -async fn list_handler( - State(state): State>, -) -> Html { - let conn = state.conn.get().unwrap(); - let items = items::table.load::(&conn).unwrap(); - let template = ItemsTemplate { items }; - Html(template.render().unwrap()) -} -``` - ---- - -## Final Steps Before Commit - -```bash -# Check for warnings -cargo check 2>&1 | grep warning - -# Audit dependencies (must be 0 warnings) -cargo audit - -# Build release -cargo build --release - -# Run tests -cargo test - -# Verify no dead code with _ prefixes -grep -r "let _" src/ --include="*.rs" - -# Verify version is 6.1.0 -grep "^version" Cargo.toml | grep "6.1.0" - -# Verify no views/triggers/functions in migrations -grep -r "CREATE VIEW\|CREATE TRIGGER\|CREATE FUNCTION" migrations/ -``` - -### Pre-Commit Checklist - -1. Version is 6.1.0 in all workspace Cargo.toml files -2. No views, triggers, or functions in migrations -3. All JSON columns use TEXT with `_json` suffix - ---- - -## Output Format - -### Shell Script Format - -```sh -#!/bin/bash - -cat > src/module/file.rs << 'EOF' -use std::io; - -pub fn my_function() -> Result<(), io::Error> { - Ok(()) -} -EOF -``` - -### Rules - -- Only return MODIFIED files -- Never return unchanged files -- Use `cat > path << 'EOF'` format -- Include complete file content -- No partial snippets - ---- - -## Key Files Reference - -``` -src/main.rs # Entry point, bootstrap, Axum server -src/lib.rs # Module exports, feature gates -src/core/ - bootstrap/mod.rs # Auto-install services - session/mod.rs # Session management - bot/mod.rs # Bot orchestration - config/mod.rs # Configuration management - package_manager/ # Service lifecycle -src/basic/ # BASIC/Rhai interpreter -src/shared/ - state.rs # AppState definition - utils.rs # Utility functions - models.rs # Database models +src/main.rs # Entry point +src/lib.rs # Module exports +src/basic/ # BASIC language keywords +src/core/ # Core functionality +src/shared/state.rs # AppState definition +src/shared/utils.rs # Utility functions +src/shared/models.rs # Database models ``` --- @@ -530,7 +356,7 @@ src/shared/ | Library | Version | Purpose | |---------|---------|---------| | axum | 0.7.5 | Web framework | -| diesel | 2.1 | PostgreSQL ORM (NOT sqlx) | +| diesel | 2.1 | PostgreSQL ORM | | tokio | 1.41 | Async runtime | | rhai | git | BASIC scripting | | reqwest | 0.12 | HTTP client | @@ -541,138 +367,58 @@ src/shared/ ## Remember -- **Two LLM modes**: Execution (fazer) vs Review (conferir) -- **Rust code**: Only in architecture.md documentation -- **HTMX**: Minimize JS, delegate to server -- **Local assets**: No CDN, all vendor files local -- **Dead code**: Never use _ prefix, implement real code -- **cargo audit**: Must pass with 0 warnings +- **ZERO WARNINGS** - Every clippy warning must be fixed +- **NO ALLOW IN CODE** - Never use #[allow()] in source files +- **CARGO.TOML EXCEPTIONS OK** - Disable lints with false positives in Cargo.toml with comment +- **NO DEAD CODE** - Delete unused code, never prefix with _ +- **NO UNWRAP/EXPECT** - Use ? operator or proper error handling +- **NO APPROXIMATE CONSTANTS** - Use std::f64::consts +- **INLINE FORMAT ARGS** - format!("{name}") not format!("{}", name) +- **USE SELF** - In impl blocks, use Self not the type name +- **DERIVE EQ** - Always derive Eq with PartialEq +- **DISPLAY NOT TOSTRING** - Implement Display, not ToString +- **USE DIAGNOSTICS** - Use IDE diagnostics tool, never call cargo clippy directly +- **PASS BY REF** - Don't clone unnecessarily +- **CONST FN** - Make functions const when possible +- **MUST USE** - Add #[must_use] to pure functions - **diesel**: No sqlx references - **Sessions**: Always retrieve by ID when present - **Config**: Never hardcode values, use AppConfig - **Bootstrap**: Never suggest manual installation -- **Warnings**: Target zero warnings before commit -- **Version**: Always 6.1.0 - do not change without approval -- **Migrations**: TABLES AND INDEXES ONLY - no views, triggers, functions -- **Stalwart**: Use Stalwart IMAP/JMAP API for email features (sieve, filters, etc.) -- **JSON**: Use TEXT columns with `_json` suffix, not JSONB +- **Version**: Always 6.1.0 - do not change +- **Migrations**: TABLES AND INDEXES ONLY +- **JSON**: Use TEXT columns with `_json` suffix +- **Session Continuation**: When running out of context, create detailed summary: (1) what was done, (2) what remains, (3) specific files and line numbers, (4) exact next steps. --- ## Monitor Keywords (ON EMAIL, ON CHANGE) -These keywords register event-driven monitors similar to `SET SCHEDULER`, but triggered by external events. - -### ON EMAIL - Email Monitoring - -Triggers a script when an email arrives at the specified address. +### ON EMAIL ```basic -' Basic usage - trigger on any email to address ON EMAIL "support@company.com" email = GET LAST "email_received_events" - TALK "New email from " + email.from_address + ": " + email.subject -END ON - -' With FROM filter - only trigger for specific sender -ON EMAIL "orders@company.com" FROM "supplier@vendor.com" - ' Process supplier orders -END ON - -' With SUBJECT filter - only trigger for matching subjects -ON EMAIL "alerts@company.com" SUBJECT "URGENT" - ' Handle urgent alerts + TALK "New email from " + email.from_address END ON ``` -**Database Tables:** -- `email_monitors` - Configuration for email monitoring -- `email_received_events` - Log of received emails to process - -**TriggerKind:** `EmailReceived = 5` - -### ON CHANGE - Folder Monitoring - -Triggers a script when files change in cloud storage folders (GDrive, OneDrive, Dropbox) or local filesystem. - -**Uses same `account://` syntax as COPY, MOVE, and other file operations.** +### ON CHANGE ```basic -' Using account:// syntax (recommended) - auto-detects provider from email -ON CHANGE "account://user@gmail.com/Documents/invoices" - file = GET LAST "folder_change_events" - TALK "File changed: " + file.file_name + " (" + file.event_type + ")" -END ON - -' OneDrive via account:// -ON CHANGE "account://user@outlook.com/Business/contracts" - ' Process OneDrive changes -END ON - -' Direct provider syntax (without account) -ON CHANGE "gdrive:///shared/reports" - ' Process Google Drive changes (requires USE ACCOUNT first) -END ON - -ON CHANGE "onedrive:///documents" - ' Process OneDrive changes -END ON - -ON CHANGE "dropbox:///team/assets" - ' Process Dropbox changes -END ON - -' Local filesystem monitoring -ON CHANGE "/var/uploads/incoming" - ' Process local filesystem changes -END ON - -' With specific event types filter -ON CHANGE "account://user@gmail.com/uploads" EVENTS "create, modify" - ' Only trigger on create and modify, ignore delete -END ON - -' Watch for deletions only -ON CHANGE "gdrive:///archive" EVENTS "delete" - ' Log when files are removed from archive +ON CHANGE "gdrive://myaccount/folder" + files = GET LAST "folder_change_events" + FOR EACH file IN files + TALK "File changed: " + file.name + NEXT END ON ``` -**Path Syntax:** -- `account://email@domain.com/path` - Uses connected account (auto-detects provider) -- `gdrive:///path` - Google Drive direct -- `onedrive:///path` - OneDrive direct -- `dropbox:///path` - Dropbox direct -- `/local/path` - Local filesystem - -**Provider Auto-Detection (from email):** -- `@gmail.com`, `@google.com` → Google Drive -- `@outlook.com`, `@hotmail.com`, `@live.com` → OneDrive -- Other emails → Default to Google Drive - -**Event Types:** -- `create` - New file created -- `modify` - File content changed -- `delete` - File deleted -- `rename` - File renamed -- `move` - File moved to different folder - -**Database Tables:** -- `folder_monitors` - Configuration for folder monitoring -- `folder_change_events` - Log of detected changes to process - -**TriggerKind:** `FolderChange = 6` - -### TriggerKind Enum Values - -```rust -pub enum TriggerKind { - Scheduled = 0, // SET SCHEDULER - TableUpdate = 1, // ON UPDATE OF "table" - TableInsert = 2, // ON INSERT OF "table" - TableDelete = 3, // ON DELETE OF "table" - Webhook = 4, // WEBHOOK - EmailReceived = 5, // ON EMAIL - FolderChange = 6, // ON CHANGE -} -``` \ No newline at end of file +**TriggerKind Enum:** +- Scheduled = 0 +- TableUpdate = 1 +- TableInsert = 2 +- TableDelete = 3 +- Webhook = 4 +- EmailReceived = 5 +- FolderChange = 6 \ No newline at end of file diff --git a/migrations/6.1.0_enterprise_suite/down.sql b/migrations/6.1.0_enterprise_suite/down.sql index 80bf153d7..10009cc53 100644 --- a/migrations/6.1.0_enterprise_suite/down.sql +++ b/migrations/6.1.0_enterprise_suite/down.sql @@ -186,10 +186,11 @@ DROP TABLE IF EXISTS calendar_shares; DROP TABLE IF EXISTS calendar_resource_bookings; DROP TABLE IF EXISTS calendar_resources; --- Drop task tables +-- Drop task tables (order matters due to foreign keys) DROP TABLE IF EXISTS task_recurrence; DROP TABLE IF EXISTS task_time_entries; DROP TABLE IF EXISTS task_dependencies; +DROP TABLE IF EXISTS tasks; -- Drop collaboration tables DROP TABLE IF EXISTS document_presence; diff --git a/migrations/6.1.0_enterprise_suite/up.sql b/migrations/6.1.0_enterprise_suite/up.sql index 10ce0833d..d8c5c7890 100644 --- a/migrations/6.1.0_enterprise_suite/up.sql +++ b/migrations/6.1.0_enterprise_suite/up.sql @@ -487,11 +487,41 @@ CREATE INDEX IF NOT EXISTS idx_document_presence_doc ON document_presence(docume -- TASK ENTERPRISE FEATURES -- ============================================================================ +-- Core tasks table +CREATE TABLE IF NOT EXISTS tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'todo', + priority TEXT NOT NULL DEFAULT 'medium', + assignee_id UUID REFERENCES users(id) ON DELETE SET NULL, + reporter_id UUID REFERENCES users(id) ON DELETE SET NULL, + project_id UUID, + due_date TIMESTAMPTZ, + tags TEXT[] DEFAULT '{}', + dependencies UUID[] DEFAULT '{}', + estimated_hours FLOAT8, + actual_hours FLOAT8, + progress INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + completed_at TIMESTAMPTZ, + CONSTRAINT check_task_status CHECK (status IN ('todo', 'in_progress', 'review', 'blocked', 'on_hold', 'done', 'completed', 'cancelled')), + CONSTRAINT check_task_priority CHECK (priority IN ('low', 'medium', 'high', 'urgent')) +); + +CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee_id); +CREATE INDEX IF NOT EXISTS idx_tasks_reporter ON tasks(reporter_id); +CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id); +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); +CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date); +CREATE INDEX IF NOT EXISTS idx_tasks_created ON tasks(created_at); + -- Task dependencies CREATE TABLE IF NOT EXISTS task_dependencies ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - task_id UUID NOT NULL, - depends_on_task_id UUID NOT NULL, + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + depends_on_task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, dependency_type VARCHAR(20) DEFAULT 'finish_to_start', lag_days INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW(), @@ -505,7 +535,7 @@ CREATE INDEX IF NOT EXISTS idx_task_dependencies_depends ON task_dependencies(de -- Task time tracking CREATE TABLE IF NOT EXISTS task_time_entries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - task_id UUID NOT NULL, + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, description TEXT, started_at TIMESTAMPTZ NOT NULL, @@ -521,7 +551,7 @@ CREATE INDEX IF NOT EXISTS idx_task_time_user ON task_time_entries(user_id, star -- Task recurring rules CREATE TABLE IF NOT EXISTS task_recurrence ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - task_template_id UUID NOT NULL, + task_template_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, recurrence_pattern VARCHAR(20) NOT NULL, interval_value INTEGER DEFAULT 1, days_of_week_json TEXT, diff --git a/src/attendance/mod.rs b/src/attendance/mod.rs index 6eae9a636..465376d54 100644 --- a/src/attendance/mod.rs +++ b/src/attendance/mod.rs @@ -65,6 +65,7 @@ use axum::{ routing::{get, post}, Json, Router, }; +use botlib::MessageType; use chrono::Utc; use diesel::prelude::*; use futures::{SinkExt, StreamExt}; @@ -228,11 +229,13 @@ pub async fn attendant_respond( user_id: recipient.to_string(), channel: "whatsapp".to_string(), content: request.message.clone(), - message_type: crate::shared::models::message_types::MessageType::BOT_RESPONSE, + message_type: botlib::MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, suggestions: vec![], context_name: None, + context_length: 0, + context_max_length: 0, }; match adapter.send_message(response).await { @@ -274,11 +277,13 @@ pub async fn attendant_respond( user_id: session.user_id.to_string(), channel: channel.to_string(), content: request.message.clone(), - message_type: crate::shared::models::message_types::MessageType::BOT_RESPONSE, + message_type: botlib::MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, suggestions: vec![], context_name: None, + context_length: 0, + context_max_length: 0, }; tx.send(response).await.is_ok() } else { @@ -333,8 +338,11 @@ async fn save_message_to_history( .values(( message_history::id.eq(Uuid::new_v4()), message_history::session_id.eq(session_id), - message_history::role.eq(sender_clone), - message_history::content.eq(content_clone), + message_history::user_id.eq(session_id), + message_history::role.eq(if sender_clone == "user" { 1 } else { 2 }), + message_history::content_encrypted.eq(content_clone), + message_history::message_type.eq(1), + message_history::message_index.eq(0i64), message_history::created_at.eq(diesel::dsl::now), )) .execute(&mut db_conn) @@ -626,12 +634,13 @@ async fn handle_attendant_message( user_id: phone.to_string(), channel: "whatsapp".to_string(), content: content.to_string(), - message_type: - crate::shared::models::message_types::MessageType::BOT_RESPONSE, + message_type: botlib::MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, suggestions: vec![], context_name: None, + context_length: 0, + context_max_length: 0, }; let _ = adapter.send_message(response).await; } diff --git a/src/basic/compiler/goto_transform.rs b/src/basic/compiler/goto_transform.rs index 8ffe92e82..bbe1e6052 100644 --- a/src/basic/compiler/goto_transform.rs +++ b/src/basic/compiler/goto_transform.rs @@ -29,7 +29,7 @@ use log::{trace, warn}; use std::collections::HashSet; - + /// Represents a labeled block of code #[derive(Debug, Clone)] struct LabeledBlock { diff --git a/src/basic/keywords/add_bot.rs b/src/basic/keywords/add_bot.rs index ba027e575..6d3a95adf 100644 --- a/src/basic/keywords/add_bot.rs +++ b/src/basic/keywords/add_bot.rs @@ -16,11 +16,11 @@ use diesel::prelude::*; use log::{info, trace}; use rhai::{Dynamic, Engine}; use serde::{Deserialize, Serialize}; +use std::fmt; use std::sync::Arc; use uuid::Uuid; -/// Bot trigger types -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum TriggerType { Keyword, Tool, @@ -32,29 +32,28 @@ pub enum TriggerType { impl From for TriggerType { fn from(s: String) -> Self { match s.to_lowercase().as_str() { - "keyword" => TriggerType::Keyword, - "tool" => TriggerType::Tool, - "schedule" => TriggerType::Schedule, - "event" => TriggerType::Event, - "always" => TriggerType::Always, - _ => TriggerType::Keyword, + "keyword" => Self::Keyword, + "tool" => Self::Tool, + "schedule" => Self::Schedule, + "event" => Self::Event, + "always" => Self::Always, + _ => Self::Keyword, } } } -impl ToString for TriggerType { - fn to_string(&self) -> String { +impl fmt::Display for TriggerType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - TriggerType::Keyword => "keyword".to_string(), - TriggerType::Tool => "tool".to_string(), - TriggerType::Schedule => "schedule".to_string(), - TriggerType::Event => "event".to_string(), - TriggerType::Always => "always".to_string(), + Self::Keyword => write!(f, "keyword"), + Self::Tool => write!(f, "tool"), + Self::Schedule => write!(f, "schedule"), + Self::Event => write!(f, "event"), + Self::Always => write!(f, "always"), } } } -/// Bot trigger configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BotTrigger { pub trigger_type: TriggerType, @@ -65,6 +64,7 @@ pub struct BotTrigger { } impl BotTrigger { + #[must_use] pub fn from_keywords(keywords: Vec) -> Self { Self { trigger_type: TriggerType::Keyword, @@ -75,6 +75,7 @@ impl BotTrigger { } } + #[must_use] pub fn from_tools(tools: Vec) -> Self { Self { trigger_type: TriggerType::Tool, @@ -85,6 +86,7 @@ impl BotTrigger { } } + #[must_use] pub fn from_schedule(cron: String) -> Self { Self { trigger_type: TriggerType::Schedule, @@ -95,6 +97,7 @@ impl BotTrigger { } } + #[must_use] pub fn always() -> Self { Self { trigger_type: TriggerType::Always, @@ -106,7 +109,6 @@ impl BotTrigger { } } -/// Session bot association #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionBot { pub id: Uuid, @@ -118,32 +120,30 @@ pub struct SessionBot { pub is_active: bool, } -/// Register all bot-related keywords -pub fn register_bot_keywords(state: Arc, user: UserSession, engine: &mut Engine) { - if let Err(e) = add_bot_with_trigger_keyword(state.clone(), user.clone(), engine) { - log::error!("Failed to register ADD BOT WITH TRIGGER keyword: {}", e); +pub fn register_bot_keywords(state: &Arc, user: &UserSession, engine: &mut Engine) { + if let Err(e) = add_bot_with_trigger_keyword(Arc::clone(state), user.clone(), engine) { + log::error!("Failed to register ADD BOT WITH TRIGGER keyword: {e}"); } - if let Err(e) = add_bot_with_tools_keyword(state.clone(), user.clone(), engine) { - log::error!("Failed to register ADD BOT WITH TOOLS keyword: {}", e); + if let Err(e) = add_bot_with_tools_keyword(Arc::clone(state), user.clone(), engine) { + log::error!("Failed to register ADD BOT WITH TOOLS keyword: {e}"); } - if let Err(e) = add_bot_with_schedule_keyword(state.clone(), user.clone(), engine) { - log::error!("Failed to register ADD BOT WITH SCHEDULE keyword: {}", e); + if let Err(e) = add_bot_with_schedule_keyword(Arc::clone(state), user.clone(), engine) { + log::error!("Failed to register ADD BOT WITH SCHEDULE keyword: {e}"); } - if let Err(e) = remove_bot_keyword(state.clone(), user.clone(), engine) { - log::error!("Failed to register REMOVE BOT keyword: {}", e); + if let Err(e) = remove_bot_keyword(Arc::clone(state), user.clone(), engine) { + log::error!("Failed to register REMOVE BOT keyword: {e}"); } - if let Err(e) = list_bots_keyword(state.clone(), user.clone(), engine) { - log::error!("Failed to register LIST BOTS keyword: {}", e); + if let Err(e) = list_bots_keyword(Arc::clone(state), user.clone(), engine) { + log::error!("Failed to register LIST BOTS keyword: {e}"); } - if let Err(e) = set_bot_priority_keyword(state.clone(), user.clone(), engine) { - log::error!("Failed to register SET BOT PRIORITY keyword: {}", e); + if let Err(e) = set_bot_priority_keyword(Arc::clone(state), user.clone(), engine) { + log::error!("Failed to register SET BOT PRIORITY keyword: {e}"); } - if let Err(e) = delegate_to_keyword(state.clone(), user.clone(), engine) { - log::error!("Failed to register DELEGATE TO keyword: {}", e); + if let Err(e) = delegate_to_keyword(Arc::clone(state), user.clone(), engine) { + log::error!("Failed to register DELEGATE TO keyword: {e}"); } } -/// ADD BOT "name" WITH TRIGGER "keywords" fn add_bot_with_trigger_keyword( state: Arc, user: UserSession, @@ -168,9 +168,7 @@ fn add_bot_with_trigger_keyword( .to_string(); trace!( - "ADD BOT '{}' WITH TRIGGER '{}' for session: {}", - bot_name, - trigger_str, + "ADD BOT '{bot_name}' WITH TRIGGER '{trigger_str}' for session: {}", user_clone.id ); @@ -184,21 +182,20 @@ fn add_bot_with_trigger_keyword( let state_for_task = Arc::clone(&state_clone); let session_id = user_clone.id; let bot_id = user_clone.bot_id; - let bot_name_clone = bot_name.clone(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime"); + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(Err(format!("Failed to create runtime: {e}"))); + return; + } + }; let result = rt.block_on(async { - add_bot_to_session( - &state_for_task, - session_id, - bot_id, - &bot_name_clone, - trigger, - ) - .await + add_bot_to_session(&state_for_task, session_id, bot_id, &bot_name, trigger) + .await }); let _ = tx.send(result); }); @@ -219,7 +216,6 @@ fn add_bot_with_trigger_keyword( Ok(()) } -/// ADD BOT "name" WITH TOOLS "tool1, tool2" fn add_bot_with_tools_keyword( state: Arc, user: UserSession, @@ -244,9 +240,7 @@ fn add_bot_with_tools_keyword( .to_string(); trace!( - "ADD BOT '{}' WITH TOOLS '{}' for session: {}", - bot_name, - tools_str, + "ADD BOT '{bot_name}' WITH TOOLS '{tools_str}' for session: {}", user_clone.id ); @@ -260,21 +254,20 @@ fn add_bot_with_tools_keyword( let state_for_task = Arc::clone(&state_clone); let session_id = user_clone.id; let bot_id = user_clone.bot_id; - let bot_name_clone = bot_name.clone(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime"); + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(Err(format!("Failed to create runtime: {e}"))); + return; + } + }; let result = rt.block_on(async { - add_bot_to_session( - &state_for_task, - session_id, - bot_id, - &bot_name_clone, - trigger, - ) - .await + add_bot_to_session(&state_for_task, session_id, bot_id, &bot_name, trigger) + .await }); let _ = tx.send(result); }); @@ -295,7 +288,6 @@ fn add_bot_with_tools_keyword( Ok(()) } -/// ADD BOT "name" WITH SCHEDULE "cron" fn add_bot_with_schedule_keyword( state: Arc, user: UserSession, @@ -320,9 +312,7 @@ fn add_bot_with_schedule_keyword( .to_string(); trace!( - "ADD BOT '{}' WITH SCHEDULE '{}' for session: {}", - bot_name, - schedule, + "ADD BOT '{bot_name}' WITH SCHEDULE '{schedule}' for session: {}", user_clone.id ); @@ -330,21 +320,20 @@ fn add_bot_with_schedule_keyword( let state_for_task = Arc::clone(&state_clone); let session_id = user_clone.id; let bot_id = user_clone.bot_id; - let bot_name_clone = bot_name.clone(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime"); + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(Err(format!("Failed to create runtime: {e}"))); + return; + } + }; let result = rt.block_on(async { - add_bot_to_session( - &state_for_task, - session_id, - bot_id, - &bot_name_clone, - trigger, - ) - .await + add_bot_to_session(&state_for_task, session_id, bot_id, &bot_name, trigger) + .await }); let _ = tx.send(result); }); @@ -365,7 +354,6 @@ fn add_bot_with_schedule_keyword( Ok(()) } -/// REMOVE BOT "name" fn remove_bot_keyword( state: Arc, user: UserSession, @@ -384,7 +372,7 @@ fn remove_bot_keyword( .trim_matches('"') .to_string(); - trace!("REMOVE BOT '{}' from session: {}", bot_name, user_clone.id); + trace!("REMOVE BOT '{bot_name}' from session: {}", user_clone.id); let state_for_task = Arc::clone(&state_clone); let session_id = user_clone.id; @@ -392,7 +380,13 @@ fn remove_bot_keyword( let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime"); + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(Err(format!("Failed to create runtime: {e}"))); + return; + } + }; let result = rt.block_on(async { remove_bot_from_session(&state_for_task, session_id, &bot_name).await }); @@ -415,7 +409,6 @@ fn remove_bot_keyword( Ok(()) } -/// LIST BOTS fn list_bots_keyword( state: Arc, user: UserSession, @@ -433,14 +426,19 @@ fn list_bots_keyword( let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime"); + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(Err(format!("Failed to create runtime: {e}"))); + return; + } + }; let result = rt.block_on(async { get_session_bots(&state_for_task, session_id).await }); let _ = tx.send(result); }); match rx.recv_timeout(std::time::Duration::from_secs(30)) { Ok(Ok(bots)) => { - // Convert to Dynamic array let bot_list: Vec = bots .into_iter() .map(|b| { @@ -470,7 +468,6 @@ fn list_bots_keyword( Ok(()) } -/// SET BOT PRIORITY "name", priority fn set_bot_priority_keyword( state: Arc, user: UserSession, @@ -488,15 +485,14 @@ fn set_bot_priority_keyword( .to_string() .trim_matches('"') .to_string(); + #[allow(clippy::cast_possible_truncation)] let priority = context .eval_expression_tree(&inputs[1])? .as_int() .unwrap_or(0) as i32; trace!( - "SET BOT PRIORITY '{}' to {} for session: {}", - bot_name, - priority, + "SET BOT PRIORITY '{bot_name}' to {priority} for session: {}", user_clone.id ); @@ -506,7 +502,13 @@ fn set_bot_priority_keyword( let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime"); + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(Err(format!("Failed to create runtime: {e}"))); + return; + } + }; let result = rt.block_on(async { set_bot_priority(&state_for_task, session_id, &bot_name, priority).await }); @@ -529,7 +531,6 @@ fn set_bot_priority_keyword( Ok(()) } -/// DELEGATE TO "bot" WITH CONTEXT fn delegate_to_keyword( state: Arc, user: UserSession, @@ -548,7 +549,7 @@ fn delegate_to_keyword( .trim_matches('"') .to_string(); - trace!("DELEGATE TO '{}' for session: {}", bot_name, user_clone.id); + trace!("DELEGATE TO '{bot_name}' for session: {}", user_clone.id); let state_for_task = Arc::clone(&state_clone); let session_id = user_clone.id; @@ -556,7 +557,13 @@ fn delegate_to_keyword( let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime"); + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(Err(format!("Failed to create runtime: {e}"))); + return; + } + }; let result = rt.block_on(async { delegate_to_bot(&state_for_task, session_id, &bot_name).await }); @@ -579,9 +586,6 @@ fn delegate_to_keyword( Ok(()) } -// Database Operations - -/// Add a bot to the session async fn add_bot_to_session( state: &AppState, session_id: Uuid, @@ -589,9 +593,8 @@ async fn add_bot_to_session( bot_name: &str, trigger: BotTrigger, ) -> Result { - let mut conn = state.conn.get().map_err(|e| format!("DB error: {}", e))?; + let mut conn = state.conn.get().map_err(|e| format!("DB error: {e}"))?; - // Check if bot exists let bot_exists: bool = diesel::sql_query( "SELECT EXISTS(SELECT 1 FROM bots WHERE name = $1 AND is_active = true) as exists", ) @@ -600,15 +603,13 @@ async fn add_bot_to_session( .map(|r| r.exists) .unwrap_or(false); - // If bot doesn't exist, try to find it in templates or create a placeholder let bot_id: String = if bot_exists { diesel::sql_query("SELECT id FROM bots WHERE name = $1 AND is_active = true") .bind::(bot_name) .get_result::(&mut *conn) .map(|r| r.id) - .map_err(|e| format!("Failed to get bot ID: {}", e))? + .map_err(|e| format!("Failed to get bot ID: {e}"))? } else { - // Create a new bot entry let new_bot_id = Uuid::new_v4(); diesel::sql_query( "INSERT INTO bots (id, name, description, is_active, created_at) @@ -618,18 +619,16 @@ async fn add_bot_to_session( ) .bind::(new_bot_id.to_string()) .bind::(bot_name) - .bind::(format!("Bot agent: {}", bot_name)) + .bind::(format!("Bot agent: {bot_name}")) .execute(&mut *conn) - .map_err(|e| format!("Failed to create bot: {}", e))?; + .map_err(|e| format!("Failed to create bot: {e}"))?; new_bot_id.to_string() }; - // Serialize trigger to JSON - let trigger_json = serde_json::to_string(&trigger) - .map_err(|e| format!("Failed to serialize trigger: {}", e))?; + let trigger_json = + serde_json::to_string(&trigger).map_err(|e| format!("Failed to serialize trigger: {e}"))?; - // Add bot to session let association_id = Uuid::new_v4(); diesel::sql_query( "INSERT INTO session_bots (id, session_id, bot_id, bot_name, trigger_config, priority, is_active, joined_at) @@ -639,27 +638,26 @@ async fn add_bot_to_session( ) .bind::(association_id.to_string()) .bind::(session_id.to_string()) - .bind::(bot_id.to_string()) + .bind::(bot_id) .bind::(bot_name) .bind::(&trigger_json) .execute(&mut *conn) - .map_err(|e| format!("Failed to add bot to session: {}", e))?; + .map_err(|e| format!("Failed to add bot to session: {e}"))?; info!( - "Bot '{}' added to session {} with trigger type: {:?}", - bot_name, session_id, trigger.trigger_type + "Bot '{bot_name}' added to session {session_id} with trigger type: {:?}", + trigger.trigger_type ); - Ok(format!("Bot '{}' added to conversation", bot_name)) + Ok(format!("Bot '{bot_name}' added to conversation")) } -/// Remove a bot from the session async fn remove_bot_from_session( state: &AppState, session_id: Uuid, bot_name: &str, ) -> Result { - let mut conn = state.conn.get().map_err(|e| format!("DB error: {}", e))?; + let mut conn = state.conn.get().map_err(|e| format!("DB error: {e}"))?; let affected = diesel::sql_query( "UPDATE session_bots SET is_active = false WHERE session_id = $1 AND bot_name = $2", @@ -667,19 +665,18 @@ async fn remove_bot_from_session( .bind::(session_id.to_string()) .bind::(bot_name) .execute(&mut *conn) - .map_err(|e| format!("Failed to remove bot: {}", e))?; + .map_err(|e| format!("Failed to remove bot: {e}"))?; if affected > 0 { - info!("Bot '{}' removed from session {}", bot_name, session_id); - Ok(format!("Bot '{}' removed from conversation", bot_name)) + info!("Bot '{bot_name}' removed from session {session_id}"); + Ok(format!("Bot '{bot_name}' removed from conversation")) } else { - Ok(format!("Bot '{}' was not in the conversation", bot_name)) + Ok(format!("Bot '{bot_name}' was not in the conversation")) } } -/// Get all bots in a session async fn get_session_bots(state: &AppState, session_id: Uuid) -> Result, String> { - let mut conn = state.conn.get().map_err(|e| format!("DB error: {}", e))?; + let mut conn = state.conn.get().map_err(|e| format!("DB error: {e}"))?; let results: Vec = diesel::sql_query( "SELECT id, session_id, bot_id, bot_name, trigger_config, priority, is_active @@ -689,13 +686,13 @@ async fn get_session_bots(state: &AppState, session_id: Uuid) -> Result(session_id.to_string()) .load(&mut *conn) - .map_err(|e| format!("Failed to get session bots: {}", e))?; + .map_err(|e| format!("Failed to get session bots: {e}"))?; let bots = results .into_iter() .filter_map(|row| { let trigger: BotTrigger = - serde_json::from_str(&row.trigger_config).unwrap_or(BotTrigger::always()); + serde_json::from_str(&row.trigger_config).unwrap_or_else(|_| BotTrigger::always()); Some(SessionBot { id: Uuid::parse_str(&row.id).ok()?, session_id: Uuid::parse_str(&row.session_id).ok()?, @@ -711,14 +708,13 @@ async fn get_session_bots(state: &AppState, session_id: Uuid) -> Result Result { - let mut conn = state.conn.get().map_err(|e| format!("DB error: {}", e))?; + let mut conn = state.conn.get().map_err(|e| format!("DB error: {e}"))?; diesel::sql_query( "UPDATE session_bots SET priority = $1 WHERE session_id = $2 AND bot_name = $3", @@ -727,19 +723,17 @@ async fn set_bot_priority( .bind::(session_id.to_string()) .bind::(bot_name) .execute(&mut *conn) - .map_err(|e| format!("Failed to set priority: {}", e))?; + .map_err(|e| format!("Failed to set priority: {e}"))?; - Ok(format!("Bot '{}' priority set to {}", bot_name, priority)) + Ok(format!("Bot '{bot_name}' priority set to {priority}")) } -/// Delegate current conversation to another bot async fn delegate_to_bot( state: &AppState, session_id: Uuid, bot_name: &str, ) -> Result { - // Get the bot's configuration - let mut conn = state.conn.get().map_err(|e| format!("DB error: {}", e))?; + let mut conn = state.conn.get().map_err(|e| format!("DB error: {e}"))?; let bot_config: Option = diesel::sql_query( "SELECT id, name, system_prompt, model_config FROM bots WHERE name = $1 AND is_active = true", @@ -748,12 +742,10 @@ async fn delegate_to_bot( .get_result(&mut *conn) .ok(); - let config = match bot_config { - Some(cfg) => cfg, - None => return Err(format!("Bot '{}' not found", bot_name)), + let Some(config) = bot_config else { + return Err(format!("Bot '{bot_name}' not found")); }; - // Log delegation details for debugging trace!( "Delegating to bot: id={}, name={}, has_system_prompt={}, has_model_config={}", config.id, @@ -762,30 +754,27 @@ async fn delegate_to_bot( config.model_config.is_some() ); - // Mark delegation in session with bot ID for proper tracking diesel::sql_query("UPDATE sessions SET delegated_to = $1, delegated_at = NOW() WHERE id = $2") .bind::(&config.id) .bind::(session_id.to_string()) .execute(&mut *conn) - .map_err(|e| format!("Failed to delegate: {}", e))?; + .map_err(|e| format!("Failed to delegate: {e}"))?; - // Build response message with bot info - let response = if let Some(ref prompt) = config.system_prompt { - format!( - "Conversation delegated to '{}' (specialized: {})", - config.name, - prompt.chars().take(50).collect::() - ) - } else { - format!("Conversation delegated to '{}'", config.name) - }; + let response = config.system_prompt.as_ref().map_or_else( + || format!("Conversation delegated to '{}'", config.name), + |prompt| { + format!( + "Conversation delegated to '{}' (specialized: {})", + config.name, + prompt.chars().take(50).collect::() + ) + }, + ); Ok(response) } -// Multi-Agent Message Processing - -/// Check if a message matches any bot triggers +#[must_use] pub fn match_bot_triggers(message: &str, bots: &[SessionBot]) -> Vec { let message_lower = message.to_lowercase(); let mut matching_bots = Vec::new(); @@ -796,27 +785,12 @@ pub fn match_bot_triggers(message: &str, bots: &[SessionBot]) -> Vec } let matches = match bot.trigger.trigger_type { - TriggerType::Keyword => { - if let Some(keywords) = &bot.trigger.keywords { - keywords - .iter() - .any(|kw| message_lower.contains(&kw.to_lowercase())) - } else { - false - } - } - TriggerType::Tool => { - // Tool triggers are checked separately when tools are invoked - false - } - TriggerType::Schedule => { - // Schedule triggers are checked by the scheduler - false - } - TriggerType::Event => { - // Event triggers are checked when events occur - false - } + TriggerType::Keyword => bot.trigger.keywords.as_ref().map_or(false, |keywords| { + keywords + .iter() + .any(|kw| message_lower.contains(&kw.to_lowercase())) + }), + TriggerType::Tool | TriggerType::Schedule | TriggerType::Event => false, TriggerType::Always => true, }; @@ -825,12 +799,11 @@ pub fn match_bot_triggers(message: &str, bots: &[SessionBot]) -> Vec } } - // Sort by priority (higher first) matching_bots.sort_by(|a, b| b.priority.cmp(&a.priority)); matching_bots } -/// Check if a tool invocation matches any bot triggers +#[must_use] pub fn match_tool_triggers(tool_name: &str, bots: &[SessionBot]) -> Vec { let tool_upper = tool_name.to_uppercase(); let mut matching_bots = Vec::new(); @@ -853,8 +826,6 @@ pub fn match_tool_triggers(tool_name: &str, bots: &[SessionBot]) -> Vec, _user: UserSession, engine: &mut Engine) { - // CONTAINS - uppercase version engine.register_fn("CONTAINS", |arr: Array, value: Dynamic| -> bool { array_contains(&arr, &value) }); - // contains - lowercase version engine.register_fn("contains", |arr: Array, value: Dynamic| -> bool { array_contains(&arr, &value) }); - // IN_ARRAY - alternative name (PHP style) engine.register_fn("IN_ARRAY", |value: Dynamic, arr: Array| -> bool { array_contains(&arr, &value) }); @@ -38,7 +21,6 @@ pub fn contains_keyword(_state: &Arc, _user: UserSession, engine: &mut array_contains(&arr, &value) }); - // INCLUDES - JavaScript style engine.register_fn("INCLUDES", |arr: Array, value: Dynamic| -> bool { array_contains(&arr, &value) }); @@ -47,7 +29,6 @@ pub fn contains_keyword(_state: &Arc, _user: UserSession, engine: &mut array_contains(&arr, &value) }); - // HAS - short form engine.register_fn("HAS", |arr: Array, value: Dynamic| -> bool { array_contains(&arr, &value) }); @@ -59,17 +40,14 @@ pub fn contains_keyword(_state: &Arc, _user: UserSession, engine: &mut debug!("Registered CONTAINS keyword"); } -/// Helper function to check if an array contains a value fn array_contains(arr: &Array, value: &Dynamic) -> bool { let search_str = value.to_string(); for item in arr { - // Try exact type match first if items_equal(item, value) { return true; } - // Fall back to string comparison if item.to_string() == search_str { return true; } @@ -78,22 +56,19 @@ fn array_contains(arr: &Array, value: &Dynamic) -> bool { false } -/// Helper function to compare two Dynamic values fn items_equal(a: &Dynamic, b: &Dynamic) -> bool { - // Both integers if a.is_int() && b.is_int() { return a.as_int().unwrap_or(0) == b.as_int().unwrap_or(1); } - // Both floats if a.is_float() && b.is_float() { let af = a.as_float().unwrap_or(0.0); let bf = b.as_float().unwrap_or(1.0); return (af - bf).abs() < f64::EPSILON; } - // Int and float comparison if a.is_int() && b.is_float() { + #[allow(clippy::cast_precision_loss)] let af = a.as_int().unwrap_or(0) as f64; let bf = b.as_float().unwrap_or(1.0); return (af - bf).abs() < f64::EPSILON; @@ -101,16 +76,15 @@ fn items_equal(a: &Dynamic, b: &Dynamic) -> bool { if a.is_float() && b.is_int() { let af = a.as_float().unwrap_or(0.0); + #[allow(clippy::cast_precision_loss)] let bf = b.as_int().unwrap_or(1) as f64; return (af - bf).abs() < f64::EPSILON; } - // Both booleans if a.is_bool() && b.is_bool() { return a.as_bool().unwrap_or(false) == b.as_bool().unwrap_or(true); } - // Both strings if a.is_string() && b.is_string() { return a.clone().into_string().unwrap_or_default() == b.clone().into_string().unwrap_or_default(); @@ -125,10 +99,11 @@ mod tests { #[test] fn test_contains_string() { - let mut arr = Array::new(); - arr.push(Dynamic::from("Alice")); - arr.push(Dynamic::from("Bob")); - arr.push(Dynamic::from("Charlie")); + let arr: Array = vec![ + Dynamic::from("Alice"), + Dynamic::from("Bob"), + Dynamic::from("Charlie"), + ]; assert!(array_contains(&arr, &Dynamic::from("Bob"))); assert!(!array_contains(&arr, &Dynamic::from("David"))); @@ -136,10 +111,11 @@ mod tests { #[test] fn test_contains_integer() { - let mut arr = Array::new(); - arr.push(Dynamic::from(1_i64)); - arr.push(Dynamic::from(2_i64)); - arr.push(Dynamic::from(3_i64)); + let arr: Array = vec![ + Dynamic::from(1_i64), + Dynamic::from(2_i64), + Dynamic::from(3_i64), + ]; assert!(array_contains(&arr, &Dynamic::from(2_i64))); assert!(!array_contains(&arr, &Dynamic::from(5_i64))); @@ -147,10 +123,11 @@ mod tests { #[test] fn test_contains_float() { - let mut arr = Array::new(); - arr.push(Dynamic::from(1.5_f64)); - arr.push(Dynamic::from(2.5_f64)); - arr.push(Dynamic::from(3.5_f64)); + let arr: Array = vec![ + Dynamic::from(1.5_f64), + Dynamic::from(2.5_f64), + Dynamic::from(3.5_f64), + ]; assert!(array_contains(&arr, &Dynamic::from(2.5_f64))); assert!(!array_contains(&arr, &Dynamic::from(4.5_f64))); @@ -158,9 +135,7 @@ mod tests { #[test] fn test_contains_bool() { - let mut arr = Array::new(); - arr.push(Dynamic::from(true)); - arr.push(Dynamic::from(false)); + let arr: Array = vec![Dynamic::from(true), Dynamic::from(false)]; assert!(array_contains(&arr, &Dynamic::from(true))); assert!(array_contains(&arr, &Dynamic::from(false))); diff --git a/src/basic/keywords/arrays/push_pop.rs b/src/basic/keywords/arrays/push_pop.rs index 3a8a6a4b1..3bcb38d4b 100644 --- a/src/basic/keywords/arrays/push_pop.rs +++ b/src/basic/keywords/arrays/push_pop.rs @@ -1,20 +1,10 @@ -//! PUSH and POP array manipulation functions -//! -//! PUSH - Add element(s) to the end of an array -//! POP - Remove and return the last element from an array -//! SHIFT - Remove and return the first element from an array -//! UNSHIFT - Add element(s) to the beginning of an array - use crate::shared::models::UserSession; use crate::shared::state::AppState; use log::debug; use rhai::{Array, Dynamic, Engine}; use std::sync::Arc; -/// PUSH - Add an element to the end of an array -/// Returns the new array with the element added pub fn push_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { - // PUSH single element engine.register_fn("PUSH", |mut arr: Array, value: Dynamic| -> Array { arr.push(value); arr @@ -25,13 +15,11 @@ pub fn push_keyword(_state: &Arc, _user: UserSession, engine: &mut Eng arr }); - // ARRAY_PUSH alias engine.register_fn("ARRAY_PUSH", |mut arr: Array, value: Dynamic| -> Array { arr.push(value); arr }); - // APPEND alias engine.register_fn("APPEND", |mut arr: Array, value: Dynamic| -> Array { arr.push(value); arr @@ -45,10 +33,7 @@ pub fn push_keyword(_state: &Arc, _user: UserSession, engine: &mut Eng debug!("Registered PUSH keyword"); } -/// POP - Remove and return the last element from an array -/// Returns the removed element (or unit if array is empty) pub fn pop_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { - // POP - returns the popped element engine.register_fn("POP", |mut arr: Array| -> Dynamic { arr.pop().unwrap_or(Dynamic::UNIT) }); @@ -57,7 +42,6 @@ pub fn pop_keyword(_state: &Arc, _user: UserSession, engine: &mut Engi arr.pop().unwrap_or(Dynamic::UNIT) }); - // ARRAY_POP alias engine.register_fn("ARRAY_POP", |mut arr: Array| -> Dynamic { arr.pop().unwrap_or(Dynamic::UNIT) }); @@ -65,7 +49,6 @@ pub fn pop_keyword(_state: &Arc, _user: UserSession, engine: &mut Engi debug!("Registered POP keyword"); } -/// SHIFT - Remove and return the first element from an array pub fn shift_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { engine.register_fn("SHIFT", |mut arr: Array| -> Dynamic { if arr.is_empty() { @@ -83,7 +66,6 @@ pub fn shift_keyword(_state: &Arc, _user: UserSession, engine: &mut En } }); - // ARRAY_SHIFT alias engine.register_fn("ARRAY_SHIFT", |mut arr: Array| -> Dynamic { if arr.is_empty() { Dynamic::UNIT @@ -95,7 +77,6 @@ pub fn shift_keyword(_state: &Arc, _user: UserSession, engine: &mut En debug!("Registered SHIFT keyword"); } -/// UNSHIFT - Add element(s) to the beginning of an array pub fn unshift_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { engine.register_fn("UNSHIFT", |mut arr: Array, value: Dynamic| -> Array { arr.insert(0, value); @@ -107,7 +88,6 @@ pub fn unshift_keyword(_state: &Arc, _user: UserSession, engine: &mut arr }); - // PREPEND alias engine.register_fn("PREPEND", |mut arr: Array, value: Dynamic| -> Array { arr.insert(0, value); arr @@ -130,15 +110,15 @@ mod tests { let mut arr: Array = vec![Dynamic::from(1), Dynamic::from(2)]; arr.push(Dynamic::from(3)); assert_eq!(arr.len(), 3); - assert_eq!(arr[2].as_int().unwrap(), 3); + assert_eq!(arr[2].as_int().unwrap_or(0), 3); } #[test] fn test_pop() { let mut arr: Array = vec![Dynamic::from(1), Dynamic::from(2), Dynamic::from(3)]; - let popped = arr.pop().unwrap(); + let popped = arr.pop(); assert_eq!(arr.len(), 2); - assert_eq!(popped.as_int().unwrap(), 3); + assert_eq!(popped.and_then(|v| v.as_int().ok()).unwrap_or(0), 3); } #[test] @@ -153,8 +133,8 @@ mod tests { let mut arr: Array = vec![Dynamic::from(1), Dynamic::from(2), Dynamic::from(3)]; let shifted = arr.remove(0); assert_eq!(arr.len(), 2); - assert_eq!(shifted.as_int().unwrap(), 1); - assert_eq!(arr[0].as_int().unwrap(), 2); + assert_eq!(shifted.as_int().unwrap_or(0), 1); + assert_eq!(arr[0].as_int().unwrap_or(0), 2); } #[test] @@ -162,6 +142,6 @@ mod tests { let mut arr: Array = vec![Dynamic::from(2), Dynamic::from(3)]; arr.insert(0, Dynamic::from(1)); assert_eq!(arr.len(), 3); - assert_eq!(arr[0].as_int().unwrap(), 1); + assert_eq!(arr[0].as_int().unwrap_or(0), 1); } } diff --git a/src/basic/keywords/create_draft.rs b/src/basic/keywords/create_draft.rs index e750e0d4c..e8da22395 100644 --- a/src/basic/keywords/create_draft.rs +++ b/src/basic/keywords/create_draft.rs @@ -3,8 +3,8 @@ use crate::shared::state::AppState; use rhai::Dynamic; use rhai::Engine; -pub fn create_draft_keyword(_state: &AppState, _user: UserSession, engine: &mut Engine) { - let state_clone = _state.clone(); +pub fn create_draft_keyword(state: &AppState, _user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); engine .register_custom_syntax( &["CREATE_DRAFT", "$expr$", ",", "$expr$", ",", "$expr$"], @@ -17,11 +17,11 @@ pub fn create_draft_keyword(_state: &AppState, _user: UserSession, engine: &mut let fut = execute_create_draft(&state_clone, &to, &subject, &reply_text); let result = tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut)) - .map_err(|e| format!("Draft creation error: {}", e))?; + .map_err(|e| format!("Draft creation error: {e}"))?; Ok(Dynamic::from(result)) }, ) - .unwrap(); + .ok(); } async fn execute_create_draft( @@ -36,37 +36,36 @@ async fn execute_create_draft( let config = state.config.as_ref().ok_or("No email config")?; - // Fetch any previous emails to this recipient for threading let previous_email = fetch_latest_sent_to(&config.email, to) .await .unwrap_or_default(); let email_body = if !previous_email.is_empty() { - // Create a threaded reply let email_separator = "


"; let formatted_reply = reply_text.replace("FIX", "Fixed"); - let formatted_old = previous_email.replace("\n", "
"); - format!("{}{}{}", formatted_reply, email_separator, formatted_old) + let formatted_old = previous_email.replace('\n', "
"); + format!("{formatted_reply}{email_separator}{formatted_old}") } else { reply_text.to_string() }; let draft_request = SaveDraftRequest { + account_id: String::new(), to: to.to_string(), - subject: subject.to_string(), cc: None, + bcc: None, + subject: subject.to_string(), body: email_body, }; save_email_draft(&config.email, &draft_request) .await - .map(|_| "Draft saved successfully".to_string()) + .map(|()| "Draft saved successfully".to_string()) .map_err(|e| e.to_string()) } #[cfg(not(feature = "email"))] { - // Store draft in database when email feature is disabled use chrono::Utc; use diesel::prelude::*; use uuid::Uuid; @@ -92,7 +91,7 @@ async fn execute_create_draft( .execute(&mut db_conn) .map_err(|e| e.to_string())?; - Ok::<_, String>(format!("Draft saved with ID: {}", draft_id)) + Ok::<_, String>(format!("Draft saved with ID: {draft_id}")) }) .await .map_err(|e| e.to_string())? diff --git a/src/basic/keywords/first.rs b/src/basic/keywords/first.rs index 4f9bab412..a946480af 100644 --- a/src/basic/keywords/first.rs +++ b/src/basic/keywords/first.rs @@ -1,14 +1,19 @@ use rhai::Dynamic; use rhai::Engine; + pub fn first_keyword(engine: &mut Engine) { - engine - .register_custom_syntax(&["FIRST", "$expr$"], false, { - move |context, inputs| { - let input_string = context.eval_expression_tree(&inputs[0])?; - let input_str = input_string.to_string(); - let first_word = input_str.split_whitespace().next().unwrap_or("").to_string(); - Ok(Dynamic::from(first_word)) - } - }) - .unwrap(); + engine + .register_custom_syntax(&["FIRST", "$expr$"], false, { + move |context, inputs| { + let input_string = context.eval_expression_tree(&inputs[0])?; + let input_str = input_string.to_string(); + let first_word = input_str + .split_whitespace() + .next() + .unwrap_or("") + .to_string(); + Ok(Dynamic::from(first_word)) + } + }) + .ok(); } diff --git a/src/basic/keywords/last.rs b/src/basic/keywords/last.rs index 78d4c9088..e1e1fb068 100644 --- a/src/basic/keywords/last.rs +++ b/src/basic/keywords/last.rs @@ -1,18 +1,19 @@ use rhai::Dynamic; use rhai::Engine; + pub fn last_keyword(engine: &mut Engine) { - engine - .register_custom_syntax(&["LAST", "(", "$expr$", ")"], false, { - move |context, inputs| { - let input_string = context.eval_expression_tree(&inputs[0])?; - let input_str = input_string.to_string(); - if input_str.trim().is_empty() { - return Ok(Dynamic::from("")); - } - let words: Vec<&str> = input_str.split_whitespace().collect(); - let last_word = words.last().map(|s| *s).unwrap_or(""); - Ok(Dynamic::from(last_word.to_string())) - } - }) - .unwrap(); + engine + .register_custom_syntax(&["LAST", "(", "$expr$", ")"], false, { + move |context, inputs| { + let input_string = context.eval_expression_tree(&inputs[0])?; + let input_str = input_string.to_string(); + if input_str.trim().is_empty() { + return Ok(Dynamic::from("")); + } + let words: Vec<&str> = input_str.split_whitespace().collect(); + let last_word = words.last().copied().unwrap_or(""); + Ok(Dynamic::from(last_word.to_string())) + } + }) + .ok(); } diff --git a/src/basic/keywords/math/round.rs b/src/basic/keywords/math/round.rs index b16169f1f..c8cddeb7b 100644 --- a/src/basic/keywords/math/round.rs +++ b/src/basic/keywords/math/round.rs @@ -30,10 +30,10 @@ mod tests { #[test] fn test_round_decimals() { - let n = 3.14159_f64; + let n = 2.71828_f64; let decimals = 2; let factor = 10_f64.powi(decimals); let result = (n * factor).round() / factor; - assert!((result - 3.14).abs() < 0.001); + assert!((result - 2.72).abs() < 0.001); } } diff --git a/src/basic/keywords/math/trig.rs b/src/basic/keywords/math/trig.rs index ecbf9533b..a23a43259 100644 --- a/src/basic/keywords/math/trig.rs +++ b/src/basic/keywords/math/trig.rs @@ -79,6 +79,7 @@ mod tests { #[test] fn test_pi() { - assert!((std::f64::consts::PI - 3.14159).abs() < 0.001); + assert!(std::f64::consts::PI > 3.14); + assert!(std::f64::consts::PI < 3.15); } } diff --git a/src/basic/keywords/print.rs b/src/basic/keywords/print.rs index eef5ade1f..824d513dc 100644 --- a/src/basic/keywords/print.rs +++ b/src/basic/keywords/print.rs @@ -1,15 +1,15 @@ +use crate::shared::models::UserSession; +use crate::shared::state::AppState; use log::trace; use rhai::Dynamic; use rhai::Engine; -use crate::shared::state::AppState; -use crate::shared::models::UserSession; + pub fn print_keyword(_state: &AppState, _user: UserSession, engine: &mut Engine) { - engine - .register_custom_syntax(&["PRINT", "$expr$"], true, |context, inputs| { - let value = context.eval_expression_tree(&inputs[0])?; - trace!("PRINT: {}", value); - Ok(Dynamic::UNIT) - }, - ) - .unwrap(); + engine + .register_custom_syntax(&["PRINT", "$expr$"], true, |context, inputs| { + let value = context.eval_expression_tree(&inputs[0])?; + trace!("PRINT: {value}"); + Ok(Dynamic::UNIT) + }) + .ok(); } diff --git a/src/basic/keywords/send_mail.rs b/src/basic/keywords/send_mail.rs index 4300e8e5c..1f7e95bcd 100644 --- a/src/basic/keywords/send_mail.rs +++ b/src/basic/keywords/send_mail.rs @@ -290,7 +290,7 @@ async fn execute_send_mail( { use crate::email::EmailService; - let email_service = EmailService::new(Arc::new(state.as_ref().clone())); + let email_service = EmailService::new(Arc::new(state.clone())); if let Ok(_) = email_service .send_email( diff --git a/src/basic/keywords/set_user.rs b/src/basic/keywords/set_user.rs index 21e774f7b..e6a5b1fe6 100644 --- a/src/basic/keywords/set_user.rs +++ b/src/basic/keywords/set_user.rs @@ -7,7 +7,7 @@ use uuid::Uuid; pub fn set_user_keyword(state: Arc, user: UserSession, engine: &mut Engine) { let state_clone = Arc::clone(&state); - let user_clone = user.clone(); + let user_clone = user; engine .register_custom_syntax(&["SET", "USER", "$expr$"], true, move |context, inputs| { @@ -21,21 +21,20 @@ pub fn set_user_keyword(state: Arc, user: UserSession, engine: &mut En futures::executor::block_on(state_for_spawn.session_manager.lock()); if let Err(e) = session_manager.update_user_id(user_clone_spawn.id, user_id) { - error!("Failed to update user ID in session: {}", e); + error!("Failed to update user ID in session: {e}"); } else { trace!( - "Updated session {} to user ID: {}", - user_clone_spawn.id, - user_id + "Updated session {} to user ID: {user_id}", + user_clone_spawn.id ); } } Err(e) => { - trace!("Invalid user ID format: {}", e); + trace!("Invalid user ID format: {e}"); } } Ok(Dynamic::UNIT) }) - .unwrap(); + .ok(); } diff --git a/src/basic/keywords/wait.rs b/src/basic/keywords/wait.rs index 4e50f5e35..f358401e7 100644 --- a/src/basic/keywords/wait.rs +++ b/src/basic/keywords/wait.rs @@ -1,27 +1,29 @@ -use crate::shared::state::AppState; use crate::shared::models::UserSession; +use crate::shared::state::AppState; use rhai::{Dynamic, Engine}; use std::thread; use std::time::Duration; + pub fn wait_keyword(_state: &AppState, _user: UserSession, engine: &mut Engine) { - engine - .register_custom_syntax(&["WAIT", "$expr$"], false, move |context, inputs| { - let seconds = context.eval_expression_tree(&inputs[0])?; - let duration_secs = if seconds.is::() { - seconds.cast::() as f64 - } else if seconds.is::() { - seconds.cast::() - } else { - return Err(format!("WAIT expects a number, got: {}", seconds).into()); - }; - if duration_secs < 0.0 { - return Err("WAIT duration cannot be negative".into()); - } - let capped_duration = if duration_secs > 300.0 { 300.0 } else { duration_secs }; - let duration = Duration::from_secs_f64(capped_duration); - thread::sleep(duration); - Ok(Dynamic::from(format!("Waited {} seconds", capped_duration))) - }, - ) - .unwrap(); + engine + .register_custom_syntax(&["WAIT", "$expr$"], false, move |context, inputs| { + let seconds = context.eval_expression_tree(&inputs[0])?; + let duration_secs = if seconds.is::() { + #[allow(clippy::cast_precision_loss)] + let val = seconds.cast::() as f64; + val + } else if seconds.is::() { + seconds.cast::() + } else { + return Err(format!("WAIT expects a number, got: {seconds}").into()); + }; + if duration_secs < 0.0 { + return Err("WAIT duration cannot be negative".into()); + } + let capped_duration = duration_secs.min(300.0); + let duration = Duration::from_secs_f64(capped_duration); + thread::sleep(duration); + Ok(Dynamic::from(format!("Waited {capped_duration} seconds"))) + }) + .ok(); } diff --git a/src/basic/mod.rs b/src/basic/mod.rs index eb059f1cd..03fc3e064 100644 --- a/src/basic/mod.rs +++ b/src/basic/mod.rs @@ -124,7 +124,7 @@ impl ScriptService { add_member_keyword(state.clone(), user.clone(), &mut engine); // Register dynamic bot management keywords (ADD BOT, REMOVE BOT) - register_bot_keywords(state.clone(), user.clone(), &mut engine); + register_bot_keywords(&state, &user, &mut engine); // Register model routing keywords (USE MODEL, SET MODEL ROUTING, etc.) register_model_routing_keywords(state.clone(), user.clone(), &mut engine); diff --git a/src/calendar/caldav.rs b/src/calendar/caldav.rs new file mode 100644 index 000000000..6b9768aba --- /dev/null +++ b/src/calendar/caldav.rs @@ -0,0 +1,190 @@ +//! CalDAV module for calendar synchronization +//! +//! This module provides CalDAV protocol support for calendar synchronization +//! with external calendar clients and servers. + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use std::sync::Arc; + +use super::CalendarEngine; +use crate::shared::state::AppState; + +/// Create the CalDAV router +/// Note: The engine is stored in a static for now to avoid state type conflicts +pub fn create_caldav_router(_engine: Arc) -> Router> { + // TODO: Store engine in a way accessible to handlers + // For now, create a stateless router that can merge with any state type + Router::new() + .route("/caldav", get(caldav_root)) + .route("/caldav/principals", get(caldav_principals)) + .route("/caldav/calendars", get(caldav_calendars)) + .route("/caldav/calendars/:calendar_id", get(caldav_calendar)) + .route( + "/caldav/calendars/:calendar_id/:event_id.ics", + get(caldav_event).put(caldav_put_event), + ) +} + +/// CalDAV root endpoint - returns server capabilities +async fn caldav_root() -> impl IntoResponse { + Response::builder() + .status(StatusCode::OK) + .header("DAV", "1, 2, calendar-access") + .header("Content-Type", "application/xml; charset=utf-8") + .body( + r#" + + + /caldav/ + + + + + + GeneralBots CalDAV Server + + HTTP/1.1 200 OK + + +"# + .to_string(), + ) + .unwrap() +} + +/// CalDAV principals endpoint - returns user principal +async fn caldav_principals() -> impl IntoResponse { + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/xml; charset=utf-8") + .body( + r#" + + + /caldav/principals/ + + + + + + + + /caldav/calendars/ + + + HTTP/1.1 200 OK + + +"# + .to_string(), + ) + .unwrap() +} + +/// CalDAV calendars collection endpoint +async fn caldav_calendars() -> impl IntoResponse { + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/xml; charset=utf-8") + .body( + r#" + + + /caldav/calendars/ + + + + + + Calendars + + HTTP/1.1 200 OK + + + + /caldav/calendars/default/ + + + + + + + Default Calendar + + + + + + HTTP/1.1 200 OK + + +"# + .to_string(), + ) + .unwrap() +} + +/// CalDAV single calendar endpoint +async fn caldav_calendar() -> impl IntoResponse { + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/xml; charset=utf-8") + .body( + r#" + + + /caldav/calendars/default/ + + + + + + + Default Calendar + + HTTP/1.1 200 OK + + +"# + .to_string(), + ) + .unwrap() +} + +/// Get a single event in iCalendar format +async fn caldav_event() -> impl IntoResponse { + // TODO: Fetch actual event from engine and convert to iCalendar format + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "text/calendar; charset=utf-8") + .body( + r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//GeneralBots//Calendar//EN +BEGIN:VEVENT +UID:placeholder@generalbots.com +DTSTAMP:20240101T000000Z +DTSTART:20240101T090000Z +DTEND:20240101T100000Z +SUMMARY:Placeholder Event +END:VEVENT +END:VCALENDAR"# + .to_string(), + ) + .unwrap() +} + +/// Put (create/update) an event +async fn caldav_put_event() -> impl IntoResponse { + // TODO: Parse incoming iCalendar and create/update event in engine + Response::builder() + .status(StatusCode::CREATED) + .header("ETag", "\"placeholder-etag\"") + .body(String::new()) + .unwrap() +} diff --git a/src/calendar/mod.rs b/src/calendar/mod.rs index 367025280..6fd5f3357 100644 --- a/src/calendar/mod.rs +++ b/src/calendar/mod.rs @@ -5,8 +5,13 @@ use axum::{ routing::{get, post}, Router, }; -use chrono::{DateTime, Utc}; -use icalendar::{Calendar, Component, Event as IcalEvent, EventLike, Property}; +use chrono::{DateTime, NaiveDateTime, TimeZone, Utc}; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::PgConnection; +use icalendar::{ + Calendar, CalendarDateTime, Component, DatePerhapsTime, Event as IcalEvent, EventLike, Property, +}; +use log::info; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; @@ -16,6 +21,8 @@ use uuid::Uuid; use crate::core::urls::ApiUrls; use crate::shared::state::AppState; +pub mod caldav; + pub struct CalendarState { events: RwLock>, } @@ -108,8 +115,8 @@ impl CalendarEvent { let uid = ical.get_uid()?; let summary = ical.get_summary()?; - let start_time = ical.get_start()?.with_timezone(&Utc); - let end_time = ical.get_end()?.with_timezone(&Utc); + let start_time = date_perhaps_time_to_utc(ical.get_start()?)?; + let end_time = date_perhaps_time_to_utc(ical.get_end()?)?; let id = Uuid::parse_str(uid).unwrap_or_else(|_| Uuid::new_v4()); @@ -130,6 +137,31 @@ impl CalendarEvent { } } +/// Convert DatePerhapsTime to DateTime +fn date_perhaps_time_to_utc(dpt: DatePerhapsTime) -> Option> { + match dpt { + DatePerhapsTime::DateTime(cal_dt) => { + // Handle different CalendarDateTime variants + match cal_dt { + CalendarDateTime::Utc(dt) => Some(dt), + CalendarDateTime::Floating(naive) => { + // For floating time, assume UTC + Some(Utc.from_utc_datetime(&naive)) + } + CalendarDateTime::WithTimezone { date_time, .. } => { + // For timezone-aware, convert to UTC (assuming UTC if tz parsing fails) + Some(Utc.from_utc_datetime(&date_time)) + } + } + } + DatePerhapsTime::Date(date) => { + // For date-only, use midnight UTC + let naive = NaiveDateTime::new(date, chrono::NaiveTime::from_hms_opt(0, 0, 0)?); + Some(Utc.from_utc_datetime(&naive)) + } + } +} + /// Export events to iCal format pub fn export_to_ical(events: &[CalendarEvent], calendar_name: &str) -> String { let mut calendar = Calendar::new(); @@ -165,11 +197,16 @@ pub fn import_from_ical(ical_str: &str, organizer: &str) -> Vec { #[derive(Default)] pub struct CalendarEngine { events: Vec, + #[allow(dead_code)] + conn: Option>>, } impl CalendarEngine { - pub fn new() -> Self { - Self::default() + pub fn new(conn: Pool>) -> Self { + Self { + events: Vec::new(), + conn: Some(conn), + } } pub fn create_event(&mut self, input: CalendarEventInput) -> CalendarEvent { @@ -262,7 +299,6 @@ impl CalendarEngine { } } - pub async fn list_events( State(_state): State>, axum::extract::Query(_query): axum::extract::Query, @@ -402,10 +438,7 @@ pub async fn update_event( Ok(Json(event.clone())) } -pub async fn delete_event( - State(_state): State>, - Path(id): Path, -) -> StatusCode { +pub async fn delete_event(State(_state): State>, Path(id): Path) -> StatusCode { let calendar_state = get_calendar_state(); let mut events = calendar_state.events.write().await; @@ -438,16 +471,21 @@ pub async fn import_ical( /// New event form (HTMX HTML response) pub async fn new_event_form(State(_state): State>) -> axum::response::Html { - axum::response::Html(r#" + axum::response::Html( + r#"

Create a new event using the form on the right panel.

- "#.to_string()) + "# + .to_string(), + ) } /// New calendar form (HTMX HTML response) -pub async fn new_calendar_form(State(_state): State>) -> axum::response::Html { - axum::response::Html(r#" +pub async fn new_calendar_form( + State(_state): State>, +) -> axum::response::Html { + axum::response::Html(r##"
@@ -468,7 +506,33 @@ pub async fn new_calendar_form(State(_state): State>) -> axum::res
- "#.to_string()) + "##.to_string()) +} + +/// Start the reminder job that checks for upcoming events and sends notifications +pub async fn start_reminder_job(engine: Arc) { + info!("Starting calendar reminder job"); + + loop { + // Check every minute for upcoming reminders + tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; + + let now = Utc::now(); + for event in &engine.events { + if let Some(reminder_minutes) = event.reminder_minutes { + let reminder_time = + event.start_time - chrono::Duration::minutes(reminder_minutes as i64); + // Check if we're within the reminder window (within 1 minute) + if now >= reminder_time && now < reminder_time + chrono::Duration::minutes(1) { + info!( + "Reminder: Event '{}' starts in {} minutes", + event.title, reminder_minutes + ); + // TODO: Send actual notification via configured channels + } + } + } + } } /// Configure calendar API routes diff --git a/src/compliance/code_scanner.rs b/src/compliance/code_scanner.rs index 6e58e9918..77ae4e207 100644 --- a/src/compliance/code_scanner.rs +++ b/src/compliance/code_scanner.rs @@ -390,7 +390,8 @@ impl CodeScanner { .to_string_lossy() .to_string(); - let bot_id = format!("{:x}", md5::compute(&bot_name)); + let bot_id = + uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, bot_name.as_bytes()).to_string(); let mut issues = Vec::new(); let mut stats = ScanStats::default(); diff --git a/src/compliance/mod.rs b/src/compliance/mod.rs index d9797d0eb..d575e4471 100644 --- a/src/compliance/mod.rs +++ b/src/compliance/mod.rs @@ -47,7 +47,7 @@ pub enum ComplianceStatus { } /// Severity levels -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Severity { Low, Medium, diff --git a/src/compliance/risk_assessment.rs b/src/compliance/risk_assessment.rs index 505d55b21..32b3d02df 100644 --- a/src/compliance/risk_assessment.rs +++ b/src/compliance/risk_assessment.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use uuid::Uuid; /// Risk category enumeration -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum RiskCategory { Security, Compliance, @@ -22,7 +22,7 @@ pub enum RiskCategory { } /// Risk likelihood levels -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum Likelihood { Rare, Unlikely, @@ -32,7 +32,7 @@ pub enum Likelihood { } /// Risk impact levels -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum Impact { Negligible, Minor, @@ -42,7 +42,7 @@ pub enum Impact { } /// Risk level based on likelihood and impact -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum RiskLevel { Low, Medium, @@ -51,7 +51,7 @@ pub enum RiskLevel { } /// Risk status -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum RiskStatus { Identified, Assessed, @@ -263,11 +263,7 @@ impl RiskAssessmentService { } /// Add vulnerability to risk assessment - pub fn add_vulnerability( - &mut self, - risk_id: Uuid, - vulnerability: Vulnerability, - ) -> Result<()> { + pub fn add_vulnerability(&mut self, risk_id: Uuid, vulnerability: Vulnerability) -> Result<()> { let assessment = self .assessments .get_mut(&risk_id) @@ -340,9 +336,9 @@ impl RiskAssessmentService { } } - assessment.risk_level = - self.risk_matrix - .calculate_risk_level(&assessment.likelihood, &assessment.impact); + assessment.risk_level = self + .risk_matrix + .calculate_risk_level(&assessment.likelihood, &assessment.impact); Ok(()) } @@ -489,24 +485,42 @@ impl Default for RiskMatrix { matrix.insert((Likelihood::Unlikely, Impact::Minor), RiskLevel::Low); matrix.insert((Likelihood::Unlikely, Impact::Moderate), RiskLevel::Medium); matrix.insert((Likelihood::Unlikely, Impact::Major), RiskLevel::High); - matrix.insert((Likelihood::Unlikely, Impact::Catastrophic), RiskLevel::High); + matrix.insert( + (Likelihood::Unlikely, Impact::Catastrophic), + RiskLevel::High, + ); matrix.insert((Likelihood::Possible, Impact::Negligible), RiskLevel::Low); matrix.insert((Likelihood::Possible, Impact::Minor), RiskLevel::Medium); matrix.insert((Likelihood::Possible, Impact::Moderate), RiskLevel::Medium); matrix.insert((Likelihood::Possible, Impact::Major), RiskLevel::High); - matrix.insert((Likelihood::Possible, Impact::Catastrophic), RiskLevel::Critical); + matrix.insert( + (Likelihood::Possible, Impact::Catastrophic), + RiskLevel::Critical, + ); matrix.insert((Likelihood::Likely, Impact::Negligible), RiskLevel::Medium); matrix.insert((Likelihood::Likely, Impact::Minor), RiskLevel::Medium); matrix.insert((Likelihood::Likely, Impact::Moderate), RiskLevel::High); matrix.insert((Likelihood::Likely, Impact::Major), RiskLevel::Critical); - matrix.insert((Likelihood::Likely, Impact::Catastrophic), RiskLevel::Critical); + matrix.insert( + (Likelihood::Likely, Impact::Catastrophic), + RiskLevel::Critical, + ); - matrix.insert((Likelihood::AlmostCertain, Impact::Negligible), RiskLevel::Medium); + matrix.insert( + (Likelihood::AlmostCertain, Impact::Negligible), + RiskLevel::Medium, + ); matrix.insert((Likelihood::AlmostCertain, Impact::Minor), RiskLevel::High); - matrix.insert((Likelihood::AlmostCertain, Impact::Moderate), RiskLevel::High); - matrix.insert((Likelihood::AlmostCertain, Impact::Major), RiskLevel::Critical); + matrix.insert( + (Likelihood::AlmostCertain, Impact::Moderate), + RiskLevel::High, + ); + matrix.insert( + (Likelihood::AlmostCertain, Impact::Major), + RiskLevel::Critical, + ); matrix.insert( (Likelihood::AlmostCertain, Impact::Catastrophic), RiskLevel::Critical, diff --git a/src/compliance/training_tracker.rs b/src/compliance/training_tracker.rs index bc9d86e72..21a8e379b 100644 --- a/src/compliance/training_tracker.rs +++ b/src/compliance/training_tracker.rs @@ -23,7 +23,7 @@ pub enum TrainingType { } /// Training status -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum TrainingStatus { NotStarted, InProgress, @@ -152,7 +152,8 @@ impl TrainingTracker { max_attempts: 3, }; - self.courses.insert(security_awareness.id, security_awareness); + self.courses + .insert(security_awareness.id, security_awareness); let data_protection = TrainingCourse { id: Uuid::new_v4(), @@ -213,11 +214,7 @@ impl TrainingTracker { self.assignments.insert(assignment.id, assignment.clone()); - log::info!( - "Assigned training '{}' to user {}", - course.title, - user_id - ); + log::info!("Assigned training '{}' to user {}", course.title, user_id); Ok(assignment) } @@ -298,7 +295,10 @@ impl TrainingTracker { course_id: course.id, issued_date: end_time, expiry_date: end_time + Duration::days(course.validity_days), - certificate_number: format!("CERT-{}", Uuid::new_v4().to_string()[..8].to_uppercase()), + certificate_number: format!( + "CERT-{}", + Uuid::new_v4().to_string()[..8].to_uppercase() + ), verification_code: Uuid::new_v4().to_string(), }; @@ -331,9 +331,11 @@ impl TrainingTracker { let mut upcoming_trainings = vec![]; for course in self.courses.values() { - if course.required_for_roles.iter().any(|r| { - user_roles.contains(r) || r == "all" - }) { + if course + .required_for_roles + .iter() + .any(|r| user_roles.contains(r) || r == "all") + { required_trainings.push(course.id); // Check if user has completed this training @@ -401,18 +403,14 @@ impl TrainingTracker { let overdue_count = self .assignments .values() - .filter(|a| { - a.status != TrainingStatus::Completed - && a.due_date < Utc::now() - }) + .filter(|a| a.status != TrainingStatus::Completed && a.due_date < Utc::now()) .count(); let expiring_soon = self .certificates .values() .filter(|c| { - c.expiry_date > Utc::now() - && c.expiry_date < Utc::now() + Duration::days(30) + c.expiry_date > Utc::now() && c.expiry_date < Utc::now() + Duration::days(30) }) .count(); @@ -460,10 +458,7 @@ impl TrainingTracker { pub fn get_overdue_trainings(&self) -> Vec { self.assignments .values() - .filter(|a| { - a.status != TrainingStatus::Completed - && a.due_date < Utc::now() - }) + .filter(|a| a.status != TrainingStatus::Completed && a.due_date < Utc::now()) .cloned() .collect() } @@ -473,9 +468,7 @@ impl TrainingTracker { let cutoff = Utc::now() + Duration::days(days_ahead); self.certificates .values() - .filter(|c| { - c.expiry_date > Utc::now() && c.expiry_date <= cutoff - }) + .filter(|c| c.expiry_date > Utc::now() && c.expiry_date <= cutoff) .cloned() .collect() } diff --git a/src/drive/vectordb.rs b/src/drive/vectordb.rs index 56a581b0c..0f383219c 100644 --- a/src/drive/vectordb.rs +++ b/src/drive/vectordb.rs @@ -109,7 +109,7 @@ impl UserDriveVectorDB { if !exists { // Create collection for file embeddings (1536 dimensions for OpenAI embeddings) client - .create_collection(&CreateCollection { + .create_collection(CreateCollection { collection_name: self.collection_name.clone(), vectors_config: Some(VectorsConfig { config: Some(Config::Params(VectorParams { @@ -482,7 +482,7 @@ impl UserDriveVectorDB { // Recreate empty collection client - .create_collection(&CreateCollection { + .create_collection(CreateCollection { collection_name: self.collection_name.clone(), vectors_config: Some(VectorsConfig { config: Some(Config::Params(VectorParams { diff --git a/src/email/mod.rs b/src/email/mod.rs index 123b6af07..0a75308d5 100644 --- a/src/email/mod.rs +++ b/src/email/mod.rs @@ -6,12 +6,13 @@ use axum::{ Json, }; use axum::{ - routing::{get, post}, + routing::{delete, get, post}, Router, }; use base64::{engine::general_purpose, Engine as _}; use chrono::{DateTime, Utc}; use diesel::prelude::*; +use diesel::sql_types::{Bool, Integer, Nullable, Text, Timestamptz, Uuid as DieselUuid}; use imap::types::Seq; use lettre::{transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport}; use log::{debug, info, warn}; @@ -20,6 +21,68 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; +// ===== QueryableByName Structs for Raw SQL Queries ===== + +/// For querying email account basic info (id, email, display_name, is_primary) +#[derive(Debug, QueryableByName)] +pub struct EmailAccountBasicRow { + #[diesel(sql_type = DieselUuid)] + pub id: Uuid, + #[diesel(sql_type = Text)] + pub email: String, + #[diesel(sql_type = Nullable)] + pub display_name: Option, + #[diesel(sql_type = Bool)] + pub is_primary: bool, +} + +/// For querying IMAP credentials +#[derive(Debug, QueryableByName)] +pub struct ImapCredentialsRow { + #[diesel(sql_type = Text)] + pub imap_server: String, + #[diesel(sql_type = Integer)] + pub imap_port: i32, + #[diesel(sql_type = Text)] + pub username: String, + #[diesel(sql_type = Text)] + pub password_encrypted: String, +} + +/// For querying SMTP credentials (for sending) +#[derive(Debug, QueryableByName)] +pub struct SmtpCredentialsRow { + #[diesel(sql_type = Text)] + pub email: String, + #[diesel(sql_type = Text)] + pub display_name: String, + #[diesel(sql_type = Integer)] + pub smtp_port: i32, + #[diesel(sql_type = Text)] + pub smtp_server: String, + #[diesel(sql_type = Text)] + pub username: String, + #[diesel(sql_type = Text)] + pub password_encrypted: String, +} + +/// For querying email search results +#[derive(Debug, QueryableByName)] +pub struct EmailSearchRow { + #[diesel(sql_type = Text)] + pub id: String, + #[diesel(sql_type = Text)] + pub subject: String, + #[diesel(sql_type = Text)] + pub from_address: String, + #[diesel(sql_type = Text)] + pub to_addresses: String, + #[diesel(sql_type = Nullable)] + pub body_text: Option, + #[diesel(sql_type = Timestamptz)] + pub received_at: DateTime, +} + pub mod stalwart_client; pub mod stalwart_sync; pub mod vectordb; @@ -43,30 +106,36 @@ pub fn configure() -> Router> { post(add_email_account), ) .route( - ApiUrls::EMAIL_ACCOUNT_BY_ID.replace(":id", "{account_id}"), + &ApiUrls::EMAIL_ACCOUNT_BY_ID.replace(":id", "{account_id}"), axum::routing::delete(delete_email_account), ) .route(ApiUrls::EMAIL_LIST, post(list_emails)) .route(ApiUrls::EMAIL_SEND, post(send_email)) .route(ApiUrls::EMAIL_DRAFT, post(save_draft)) .route( - ApiUrls::EMAIL_FOLDERS.replace(":account_id", "{account_id}"), + &ApiUrls::EMAIL_FOLDERS.replace(":account_id", "{account_id}"), get(list_folders), ) .route(ApiUrls::EMAIL_LATEST, get(get_latest_email)) .route( - ApiUrls::EMAIL_GET.replace(":campaign_id", "{campaign_id}"), + &ApiUrls::EMAIL_GET.replace(":campaign_id", "{campaign_id}"), get(get_email), ) .route( - ApiUrls::EMAIL_CLICK + &ApiUrls::EMAIL_CLICK .replace(":campaign_id", "{campaign_id}") .replace(":email", "{email}"), post(track_click), ) // Email read tracking endpoints - .route("/api/email/tracking/pixel/{tracking_id}", get(serve_tracking_pixel)) - .route("/api/email/tracking/status/{tracking_id}", get(get_tracking_status)) + .route( + "/api/email/tracking/pixel/{tracking_id}", + get(serve_tracking_pixel), + ) + .route( + "/api/email/tracking/status/{tracking_id}", + get(get_tracking_status), + ) .route("/api/email/tracking/list", get(list_sent_emails_tracking)) .route("/api/email/tracking/stats", get(get_tracking_stats)) // UI HTMX endpoints (return HTML fragments) @@ -328,7 +397,7 @@ pub async fn add_email_account( Json(request): Json, ) -> Result>, EmailError> { // Get user_id from session - let user_id = match extract_user_from_session(&state).await { + let current_user_id = match extract_user_from_session(&state).await { Ok(id) => id, Err(_) => return Err(EmailError("Authentication required".to_string())), }; @@ -336,6 +405,15 @@ pub async fn add_email_account( let account_id = Uuid::new_v4(); let encrypted_password = encrypt_password(&request.password); + // Clone fields for response before moving into spawn_blocking + let resp_email = request.email.clone(); + let resp_display_name = request.display_name.clone(); + let resp_imap_server = request.imap_server.clone(); + let resp_imap_port = request.imap_port; + let resp_smtp_server = request.smtp_server.clone(); + let resp_smtp_port = request.smtp_port; + let resp_is_primary = request.is_primary; + let conn = state.conn.clone(); tokio::task::spawn_blocking(move || { use crate::shared::models::schema::user_email_accounts::dsl::*; @@ -343,7 +421,7 @@ pub async fn add_email_account( // If this is primary, unset other primary accounts if request.is_primary { - diesel::update(user_email_accounts.filter(user_id.eq(&user_id))) + diesel::update(user_email_accounts.filter(user_id.eq(¤t_user_id))) .set(is_primary.eq(false)) .execute(&mut db_conn) .ok(); @@ -355,7 +433,7 @@ pub async fn add_email_account( VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)" ) .bind::(account_id) - .bind::(user_id) + .bind::(current_user_id) .bind::(&request.email) .bind::, _>(request.display_name.as_ref()) .bind::(&request.imap_server) @@ -379,13 +457,13 @@ pub async fn add_email_account( success: true, data: Some(EmailAccountResponse { id: account_id.to_string(), - email: request.email, - display_name: request.display_name, - imap_server: request.imap_server, - imap_port: request.imap_port, - smtp_server: request.smtp_server, - smtp_port: request.smtp_port, - is_primary: request.is_primary, + email: resp_email, + display_name: resp_display_name, + imap_server: resp_imap_server, + imap_port: resp_imap_port, + smtp_server: resp_smtp_server, + smtp_port: resp_smtp_port, + is_primary: resp_is_primary, is_active: true, created_at: chrono::Utc::now().to_rfc3339(), }), @@ -394,9 +472,7 @@ pub async fn add_email_account( } /// List email accounts - HTMX HTML response for UI -pub async fn list_email_accounts_htmx( - State(state): State>, -) -> impl IntoResponse { +pub async fn list_email_accounts_htmx(State(state): State>) -> impl IntoResponse { // Get user_id from session let user_id = match extract_user_from_session(&state).await { Ok(id) => id, @@ -417,7 +493,7 @@ pub async fn list_email_accounts_htmx( "SELECT id, email, display_name, is_primary FROM user_email_accounts WHERE user_id = $1 AND is_active = true ORDER BY is_primary DESC" ) .bind::(user_id) - .load::<(Uuid, String, Option, bool)>(&mut db_conn) + .load::(&mut db_conn) .map_err(|e| format!("Query failed: {}", e)) }) .await @@ -434,15 +510,22 @@ pub async fn list_email_accounts_htmx( } let mut html = String::new(); - for (id, email, display_name, is_primary) in accounts { - let name = display_name.unwrap_or_else(|| email.clone()); - let primary_badge = if is_primary { r#"Primary"# } else { "" }; + for account in accounts { + let name = account + .display_name + .clone() + .unwrap_or_else(|| account.email.clone()); + let primary_badge = if account.is_primary { + r##"Primary"## + } else { + "" + }; html.push_str(&format!( - r#""##, + account.id, name, primary_badge )); } @@ -454,22 +537,46 @@ pub async fn list_email_accounts( State(state): State>, ) -> Result>>, EmailError> { // Get user_id from session - let user_id = match extract_user_from_session(&state).await { + let current_user_id = match extract_user_from_session(&state).await { Ok(id) => id, Err(_) => return Err(EmailError("Authentication required".to_string())), }; let conn = state.conn.clone(); let accounts = tokio::task::spawn_blocking(move || { - let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + use crate::shared::models::schema::user_email_accounts::dsl::*; + let mut db_conn = conn + .get() + .map_err(|e| format!("DB connection error: {}", e))?; - let results: Vec<(Uuid, String, Option, String, i32, String, i32, bool, bool, chrono::DateTime)> = - diesel::sql_query( - "SELECT id, email, display_name, imap_server, imap_port, smtp_server, smtp_port, is_primary, is_active, created_at - FROM user_email_accounts WHERE user_id = $1 AND is_active = true ORDER BY is_primary DESC, created_at DESC" - ) - .bind::(user_id) - .load(&mut db_conn) + let results = user_email_accounts + .filter(user_id.eq(current_user_id)) + .filter(is_active.eq(true)) + .order((is_primary.desc(), created_at.desc())) + .select(( + id, + email, + display_name, + imap_server, + imap_port, + smtp_server, + smtp_port, + is_primary, + is_active, + created_at, + )) + .load::<( + Uuid, + String, + Option, + String, + i32, + String, + i32, + bool, + bool, + chrono::DateTime, + )>(&mut db_conn) .map_err(|e| format!("Query failed: {}", e))?; Ok::<_, String>(results) @@ -482,28 +589,28 @@ pub async fn list_email_accounts( .into_iter() .map( |( - id, - email, - display_name, - imap_server, - imap_port, - smtp_server, - smtp_port, - is_primary, - is_active, - created_at, + acc_id, + acc_email, + acc_display_name, + acc_imap_server, + acc_imap_port, + acc_smtp_server, + acc_smtp_port, + acc_is_primary, + acc_is_active, + acc_created_at, )| { EmailAccountResponse { - id: id.to_string(), - email, - display_name, - imap_server, - imap_port: imap_port as u16, - smtp_server, - smtp_port: smtp_port as u16, - is_primary, - is_active, - created_at: created_at.to_rfc3339(), + id: acc_id.to_string(), + email: acc_email, + display_name: acc_display_name, + imap_server: acc_imap_server, + imap_port: acc_imap_port as u16, + smtp_server: acc_smtp_server, + smtp_port: acc_smtp_port as u16, + is_primary: acc_is_primary, + is_active: acc_is_active, + created_at: acc_created_at.to_rfc3339(), } }, ) @@ -561,7 +668,7 @@ pub async fn list_emails( let account_info = tokio::task::spawn_blocking(move || { let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; - let result: (String, i32, String, String) = diesel::sql_query( + let result: ImapCredentialsRow = diesel::sql_query( "SELECT imap_server, imap_port, username, password_encrypted FROM user_email_accounts WHERE id = $1 AND is_active = true" ) .bind::(account_uuid) @@ -574,17 +681,16 @@ pub async fn list_emails( .map_err(|e| EmailError(format!("Task join error: {}", e)))? .map_err(EmailError)?; - let (imap_server, imap_port, username, encrypted_password) = account_info; + let (imap_server, imap_port, username, encrypted_password) = ( + account_info.imap_server, + account_info.imap_port, + account_info.username, + account_info.password_encrypted, + ); let password = decrypt_password(&encrypted_password).map_err(EmailError)?; - // Connect to IMAP - let tls = native_tls::TlsConnector::builder() - .build() - .map_err(|e| EmailError(format!("Failed to create TLS connector: {:?}", e)))?; - + // Connect to IMAP (imap 3.0 handles TLS internally) let client = imap::ClientBuilder::new(imap_server.as_str(), imap_port as u16) - .native_tls(&tls) - .map_err(|e| EmailError(format!("Failed to create IMAP client: {:?}", e)))? .connect() .map_err(|e| EmailError(format!("Failed to connect to IMAP: {:?}", e)))?; @@ -708,7 +814,7 @@ pub async fn send_email( .get() .map_err(|e| format!("DB connection error: {}", e))?; - let result: (String, String, i32, String, String, String) = diesel::sql_query( + let result: SmtpCredentialsRow = diesel::sql_query( "SELECT email, display_name, smtp_port, smtp_server, username, password_encrypted FROM user_email_accounts WHERE id = $1 AND is_active = true", ) @@ -722,8 +828,14 @@ pub async fn send_email( .map_err(|e| EmailError(format!("Task join error: {}", e)))? .map_err(EmailError)?; - let (from_email, display_name, smtp_port, smtp_server, username, encrypted_password) = - account_info; + let (from_email, display_name, smtp_port, smtp_server, username, encrypted_password) = ( + account_info.email, + account_info.display_name, + account_info.smtp_port, + account_info.smtp_server, + account_info.username, + account_info.password_encrypted, + ); let password = decrypt_password(&encrypted_password).map_err(EmailError)?; let from_addr = if display_name.is_empty() { @@ -809,7 +921,10 @@ pub async fn send_email( .await; } - info!("Email sent successfully from account {} with tracking_id {}", account_uuid, tracking_id); + info!( + "Email sent successfully from account {} with tracking_id {}", + account_uuid, tracking_id + ); Ok(Json(ApiResponse { success: true, @@ -878,7 +993,7 @@ pub async fn list_folders( let account_info = tokio::task::spawn_blocking(move || { let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; - let result: (String, i32, String, String) = diesel::sql_query( + let result: ImapCredentialsRow = diesel::sql_query( "SELECT imap_server, imap_port, username, password_encrypted FROM user_email_accounts WHERE id = $1 AND is_active = true" ) .bind::(account_uuid) @@ -891,19 +1006,19 @@ pub async fn list_folders( .map_err(|e| EmailError(format!("Task join error: {}", e)))? .map_err(EmailError)?; - let (imap_server, imap_port, username, encrypted_password) = account_info; + let (imap_server, imap_port, username, encrypted_password) = ( + account_info.imap_server, + account_info.imap_port, + account_info.username, + account_info.password_encrypted, + ); let password = decrypt_password(&encrypted_password).map_err(EmailError)?; // Connect and list folders - let tls = native_tls::TlsConnector::builder() - .build() - .map_err(|e| EmailError(format!("TLS error: {:?}", e)))?; - + // Connect to IMAP (imap 3.0 handles TLS internally) let client = imap::ClientBuilder::new(imap_server.as_str(), imap_port as u16) - .native_tls(&tls) - .map_err(|e| EmailError(format!("Failed to create IMAP client: {:?}", e)))? .connect() - .map_err(|e| EmailError(format!("Failed to connect to IMAP: {:?}", e)))?; + .map_err(|e| format!("Failed to connect to IMAP: {:?}", e))?; let mut session = client .login(&username, &password) @@ -966,9 +1081,9 @@ pub async fn save_click( /// 1x1 transparent GIF pixel bytes const TRACKING_PIXEL: [u8; 43] = [ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xFF, 0xFF, - 0xFF, 0x00, 0x00, 0x00, 0x21, 0xF9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, - 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3B, + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xFF, 0xFF, 0xFF, + 0x00, 0x00, 0x00, 0x21, 0xF9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3B, ]; /// Check if email-read-pixel is enabled in config @@ -983,12 +1098,16 @@ async fn is_tracking_pixel_enabled(state: &Arc, bot_id: Option) } /// Inject tracking pixel into HTML email body -async fn inject_tracking_pixel(html_body: &str, tracking_id: &str, state: &Arc) -> String { +async fn inject_tracking_pixel( + html_body: &str, + tracking_id: &str, + state: &Arc, +) -> String { // Get base URL from config or use default let config_manager = crate::core::config::ConfigManager::new(state.conn.clone()); let base_url = config_manager .get_config(&Uuid::nil(), "server-url", Some("http://localhost:8080")) - .unwrap_or_else(|| "http://localhost:8080".to_string()); + .unwrap_or_else(|_| "http://localhost:8080".to_string()); let pixel_url = format!("{}/api/email/tracking/pixel/{}", base_url, tracking_id); let pixel_html = format!( @@ -998,7 +1117,8 @@ async fn inject_tracking_pixel(html_body: &str, tracking_id: &str, state: &Arc tag, or at the end if no body tag if html_body.to_lowercase().contains("") { - html_body.replace("", &format!("{}", pixel_html)) + html_body + .replace("", &format!("{}", pixel_html)) .replace("", &format!("{}", pixel_html)) } else { format!("{}{}", html_body, pixel_html) @@ -1017,7 +1137,9 @@ fn save_email_tracking_record( bcc: Option<&str>, subject: &str, ) -> Result<(), String> { - let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + let mut db_conn = conn + .get() + .map_err(|e| format!("DB connection error: {}", e))?; let id = Uuid::new_v4(); let now = Utc::now(); @@ -1080,7 +1202,10 @@ pub async fn serve_tracking_pixel( }) .await; - info!("Email read tracked: tracking_id={}, ip={:?}", tracking_id, client_ip); + info!( + "Email read tracked: tracking_id={}, ip={:?}", + tracking_id, client_ip + ); } else { warn!("Invalid tracking ID received: {}", tracking_id); } @@ -1091,7 +1216,10 @@ pub async fn serve_tracking_pixel( StatusCode::OK, [ ("content-type", "image/gif"), - ("cache-control", "no-store, no-cache, must-revalidate, max-age=0"), + ( + "cache-control", + "no-store, no-cache, must-revalidate, max-age=0", + ), ("pragma", "no-cache"), ("expires", "0"), ], @@ -1106,7 +1234,9 @@ fn update_email_read_status( client_ip: Option, user_agent: Option, ) -> Result<(), String> { - let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + let mut db_conn = conn + .get() + .map_err(|e| format!("DB connection error: {}", e))?; let now = Utc::now(); // Update tracking record - increment read count, set first/last read info @@ -1120,7 +1250,7 @@ fn update_email_read_status( last_read_ip = $3, user_agent = COALESCE(user_agent, $4), updated_at = $2 - WHERE tracking_id = $1"# + WHERE tracking_id = $1"#, ) .bind::(tracking_id) .bind::(now) @@ -1138,16 +1268,14 @@ pub async fn get_tracking_status( Path(tracking_id): Path, State(state): State>, ) -> Result>, EmailError> { - let tracking_uuid = Uuid::parse_str(&tracking_id) - .map_err(|_| EmailError("Invalid tracking ID".to_string()))?; + let tracking_uuid = + Uuid::parse_str(&tracking_id).map_err(|_| EmailError("Invalid tracking ID".to_string()))?; let conn = state.conn.clone(); - let result = tokio::task::spawn_blocking(move || { - get_tracking_record(conn, tracking_uuid) - }) - .await - .map_err(|e| EmailError(format!("Task join error: {}", e)))? - .map_err(EmailError)?; + let result = tokio::task::spawn_blocking(move || get_tracking_record(conn, tracking_uuid)) + .await + .map_err(|e| EmailError(format!("Task join error: {}", e)))? + .map_err(EmailError)?; Ok(Json(ApiResponse { success: true, @@ -1161,7 +1289,9 @@ fn get_tracking_record( conn: crate::shared::utils::DbPool, tracking_id: Uuid, ) -> Result { - let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + let mut db_conn = conn + .get() + .map_err(|e| format!("DB connection error: {}", e))?; #[derive(QueryableByName)] struct TrackingRow { @@ -1183,7 +1313,7 @@ fn get_tracking_record( let row: TrackingRow = diesel::sql_query( r#"SELECT tracking_id, to_email, subject, sent_at, is_read, read_at, read_count - FROM sent_email_tracking WHERE tracking_id = $1"# + FROM sent_email_tracking WHERE tracking_id = $1"#, ) .bind::(tracking_id) .get_result(&mut db_conn) @@ -1206,12 +1336,10 @@ pub async fn list_sent_emails_tracking( Query(query): Query, ) -> Result>>, EmailError> { let conn = state.conn.clone(); - let result = tokio::task::spawn_blocking(move || { - list_tracking_records(conn, query) - }) - .await - .map_err(|e| EmailError(format!("Task join error: {}", e)))? - .map_err(EmailError)?; + let result = tokio::task::spawn_blocking(move || list_tracking_records(conn, query)) + .await + .map_err(|e| EmailError(format!("Task join error: {}", e)))? + .map_err(EmailError)?; Ok(Json(ApiResponse { success: true, @@ -1225,7 +1353,9 @@ fn list_tracking_records( conn: crate::shared::utils::DbPool, query: ListTrackingQuery, ) -> Result, String> { - let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + let mut db_conn = conn + .get() + .map_err(|e| format!("DB connection error: {}", e))?; let limit = query.limit.unwrap_or(50); let offset = query.offset.unwrap_or(0); @@ -1292,12 +1422,10 @@ pub async fn get_tracking_stats( State(state): State>, ) -> Result>, EmailError> { let conn = state.conn.clone(); - let result = tokio::task::spawn_blocking(move || { - calculate_tracking_stats(conn) - }) - .await - .map_err(|e| EmailError(format!("Task join error: {}", e)))? - .map_err(EmailError)?; + let result = tokio::task::spawn_blocking(move || calculate_tracking_stats(conn)) + .await + .map_err(|e| EmailError(format!("Task join error: {}", e)))? + .map_err(EmailError)?; Ok(Json(ApiResponse { success: true, @@ -1310,7 +1438,9 @@ pub async fn get_tracking_stats( fn calculate_tracking_stats( conn: crate::shared::utils::DbPool, ) -> Result { - let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + let mut db_conn = conn + .get() + .map_err(|e| format!("DB connection error: {}", e))?; #[derive(QueryableByName)] struct StatsRow { @@ -1423,15 +1553,8 @@ impl EmailService { // Helper functions for draft system pub async fn fetch_latest_sent_to(config: &EmailConfig, to: &str) -> Result { - use native_tls::TlsConnector; - - let tls = TlsConnector::builder() - .build() - .map_err(|e| format!("TLS error: {}", e))?; - + // Connect to IMAP (imap 3.0 handles TLS internally) let client = imap::ClientBuilder::new(&config.server, config.port as u16) - .native_tls(&tls) - .map_err(|e| format!("IMAP client error: {}", e))? .connect() .map_err(|e| format!("Connection error: {}", e))?; @@ -1449,7 +1572,7 @@ pub async fn fetch_latest_sent_to(config: &EmailConfig, to: &str) -> Result Result<(), String> { use chrono::Utc; - use native_tls::TlsConnector; - - let tls = TlsConnector::builder() - .build() - .map_err(|e| format!("TLS error: {}", e))?; + // Connect to IMAP (imap 3.0 handles TLS internally) let client = imap::ClientBuilder::new(&config.server, config.port as u16) - .native_tls(&tls) - .map_err(|e| format!("IMAP client error: {}", e))? .connect() .map_err(|e| format!("Connection error: {}", e))?; @@ -1505,7 +1622,7 @@ pub async fn save_email_draft( Content-Type: text/html; charset=UTF-8\r\n\ \r\n\ {}", - date, config.from, draft.to, cc_header, draft.subject, message_id, draft.text + date, config.from, draft.to, cc_header, draft.subject, message_id, draft.body ); // Try to save to Drafts folder, fall back to INBOX if not available @@ -1519,6 +1636,7 @@ pub async fn save_email_draft( session .append(&folder, email_content.as_bytes()) + .finish() .map_err(|e| format!("Append draft failed: {}", e))?; session.logout().ok(); @@ -1528,16 +1646,12 @@ pub async fn save_email_draft( // ===== Helper Functions for IMAP Operations ===== -async fn fetch_emails_from_folder(config: &EmailConfig, folder: &str) -> Result, String> { - use native_tls::TlsConnector; - - let tls = TlsConnector::builder() - .build() - .map_err(|e| format!("TLS error: {}", e))?; - +async fn fetch_emails_from_folder( + config: &EmailConfig, + folder: &str, +) -> Result, String> { + // Connect to IMAP (imap 3.0 handles TLS internally) let client = imap::ClientBuilder::new(&config.server, config.port as u16) - .native_tls(&tls) - .map_err(|e| format!("IMAP client error: {}", e))? .connect() .map_err(|e| format!("Connection error: {}", e))?; @@ -1553,9 +1667,12 @@ async fn fetch_emails_from_folder(config: &EmailConfig, folder: &str) -> Result< _ => "INBOX", }; - session.select(folder_name).map_err(|e| format!("Select folder failed: {}", e))?; + session + .select(folder_name) + .map_err(|e| format!("Select folder failed: {}", e))?; - let messages = session.fetch("1:20", "(FLAGS RFC822.HEADER)") + let messages = session + .fetch("1:20", "(FLAGS RFC822.HEADER)") .map_err(|e| format!("Fetch failed: {}", e))?; let mut emails = Vec::new(); @@ -1569,12 +1686,13 @@ async fn fetch_emails_from_folder(config: &EmailConfig, folder: &str) -> Result< let flags = message.flags(); let unread = !flags.iter().any(|f| matches!(f, imap::types::Flag::Seen)); + let preview = subject.chars().take(100).collect(); emails.push(EmailSummary { id: message.message.to_string(), from, subject, date, - preview: subject.chars().take(100).collect(), + preview, unread, }); } @@ -1585,17 +1703,13 @@ async fn fetch_emails_from_folder(config: &EmailConfig, folder: &str) -> Result< Ok(emails) } -async fn get_folder_counts(config: &EmailConfig) -> Result, String> { - use native_tls::TlsConnector; +async fn get_folder_counts( + config: &EmailConfig, +) -> Result, String> { use std::collections::HashMap; - let tls = TlsConnector::builder() - .build() - .map_err(|e| format!("TLS error: {}", e))?; - + // Connect to IMAP (imap 3.0 handles TLS internally) let client = imap::ClientBuilder::new(&config.server, config.port as u16) - .native_tls(&tls) - .map_err(|e| format!("IMAP client error: {}", e))? .connect() .map_err(|e| format!("Connection error: {}", e))?; @@ -1616,15 +1730,8 @@ async fn get_folder_counts(config: &EmailConfig) -> Result Result { - use native_tls::TlsConnector; - - let tls = TlsConnector::builder() - .build() - .map_err(|e| format!("TLS error: {}", e))?; - + // Connect to IMAP (imap 3.0 handles TLS internally) let client = imap::ClientBuilder::new(&config.server, config.port as u16) - .native_tls(&tls) - .map_err(|e| format!("IMAP client error: {}", e))? .connect() .map_err(|e| format!("Connection error: {}", e))?; @@ -1632,21 +1739,29 @@ async fn fetch_email_by_id(config: &EmailConfig, id: &str) -> Result Result Result<(), String> { - use native_tls::TlsConnector; - - let tls = TlsConnector::builder() - .build() - .map_err(|e| format!("TLS error: {}", e))?; - + // Connect to IMAP (imap 3.0 handles TLS internally) let client = imap::ClientBuilder::new(&config.server, config.port as u16) - .native_tls(&tls) - .map_err(|e| format!("IMAP client error: {}", e))? .connect() .map_err(|e| format!("Connection error: {}", e))?; @@ -1684,13 +1792,18 @@ async fn move_email_to_trash(config: &EmailConfig, id: &str) -> Result<(), Strin .login(&config.username, &config.password) .map_err(|e| format!("Login failed: {:?}", e))?; - session.select("INBOX").map_err(|e| format!("Select failed: {}", e))?; + session + .select("INBOX") + .map_err(|e| format!("Select failed: {}", e))?; // Mark as deleted and expunge - session.store(id, "+FLAGS (\\Deleted)") + session + .store(id, "+FLAGS (\\Deleted)") .map_err(|e| format!("Store failed: {}", e))?; - session.expunge().map_err(|e| format!("Expunge failed: {}", e))?; + session + .expunge() + .map_err(|e| format!("Expunge failed: {}", e))?; session.logout().ok(); Ok(()) @@ -1725,21 +1838,22 @@ pub async fn list_emails_htmx( let folder = params.get("folder").unwrap_or(&"inbox".to_string()).clone(); // Get user's email accounts - let user_id = extract_user_from_session(&state).await + let user_id = extract_user_from_session(&state) + .await .map_err(|_| EmailError("Authentication required".to_string()))?; // Get first email account for the user let conn = state.conn.clone(); let account = tokio::task::spawn_blocking(move || { - let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + let mut db_conn = conn + .get() + .map_err(|e| format!("DB connection error: {}", e))?; - diesel::sql_query( - "SELECT * FROM email_accounts WHERE user_id = $1 LIMIT 1" - ) - .bind::(user_id) - .get_result::(&mut db_conn) - .optional() - .map_err(|e| format!("Failed to get email account: {}", e)) + diesel::sql_query("SELECT * FROM email_accounts WHERE user_id = $1 LIMIT 1") + .bind::(user_id) + .get_result::(&mut db_conn) + .optional() + .map_err(|e| format!("Failed to get email account: {}", e)) }) .await .map_err(|e| EmailError(format!("Task join error: {}", e)))? @@ -1750,7 +1864,8 @@ pub async fn list_emails_htmx( r#"

No email account configured

Please add an email account first

-
"#.to_string() + "# + .to_string(), )); }; @@ -1759,8 +1874,10 @@ pub async fn list_emails_htmx( username: account.username.clone(), password: account.password.clone(), server: account.imap_server.clone(), - port: account.imap_port as u32, + port: account.imap_port as u16, from: account.email.clone(), + smtp_server: account.smtp_server.clone(), + smtp_port: account.smtp_port as u16, }; let emails = fetch_emails_from_folder(&config, &folder) @@ -1771,7 +1888,7 @@ pub async fn list_emails_htmx( for (idx, email) in emails.iter().enumerate() { let unread_class = if email.unread { "unread" } else { "" }; html.push_str(&format!( - r#"
@@ -1781,22 +1898,17 @@ pub async fn list_emails_htmx(
{}
{}
- "#, - unread_class, - email.id, - email.from, - email.date, - email.subject, - email.preview + "##, + unread_class, email.id, email.from, email.date, email.subject, email.preview )); } if html.is_empty() { html = format!( - r#"
+ r##"

No emails in {}

This folder is empty

-
"#, +
"##, folder ); } @@ -1809,20 +1921,21 @@ pub async fn list_folders_htmx( State(state): State>, ) -> Result { // Get user's first email account - let user_id = extract_user_from_session(&state).await + let user_id = extract_user_from_session(&state) + .await .map_err(|_| EmailError("Authentication required".to_string()))?; let conn = state.conn.clone(); let account = tokio::task::spawn_blocking(move || { - let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + let mut db_conn = conn + .get() + .map_err(|e| format!("DB connection error: {}", e))?; - diesel::sql_query( - "SELECT * FROM email_accounts WHERE user_id = $1 LIMIT 1" - ) - .bind::(user_id) - .get_result::(&mut db_conn) - .optional() - .map_err(|e| format!("Failed to get email account: {}", e)) + diesel::sql_query("SELECT * FROM email_accounts WHERE user_id = $1 LIMIT 1") + .bind::(user_id) + .get_result::(&mut db_conn) + .optional() + .map_err(|e| format!("Failed to get email account: {}", e)) }) .await .map_err(|e| EmailError(format!("Task join error: {}", e)))? @@ -1830,7 +1943,7 @@ pub async fn list_folders_htmx( if account.is_none() { return Ok(axum::response::Html( - r#""#.to_string() + r#""#.to_string(), )); } @@ -1838,11 +1951,13 @@ pub async fn list_folders_htmx( // Get folder list with counts using IMAP let config = EmailConfig { - username: account.username, - password: account.password, - server: account.imap_server, - port: account.imap_port as u32, - from: account.email, + username: account.username.clone(), + password: account.password.clone(), + server: account.imap_server.clone(), + port: account.imap_port as u16, + from: account.email.clone(), + smtp_server: account.smtp_server.clone(), + smtp_port: account.smtp_port as u16, }; let folder_counts = get_folder_counts(&config).await.unwrap_or_default(); @@ -1854,23 +1969,38 @@ pub async fn list_folders_htmx( ("drafts", "", folder_counts.get("Drafts").unwrap_or(&0)), ("trash", "", folder_counts.get("Trash").unwrap_or(&0)), ] { - let active = if *folder_name == "inbox" { "active" } else { "" }; + let active = if *folder_name == "inbox" { + "active" + } else { + "" + }; let count_badge = if **count > 0 { - format!(r#"{}"#, count) + format!( + r##"{}"##, + count + ) } else { String::new() }; html.push_str(&format!( - r#""#, - active, folder_name, icon, - folder_name.chars().next().unwrap().to_uppercase().collect::() + &folder_name[1..], + "##, + active, + folder_name, + icon, + folder_name + .chars() + .next() + .unwrap() + .to_uppercase() + .collect::() + + &folder_name[1..], count_badge )); } @@ -1882,7 +2012,7 @@ pub async fn list_folders_htmx( pub async fn compose_email_htmx( State(state): State>, ) -> Result { - let html = r#" + let html = r##"

Compose New Email

- "#; + "##; Ok(axum::response::Html(html)) } @@ -1920,20 +2050,21 @@ pub async fn get_email_content_htmx( Path(id): Path, ) -> Result { // Get user's email account - let user_id = extract_user_from_session(&state).await + let user_id = extract_user_from_session(&state) + .await .map_err(|_| EmailError("Authentication required".to_string()))?; let conn = state.conn.clone(); let account = tokio::task::spawn_blocking(move || { - let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + let mut db_conn = conn + .get() + .map_err(|e| format!("DB connection error: {}", e))?; - diesel::sql_query( - "SELECT * FROM email_accounts WHERE user_id = $1 LIMIT 1" - ) - .bind::(user_id) - .get_result::(&mut db_conn) - .optional() - .map_err(|e| format!("Failed to get email account: {}", e)) + diesel::sql_query("SELECT * FROM email_accounts WHERE user_id = $1 LIMIT 1") + .bind::(user_id) + .get_result::(&mut db_conn) + .optional() + .map_err(|e| format!("Failed to get email account: {}", e)) }) .await .map_err(|e| EmailError(format!("Task join error: {}", e)))? @@ -1941,19 +2072,22 @@ pub async fn get_email_content_htmx( let Some(account) = account else { return Ok(axum::response::Html( - r#"
+ r##"

No email account configured

-
"#.to_string() +
"## + .to_string(), )); }; // Fetch email content using IMAP let config = EmailConfig { - username: account.username, - password: account.password, - server: account.imap_server, - port: account.imap_port as u32, + username: account.username.clone(), + password: account.password.clone(), + server: account.imap_server.clone(), + port: account.imap_port as u16, from: account.email.clone(), + smtp_server: account.smtp_server.clone(), + smtp_port: account.smtp_port as u16, }; let email_content = fetch_email_by_id(&config, &id) @@ -1961,7 +2095,7 @@ pub async fn get_email_content_htmx( .map_err(|e| EmailError(format!("Failed to fetch email: {}", e)))?; let html = format!( - r#" + r##"
- "#, - id, id, id, + "##, + id, + id, + id, email_content.subject, email_content.from, email_content.to, @@ -2005,20 +2141,21 @@ pub async fn delete_email_htmx( Path(id): Path, ) -> Result { // Get user's email account - let user_id = extract_user_from_session(&state).await + let user_id = extract_user_from_session(&state) + .await .map_err(|_| EmailError("Authentication required".to_string()))?; let conn = state.conn.clone(); let account = tokio::task::spawn_blocking(move || { - let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?; + let mut db_conn = conn + .get() + .map_err(|e| format!("DB connection error: {}", e))?; - diesel::sql_query( - "SELECT * FROM email_accounts WHERE user_id = $1 LIMIT 1" - ) - .bind::(user_id) - .get_result::(&mut db_conn) - .optional() - .map_err(|e| format!("Failed to get email account: {}", e)) + diesel::sql_query("SELECT * FROM email_accounts WHERE user_id = $1 LIMIT 1") + .bind::(user_id) + .get_result::(&mut db_conn) + .optional() + .map_err(|e| format!("Failed to get email account: {}", e)) }) .await .map_err(|e| EmailError(format!("Task join error: {}", e)))? @@ -2026,11 +2163,13 @@ pub async fn delete_email_htmx( if let Some(account) = account { let config = EmailConfig { - username: account.username, - password: account.password, - server: account.imap_server, - port: account.imap_port as u32, - from: account.email, + username: account.username.clone(), + password: account.password.clone(), + server: account.imap_server.clone(), + port: account.imap_port as u16, + from: account.email.clone(), + smtp_server: account.smtp_server.clone(), + smtp_port: account.smtp_port as u16, }; // Move email to trash folder using IMAP @@ -2091,7 +2230,10 @@ pub async fn track_click( State(state): State>, Path((campaign_id, email)): Path<(String, String)>, ) -> Result>, EmailError> { - info!("Tracking click for campaign {} email {}", campaign_id, email); + info!( + "Tracking click for campaign {} email {}", + campaign_id, email + ); Ok(Json(ApiResponse { success: true, @@ -2137,11 +2279,10 @@ struct EmailAccountRow { // ===== HTMX UI Endpoint Handlers ===== /// List email labels (HTMX HTML response) -pub async fn list_labels_htmx( - State(_state): State>, -) -> impl IntoResponse { +pub async fn list_labels_htmx(State(_state): State>) -> impl IntoResponse { // Return default labels as HTML for HTMX - axum::response::Html(r#" + axum::response::Html( + r#"
Important @@ -2158,14 +2299,15 @@ pub async fn list_labels_htmx( Finance
- "#.to_string()) + "# + .to_string(), + ) } /// List email templates (HTMX HTML response) -pub async fn list_templates_htmx( - State(_state): State>, -) -> impl IntoResponse { - axum::response::Html(r#" +pub async fn list_templates_htmx(State(_state): State>) -> impl IntoResponse { + axum::response::Html( + r#"

Welcome Email

Standard welcome message for new contacts

@@ -2181,14 +2323,15 @@ pub async fn list_templates_htmx(

Click a template to use it

- "#.to_string()) + "# + .to_string(), + ) } /// List email signatures (HTMX HTML response) -pub async fn list_signatures_htmx( - State(_state): State>, -) -> impl IntoResponse { - axum::response::Html(r#" +pub async fn list_signatures_htmx(State(_state): State>) -> impl IntoResponse { + axum::response::Html( + r#"

Default Signature

Best regards,
Your Name

@@ -2200,14 +2343,15 @@ pub async fn list_signatures_htmx(

Click a signature to insert it

- "#.to_string()) + "# + .to_string(), + ) } /// List email rules (HTMX HTML response) -pub async fn list_rules_htmx( - State(_state): State>, -) -> impl IntoResponse { - axum::response::Html(r#" +pub async fn list_rules_htmx(State(_state): State>) -> impl IntoResponse { + axum::response::Html( + r#"
Auto-archive newsletters @@ -2231,7 +2375,9 @@ pub async fn list_rules_htmx( - "#.to_string()) + "# + .to_string(), + ) } /// Search emails (HTMX HTML response) @@ -2242,23 +2388,29 @@ pub async fn search_emails_htmx( let query = params.get("q").map(|s| s.as_str()).unwrap_or(""); if query.is_empty() { - return axum::response::Html(r#" + return axum::response::Html( + r#"

Enter a search term to find emails

- "#.to_string()); + "# + .to_string(), + ); } let search_term = format!("%{}%", query.to_lowercase()); - let conn = match state.conn.get() { + let mut conn = match state.conn.get() { Ok(c) => c, Err(_) => { - return axum::response::Html(r#" + return axum::response::Html( + r#"

Database connection error

- "#.to_string()); + "# + .to_string(), + ); } }; @@ -2272,20 +2424,20 @@ pub async fn search_emails_htmx( LIMIT 50" ); - let results: Vec<(String, String, String, String, Option, DateTime)> = - match diesel::sql_query(&search_query) - .bind::(&search_term) - .load(&conn) - { - Ok(r) => r.into_iter().map(|row: (String, String, String, String, Option, DateTime)| row).collect(), - Err(e) => { - warn!("Email search query failed: {}", e); - Vec::new() - } - }; + let results: Vec = match diesel::sql_query(&search_query) + .bind::(&search_term) + .load::(&mut conn) + { + Ok(r) => r, + Err(e) => { + warn!("Email search query failed: {}", e); + Vec::new() + } + }; if results.is_empty() { - return axum::response::Html(format!(r#" + return axum::response::Html(format!( + r##"
@@ -2294,29 +2446,36 @@ pub async fn search_emails_htmx(

No results for "{}"

Try different keywords or check your spelling.

- "#, query)); + "##, + query + )); } - let mut html = String::from(r#"
"#); - html.push_str(&format!(r#"
Found {} result(s) for "{}"
"#, results.len(), query)); + let mut html = String::from(r##"
"##); + html.push_str(&format!( + r##"
Found {} result(s) for "{}"
"##, + results.len(), + query + )); - for (id, subject, from, _to, body, date) in results { - let preview = body + for row in results { + let preview = row + .body_text .as_deref() .unwrap_or("") .chars() .take(100) .collect::(); - let formatted_date = date.format("%b %d, %Y").to_string(); + let formatted_date = row.received_at.format("%b %d, %Y").to_string(); - html.push_str(&format!(r#" + html.push_str(&format!(r##" - "#, id, from, subject, preview, formatted_date)); + "##, row.id, row.from_address, row.subject, preview, formatted_date)); } html.push_str("
"); @@ -2331,9 +2490,12 @@ pub async fn save_auto_responder( info!("Saving auto-responder settings: {:?}", form); // In production, save to database - axum::response::Html(r#" + axum::response::Html( + r#"
Auto-responder settings saved successfully!
- "#.to_string()) + "# + .to_string(), + ) } diff --git a/src/email/vectordb.rs b/src/email/vectordb.rs index 670e66926..c39d0ce6b 100644 --- a/src/email/vectordb.rs +++ b/src/email/vectordb.rs @@ -3,6 +3,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::Arc; +use tokio::fs; use uuid::Uuid; @@ -87,7 +88,7 @@ impl UserEmailVectorDB { if !exists { // Create collection for email embeddings (1536 dimensions for OpenAI embeddings) client - .create_collection(&CreateCollection { + .create_collection(CreateCollection { collection_name: self.collection_name.clone(), vectors_config: Some(VectorsConfig { config: Some(Config::Params(VectorParams { @@ -329,7 +330,7 @@ impl UserEmailVectorDB { // Recreate empty collection client - .create_collection(&CreateCollection { + .create_collection(CreateCollection { collection_name: self.collection_name.clone(), vectors_config: Some(VectorsConfig { config: Some(Config::Params(VectorParams { diff --git a/src/instagram/mod.rs b/src/instagram/mod.rs new file mode 100644 index 000000000..967213e58 --- /dev/null +++ b/src/instagram/mod.rs @@ -0,0 +1,96 @@ +pub use crate::core::bot::channels::instagram::*; + +use crate::shared::state::AppState; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct WebhookVerifyQuery { + #[serde(rename = "hub.mode")] + pub mode: Option, + #[serde(rename = "hub.verify_token")] + pub verify_token: Option, + #[serde(rename = "hub.challenge")] + pub challenge: Option, +} + +pub fn configure() -> Router> { + Router::new() + .route( + "/api/instagram/webhook", + get(verify_webhook).post(handle_webhook), + ) + .route("/api/instagram/send", post(send_message)) +} + +async fn verify_webhook(Query(query): Query) -> impl IntoResponse { + let adapter = InstagramAdapter::new(); + + match ( + query.mode.as_deref(), + query.verify_token.as_deref(), + query.challenge, + ) { + (Some(mode), Some(token), Some(challenge)) => { + if let Some(response) = adapter + .handle_webhook_verification(mode, token, &challenge) + .await + { + (StatusCode::OK, response) + } else { + (StatusCode::FORBIDDEN, "Verification failed".to_string()) + } + } + _ => (StatusCode::BAD_REQUEST, "Missing parameters".to_string()), + } +} + +async fn handle_webhook( + State(_state): State>, + Json(payload): Json, +) -> impl IntoResponse { + for entry in payload.entry { + if let Some(messaging_list) = entry.messaging { + for messaging in messaging_list { + if let Some(message) = messaging.message { + if let Some(text) = message.text { + log::info!( + "Instagram message from={} text={}", + messaging.sender.id, + text + ); + } + } + } + } + } + + StatusCode::OK +} + +async fn send_message( + State(_state): State>, + Json(request): Json, +) -> impl IntoResponse { + let adapter = InstagramAdapter::new(); + let recipient = request.get("to").and_then(|v| v.as_str()).unwrap_or(""); + let message = request + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + match adapter.send_instagram_message(recipient, message).await { + Ok(_) => (StatusCode::OK, Json(serde_json::json!({"success": true}))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"success": false, "error": e.to_string()})), + ), + } +} diff --git a/src/meet/mod.rs b/src/meet/mod.rs index 548e5a0e2..88eb8df89 100644 --- a/src/meet/mod.rs +++ b/src/meet/mod.rs @@ -32,15 +32,15 @@ pub fn configure() -> Router> { .route("/api/meet/participants", get(all_participants)) .route("/api/meet/scheduled", get(scheduled_meetings)) .route( - ApiUrls::MEET_ROOM_BY_ID.replace(":id", "{room_id}"), + &ApiUrls::MEET_ROOM_BY_ID.replace(":id", "{room_id}"), get(get_room), ) .route( - ApiUrls::MEET_JOIN.replace(":id", "{room_id}"), + &ApiUrls::MEET_JOIN.replace(":id", "{room_id}"), post(join_room), ) .route( - ApiUrls::MEET_TRANSCRIPTION.replace(":id", "{room_id}"), + &ApiUrls::MEET_TRANSCRIPTION.replace(":id", "{room_id}"), post(start_transcription), ) .route(ApiUrls::MEET_TOKEN, post(get_meeting_token)) diff --git a/src/meet/service.rs b/src/meet/service.rs index 36d5a1019..87cae3fb7 100644 --- a/src/meet/service.rs +++ b/src/meet/service.rs @@ -3,6 +3,7 @@ use crate::shared::state::AppState; use anyhow::Result; use async_trait::async_trait; use axum::extract::ws::{Message, WebSocket}; +use botlib::MessageType; use futures::{SinkExt, StreamExt}; use log::{info, trace, warn}; use serde::{Deserialize, Serialize}; @@ -416,7 +417,7 @@ impl MeetingService { session_id: room_id.to_string(), channel: "meeting".to_string(), content: text.to_string(), - message_type: 0, + message_type: MessageType::USER, media_url: None, timestamp: chrono::Utc::now(), context_name: None, @@ -481,7 +482,7 @@ impl MeetingService { session_id: message.session_id, channel: "meeting".to_string(), content: format!("Processing: {}", message.content), - message_type: 1, + message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, suggestions: Vec::new(), diff --git a/src/msteams/mod.rs b/src/msteams/mod.rs new file mode 100644 index 000000000..e726a9892 --- /dev/null +++ b/src/msteams/mod.rs @@ -0,0 +1,137 @@ +pub use crate::core::bot::channels::teams::TeamsAdapter; + +use crate::core::bot::channels::ChannelAdapter; +use crate::shared::state::AppState; +use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Json, Router}; +use diesel::prelude::*; +use serde::Deserialize; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct TeamsActivity { + #[serde(rename = "type")] + pub activity_type: String, + pub id: String, + pub timestamp: Option, + #[serde(rename = "serviceUrl")] + pub service_url: Option, + #[serde(rename = "channelId")] + pub channel_id: Option, + pub from: TeamsChannelAccount, + pub conversation: TeamsConversationAccount, + pub recipient: Option, + pub text: Option, + pub value: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TeamsChannelAccount { + pub id: String, + pub name: Option, + #[serde(rename = "aadObjectId")] + pub aad_object_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TeamsConversationAccount { + pub id: String, + #[serde(rename = "conversationType")] + pub conversation_type: Option, + #[serde(rename = "tenantId")] + pub tenant_id: Option, + pub name: Option, +} + +pub fn configure() -> Router> { + Router::new() + .route("/api/msteams/messages", post(handle_incoming)) + .route("/api/msteams/send", post(send_message)) +} + +async fn handle_incoming( + State(state): State>, + Json(activity): Json, +) -> impl IntoResponse { + match activity.activity_type.as_str() { + "message" => { + if let Some(text) = &activity.text { + log::info!( + "Teams message from={} conversation={} text={}", + activity.from.id, + activity.conversation.id, + text + ); + } + (StatusCode::OK, Json(serde_json::json!({}))) + } + "conversationUpdate" => { + log::info!("Teams conversation update id={}", activity.id); + (StatusCode::OK, Json(serde_json::json!({}))) + } + "invoke" => { + log::info!("Teams invoke id={}", activity.id); + (StatusCode::OK, Json(serde_json::json!({"status": 200}))) + } + _ => (StatusCode::OK, Json(serde_json::json!({}))), + } +} + +async fn send_message( + State(state): State>, + Json(request): Json, +) -> impl IntoResponse { + let bot_id = get_default_bot_id(&state).await; + let adapter = TeamsAdapter::new(state.conn.clone(), bot_id); + + let conversation_id = request + .get("conversation_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let message = request + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let response = crate::shared::models::BotResponse { + bot_id: bot_id.to_string(), + session_id: conversation_id.to_string(), + user_id: conversation_id.to_string(), + channel: "teams".to_string(), + content: message.to_string(), + message_type: botlib::MessageType::BOT_RESPONSE, + stream_token: None, + is_complete: true, + suggestions: vec![], + context_name: None, + context_length: 0, + context_max_length: 0, + }; + + match adapter.send_message(response).await { + Ok(_) => (StatusCode::OK, Json(serde_json::json!({"success": true}))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"success": false, "error": e.to_string()})), + ), + } +} + +async fn get_default_bot_id(state: &Arc) -> Uuid { + let conn = state.conn.clone(); + + tokio::task::spawn_blocking(move || { + let mut db_conn = conn.get().ok()?; + use crate::shared::models::schema::bots; + use diesel::prelude::*; + + bots::table + .filter(bots::is_active.eq(true)) + .select(bots::id) + .first::(&mut db_conn) + .ok() + }) + .await + .ok() + .flatten() + .unwrap_or_else(Uuid::nil) +} diff --git a/src/vector-db/hybrid_search.rs b/src/vector-db/hybrid_search.rs index 79762994e..b562ddc14 100644 --- a/src/vector-db/hybrid_search.rs +++ b/src/vector-db/hybrid_search.rs @@ -202,7 +202,6 @@ pub struct BM25Index { enabled: bool, } -#[cfg(not(feature = "vectordb"))] impl BM25Index { pub fn new() -> Self { Self { @@ -351,7 +350,6 @@ pub struct BM25Stats { pub enabled: bool, } - /// Document entry in the store #[derive(Debug, Clone)] struct DocumentEntry { @@ -757,7 +755,6 @@ pub struct HybridSearchStats { pub config: HybridSearchConfig, } - /// Query decomposition for complex questions pub struct QueryDecomposer { llm_endpoint: String, @@ -841,7 +838,6 @@ impl QueryDecomposer { } } - #[cfg(test)] mod tests { use super::*; diff --git a/src/vector-db/mod.rs b/src/vector-db/mod.rs index cc77eb0cb..9cb47cb5c 100644 --- a/src/vector-db/mod.rs +++ b/src/vector-db/mod.rs @@ -39,12 +39,6 @@ pub use hybrid_search::{ SearchMethod, SearchResult, }; -// Tantivy BM25 index (when vectordb feature enabled) -#[cfg(feature = "vectordb")] -pub use hybrid_search::TantivyBM25Index; - -// Fallback BM25 index (when vectordb feature NOT enabled) -#[cfg(not(feature = "vectordb"))] pub use hybrid_search::BM25Index; pub use vectordb_indexer::{IndexingStats, IndexingStatus, VectorDBIndexer}; diff --git a/src/vector-db/vectordb_indexer.rs b/src/vector-db/vectordb_indexer.rs index 8a249c6c0..67eedd10c 100644 --- a/src/vector-db/vectordb_indexer.rs +++ b/src/vector-db/vectordb_indexer.rs @@ -39,8 +39,15 @@ impl UserWorkspace { .join(self.bot_id.to_string()) .join(self.user_id.to_string()) } -} + fn email_vectordb(&self) -> String { + format!("email_{}_{}", self.bot_id, self.user_id) + } + + fn drive_vectordb(&self) -> String { + format!("drive_{}_{}", self.bot_id, self.user_id) + } +} /// Indexing job status #[derive(Debug, Clone, PartialEq)] @@ -455,11 +462,11 @@ impl VectorDBIndexer { user_id: Uuid, account_id: &str, ) -> Result, Box> { - let pool = self.pool.clone(); + let pool = self.db_pool.clone(); let account_id = account_id.to_string(); let results = tokio::task::spawn_blocking(move || { - let conn = pool.get()?; + let mut conn = pool.get()?; let query = r#" SELECT e.id, e.message_id, e.subject, e.from_address, e.to_addresses, @@ -486,7 +493,17 @@ impl VectorDBIndexer { )> = diesel::sql_query(query) .bind::(user_id) .bind::(&account_id) - .load(&conn) + .load::<( + Uuid, + String, + String, + String, + String, + Option, + Option, + DateTime, + String, + )>(&mut conn) .unwrap_or_default(); let emails: Vec = rows @@ -505,18 +522,16 @@ impl VectorDBIndexer { )| { EmailDocument { id: id.to_string(), - message_id, - subject, - from_address: from, - to_addresses: to.split(',').map(|s| s.trim().to_string()).collect(), - cc_addresses: Vec::new(), - body_text: body_text.unwrap_or_default(), - body_html, - received_at, - folder, - labels: Vec::new(), - has_attachments: false, account_id: account_id.clone(), + from_email: from.clone(), + from_name: from, + to_email: to, + subject, + body_text: body_text.unwrap_or_default(), + date: received_at, + folder, + has_attachments: false, + thread_id: None, } }, ) @@ -533,10 +548,10 @@ impl VectorDBIndexer { &self, user_id: Uuid, ) -> Result, Box> { - let pool = self.pool.clone(); + let pool = self.db_pool.clone(); let results = tokio::task::spawn_blocking(move || { - let conn = pool.get()?; + let mut conn = pool.get()?; let query = r#" SELECT f.id, f.file_path, f.file_name, f.file_type, f.file_size, @@ -562,7 +577,7 @@ impl VectorDBIndexer { DateTime, )> = diesel::sql_query(query) .bind::(user_id) - .load(&conn) + .load::<(Uuid, String, String, i64, DateTime)>(&mut conn) .unwrap_or_default(); let files: Vec = rows diff --git a/src/whatsapp/mod.rs b/src/whatsapp/mod.rs index 9f0e4254d..758b1cbef 100644 --- a/src/whatsapp/mod.rs +++ b/src/whatsapp/mod.rs @@ -17,11 +17,11 @@ //! whatsapp-business-account-id,your_business_account_id //! ``` +use crate::bot::BotOrchestrator; use crate::core::bot::channels::whatsapp::WhatsAppAdapter; use crate::core::bot::channels::ChannelAdapter; -use crate::core::bot::orchestrator::BotOrchestrator; use crate::shared::models::{BotResponse, UserMessage, UserSession}; -use crate::shared::state::AppState; +use crate::shared::state::{AppState, AttendantNotification}; use axum::{ extract::{Query, State}, http::StatusCode, @@ -29,6 +29,7 @@ use axum::{ routing::{get, post}, Json, Router, }; +use botlib::MessageType; use chrono::Utc; use diesel::prelude::*; use log::{debug, error, info, warn}; @@ -41,22 +42,6 @@ use uuid::Uuid; /// WebSocket broadcast channel for attendant notifications pub type AttendantBroadcast = broadcast::Sender; -/// Notification sent to attendants via WebSocket -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AttendantNotification { - #[serde(rename = "type")] - pub notification_type: String, - pub session_id: String, - pub user_id: String, - pub user_name: Option, - pub user_phone: Option, - pub channel: String, - pub content: String, - pub timestamp: String, - pub assigned_to: Option, - pub priority: i32, -} - /// WhatsApp webhook verification query parameters #[derive(Debug, Deserialize)] pub struct WebhookVerifyQuery { @@ -327,11 +312,13 @@ async fn process_incoming_message( user_id: phone.clone(), channel: "whatsapp".to_string(), content: response, - message_type: crate::shared::models::message_types::MessageType::BOT_RESPONSE, + message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, suggestions: vec![], context_name: None, + context_length: 0, + context_max_length: 0, }; if let Err(e) = adapter.send_message(bot_response).await { error!("Failed to send attendant command response: {}", e); @@ -374,17 +361,29 @@ async fn process_attendant_command( // Get current session the attendant is handling (if any) let current_session = get_attendant_active_session(state, phone).await; - // Process the command using llm_assist module - match crate::attendance::llm_assist::process_attendant_command( - state, - phone, - content, - current_session, - ) - .await + // Process the command using llm_assist module (only if attendance feature is enabled) + #[cfg(feature = "attendance")] { - Ok(response) => Some(response), - Err(e) => Some(format!("❌ Error: {}", e)), + match crate::attendance::llm_assist::process_attendant_command( + state, + phone, + content, + current_session, + ) + .await + { + Ok(response) => return Some(response), + Err(e) => return Some(format!("❌ Error: {}", e)), + } + } + + #[cfg(not(feature = "attendance"))] + { + let _ = current_session; // Suppress unused warning + Some(format!( + "Attendance module not enabled. Message: {}", + content + )) } } @@ -568,7 +567,7 @@ async fn find_or_create_session( // Check if session is recent (within 24 hours) let age = Utc::now() - session.updated_at; if age.num_hours() < 24 { - return Ok((session, false)); + return Ok::<(UserSession, bool), String>((session, false)); } } @@ -597,7 +596,7 @@ async fn find_or_create_session( .first(&mut db_conn) .map_err(|e| format!("Load session error: {}", e))?; - Ok((new_session, true)) + Ok::<(UserSession, bool), String>((new_session, true)) }) .await .map_err(|e| format!("Task error: {}", e))??; @@ -623,9 +622,15 @@ async fn route_to_bot( info!("Routing WhatsApp message to bot for session {}", session.id); let user_message = UserMessage { - session_id: session.id.to_string(), - content: content.to_string(), + bot_id: session.bot_id.to_string(), user_id: session.user_id.to_string(), + session_id: session.id.to_string(), + channel: "whatsapp".to_string(), + content: content.to_string(), + message_type: MessageType::USER, + media_url: None, + timestamp: Utc::now(), + context_name: None, }; // Get WhatsApp adapter for sending responses @@ -645,6 +650,7 @@ async fn route_to_bot( .unwrap_or("") .to_string(); + let phone_for_error = phone.clone(); // Clone for use in error handling after move let adapter_for_send = WhatsAppAdapter::new(state.conn.clone(), session.bot_id); tokio::spawn(async move { @@ -662,26 +668,25 @@ async fn route_to_bot( } }); - // Process message - if let Err(e) = orchestrator - .process_message(user_message, Some(tx), is_new) - .await - { + // Process message using stream_response + if let Err(e) = orchestrator.stream_response(user_message, tx).await { error!("Bot processing error: {}", e); // Send error message back let error_response = BotResponse { bot_id: session.bot_id.to_string(), session_id: session.id.to_string(), - user_id: phone.clone(), + user_id: phone_for_error.clone(), channel: "whatsapp".to_string(), content: "Sorry, I encountered an error processing your message. Please try again." .to_string(), - message_type: crate::shared::models::message_types::MessageType::BOT_RESPONSE, + message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, suggestions: vec![], context_name: None, + context_length: 0, + context_max_length: 0, }; if let Err(e) = adapter.send_message(error_response).await { @@ -759,6 +764,7 @@ async fn save_message_to_history( ) -> Result<(), Box> { let conn = state.conn.clone(); let session_id = session.id; + let user_id = session.user_id; // Get the actual user_id from the session let content_clone = content.to_string(); let sender_clone = sender.to_string(); @@ -771,8 +777,11 @@ async fn save_message_to_history( .values(( message_history::id.eq(Uuid::new_v4()), message_history::session_id.eq(session_id), - message_history::role.eq(sender_clone), - message_history::content.eq(content_clone), + message_history::user_id.eq(user_id), // User associated with the message (has mobile field) + message_history::role.eq(if sender_clone == "user" { 1 } else { 2 }), + message_history::content_encrypted.eq(content_clone), + message_history::message_type.eq(1), + message_history::message_index.eq(0i64), message_history::created_at.eq(diesel::dsl::now), )) .execute(&mut db_conn) @@ -853,11 +862,13 @@ pub async fn send_message( user_id: request.to.clone(), channel: "whatsapp".to_string(), content: request.message.clone(), - message_type: crate::shared::models::message_types::MessageType::EXTERNAL, + message_type: MessageType::EXTERNAL, stream_token: None, is_complete: true, suggestions: vec![], context_name: None, + context_length: 0, + context_max_length: 0, }; match adapter.send_message(response).await { @@ -975,11 +986,13 @@ pub async fn attendant_respond( user_id: recipient.to_string(), channel: "whatsapp".to_string(), content: request.message.clone(), - message_type: crate::shared::models::message_types::MessageType::BOT_RESPONSE, + message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, suggestions: vec![], context_name: None, + context_length: 0, + context_max_length: 0, }; match adapter.send_message(response).await { @@ -1033,15 +1046,15 @@ pub async fn attendant_respond( async fn get_verify_token(_state: &Arc) -> String { // Get verify token from Vault - stored at gbo/whatsapp use crate::core::secrets::SecretsManager; - - match SecretsManager::new() { + + match SecretsManager::from_env() { Ok(secrets) => { - match secrets.get("gbo/whatsapp", "verify_token").await { + match secrets.get_value("gbo/whatsapp", "verify_token").await { Ok(token) => token, - Err(_) => "webhook_verify".to_string() // Default for initial setup + Err(_) => "webhook_verify".to_string(), // Default for initial setup } } - Err(_) => "webhook_verify".to_string() // Default if Vault not configured + Err(_) => "webhook_verify".to_string(), // Default if Vault not configured } }