From a41ff7a7d4ae5eda5df53a482a924e715b236a1e Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Sun, 30 Nov 2025 11:09:16 -0300 Subject: [PATCH] Add documentation and core BASIC language functions - Add SET_SCHEDULE.md and TEMPLATE_VARIABLES.md documentation - Implement array functions (CONTAINS, PUSH/POP, SLICE, SORT, UNIQUE) - Implement math functions module structure - Implement datetime functions module structure - Implement validation functions (ISNULL, ISEMPTY, VAL, STR, TYPEOF) - Implement error handling functions (THROW, ERROR, ASSERT) - Add CRM lead scoring keywords (SCORE_LEAD, AI_SCORE_LEAD) - Add messaging keywords (SEND_TEMPLATE, CREATE_TEMPLATE) --- docs/SET_SCHEDULE.md | 230 +++++++ docs/TEMPLATE_VARIABLES.md | 241 ++++++++ src/basic/keywords/arrays/contains.rs | 192 ++++++ src/basic/keywords/arrays/mod.rs | 273 +++++++++ src/basic/keywords/arrays/push_pop.rs | 167 +++++ src/basic/keywords/arrays/slice.rs | 204 +++++++ src/basic/keywords/arrays/sort.rs | 148 +++++ src/basic/keywords/arrays/unique.rs | 142 +++++ src/basic/keywords/core_functions.rs | 136 +++++ src/basic/keywords/crm/mod.rs | 17 + src/basic/keywords/crm/score_lead.rs | 519 ++++++++++++++++ src/basic/keywords/errors/mod.rs | 194 ++++++ src/basic/keywords/errors/throw.rs | 39 ++ src/basic/keywords/lead_scoring.rs | 99 +++ src/basic/keywords/messaging/mod.rs | 16 + src/basic/keywords/messaging/send_template.rs | 576 ++++++++++++++++++ src/basic/keywords/mod.rs | 8 + src/basic/keywords/on_form_submit.rs | 504 +++++++++++++++ src/basic/keywords/send_template.rs | 124 ++++ src/basic/keywords/social_media.rs | 116 ++++ src/basic/keywords/validation/isempty.rs | 141 +++++ src/basic/keywords/validation/isnull.rs | 41 ++ src/basic/keywords/validation/mod.rs | 30 + src/basic/keywords/validation/nvl_iif.rs | 181 ++++++ src/basic/keywords/validation/str_val.rs | 149 +++++ src/basic/keywords/validation/typeof_check.rs | 161 +++++ .../campaigns/lead-nurture-campaign.bas | 350 +++-------- .../campaigns/welcome-campaign.bas | 70 ++- 28 files changed, 4811 insertions(+), 257 deletions(-) create mode 100644 docs/SET_SCHEDULE.md create mode 100644 docs/TEMPLATE_VARIABLES.md create mode 100644 src/basic/keywords/arrays/contains.rs create mode 100644 src/basic/keywords/arrays/mod.rs create mode 100644 src/basic/keywords/arrays/push_pop.rs create mode 100644 src/basic/keywords/arrays/slice.rs create mode 100644 src/basic/keywords/arrays/sort.rs create mode 100644 src/basic/keywords/arrays/unique.rs create mode 100644 src/basic/keywords/core_functions.rs create mode 100644 src/basic/keywords/crm/mod.rs create mode 100644 src/basic/keywords/crm/score_lead.rs create mode 100644 src/basic/keywords/errors/mod.rs create mode 100644 src/basic/keywords/errors/throw.rs create mode 100644 src/basic/keywords/lead_scoring.rs create mode 100644 src/basic/keywords/messaging/mod.rs create mode 100644 src/basic/keywords/messaging/send_template.rs create mode 100644 src/basic/keywords/on_form_submit.rs create mode 100644 src/basic/keywords/send_template.rs create mode 100644 src/basic/keywords/social_media.rs create mode 100644 src/basic/keywords/validation/isempty.rs create mode 100644 src/basic/keywords/validation/isnull.rs create mode 100644 src/basic/keywords/validation/mod.rs create mode 100644 src/basic/keywords/validation/nvl_iif.rs create mode 100644 src/basic/keywords/validation/str_val.rs create mode 100644 src/basic/keywords/validation/typeof_check.rs diff --git a/docs/SET_SCHEDULE.md b/docs/SET_SCHEDULE.md new file mode 100644 index 000000000..9af11e1d9 --- /dev/null +++ b/docs/SET_SCHEDULE.md @@ -0,0 +1,230 @@ +# SET SCHEDULE Keyword Reference + +> Documentation for scheduling scripts and automations in General Bots + +## Overview + +SET SCHEDULE has two distinct usages: + +1. **File-level scheduler**: When placed at the top of a `.bas` file, defines when the script runs automatically +2. **Runtime scheduling**: When used within code, schedules another script to run at a specific time + +## Usage 1: File-Level Scheduler + +When SET SCHEDULE appears at the top of a file (before any executable code), it registers the entire script to run on a cron schedule. + +### Syntax + +```basic +SET SCHEDULE "cron_expression" + +' Rest of script follows +TALK "This runs on schedule" +``` + +### Examples + +```basic +' Run every day at 9 AM +SET SCHEDULE "0 9 * * *" + +TALK "Good morning! Running daily report..." +stats = AGGREGATE "sales", "SUM", "amount", "date = TODAY()" +SEND TEMPLATE "daily-report", "email", "team@company.com", #{total: stats} +``` + +```basic +' Run every Monday at 10 AM +SET SCHEDULE "0 10 * * 1" + +TALK "Weekly summary starting..." +' Generate weekly report +``` + +```basic +' Run first day of every month at midnight +SET SCHEDULE "0 0 1 * *" + +TALK "Monthly billing cycle..." +' Process monthly billing +``` + +### Cron Expression Reference + +``` +┌───────────── minute (0-59) +│ ┌───────────── hour (0-23) +│ │ ┌───────────── day of month (1-31) +│ │ │ ┌───────────── month (1-12) +│ │ │ │ ┌───────────── day of week (0-6, Sunday=0) +│ │ │ │ │ +* * * * * +``` + +| Expression | Description | +|------------|-------------| +| `0 9 * * *` | Every day at 9:00 AM | +| `0 9 * * 1-5` | Weekdays at 9:00 AM | +| `0 */2 * * *` | Every 2 hours | +| `30 8 * * 1` | Mondays at 8:30 AM | +| `0 0 1 * *` | First of each month at midnight | +| `0 12 * * 0` | Sundays at noon | +| `*/15 * * * *` | Every 15 minutes | +| `0 9,17 * * *` | At 9 AM and 5 PM daily | + +## Usage 2: Runtime Scheduling + +When used within code (not at file top), schedules another script to run at a specified time. + +### Syntax + +```basic +' Schedule with cron + date +SET SCHEDULE "cron_expression" date, "script.bas" + +' Schedule for specific date/time +SET SCHEDULE datetime, "script.bas" +``` + +### Examples + +```basic +' Schedule script to run in 3 days at 9 AM +SET SCHEDULE "0 9 * * *" DATEADD(TODAY(), 3, "day"), "followup-email.bas" +``` + +```basic +' Schedule for specific datetime +SET SCHEDULE "2025-02-15 10:00", "product-launch.bas" +``` + +```basic +' Schedule relative to now +SET SCHEDULE DATEADD(NOW(), 1, "hour"), "reminder.bas" +SET SCHEDULE DATEADD(NOW(), 30, "minute"), "check-status.bas" +``` + +```basic +' Campaign scheduling example +ON FORM SUBMIT "signup" + ' Welcome email immediately + SEND TEMPLATE "welcome", "email", fields.email, #{name: fields.name} + + ' Schedule follow-up sequence + SET SCHEDULE DATEADD(NOW(), 2, "day"), "nurture-day-2.bas" + SET SCHEDULE DATEADD(NOW(), 5, "day"), "nurture-day-5.bas" + SET SCHEDULE DATEADD(NOW(), 14, "day"), "nurture-day-14.bas" +END ON +``` + +## Passing Data to Scheduled Scripts + +Use SET BOT MEMORY to pass data to scheduled scripts: + +```basic +' In the scheduling script +SET BOT MEMORY "scheduled_lead_" + lead_id, lead_email +SET SCHEDULE DATEADD(NOW(), 7, "day"), "followup.bas" + +' In followup.bas +PARAM lead_id AS string +lead_email = GET BOT MEMORY "scheduled_lead_" + lead_id +SEND TEMPLATE "followup", "email", lead_email, #{id: lead_id} +``` + +## Campaign Drip Sequence Example + +```basic +' welcome-sequence.bas - File-level scheduler not used here +' This is triggered by form submission + +PARAM email AS string +PARAM name AS string + +DESCRIPTION "Start welcome email sequence" + +' Day 0: Immediate welcome +WITH vars + .name = name + .date = TODAY() +END WITH + +SEND TEMPLATE "welcome-1", "email", email, vars + +' Store lead info for scheduled scripts +SET BOT MEMORY "welcome_" + email + "_name", name + +' Schedule remaining emails +SET SCHEDULE DATEADD(NOW(), 2, "day"), "welcome-day-2.bas" +SET SCHEDULE DATEADD(NOW(), 5, "day"), "welcome-day-5.bas" +SET SCHEDULE DATEADD(NOW(), 7, "day"), "welcome-day-7.bas" + +TALK "Welcome sequence started for " + email +``` + +## Daily Report Example + +```basic +' daily-sales-report.bas +SET SCHEDULE "0 18 * * 1-5" + +' This runs every weekday at 6 PM + +today_sales = AGGREGATE "orders", "SUM", "total", "date = TODAY()" +order_count = AGGREGATE "orders", "COUNT", "id", "date = TODAY()" +avg_order = IIF(order_count > 0, today_sales / order_count, 0) + +WITH report + .date = TODAY() + .total_sales = today_sales + .order_count = order_count + .average_order = ROUND(avg_order, 2) +END WITH + +SEND TEMPLATE "daily-sales", "email", "sales@company.com", report + +TALK "Daily report sent: $" + today_sales +``` + +## Canceling Scheduled Tasks + +Scheduled tasks are stored in `system_automations` table. To cancel: + +```basic +' Remove scheduled automation +DELETE "system_automations", "param = 'followup.bas' AND bot_id = '" + bot_id + "'" +``` + +## Best Practices + +1. **Use descriptive script names**: `welcome-day-2.bas` not `email2.bas` +2. **Store context in BOT MEMORY**: Pass lead ID, not entire objects +3. **Check for unsubscribes**: Before sending scheduled emails +4. **Handle errors gracefully**: Scheduled scripts should not crash +5. **Log execution**: Track when scheduled scripts run +6. **Use appropriate times**: Consider timezone and recipient preferences +7. **Avoid overlapping schedules**: Don't schedule too many scripts at same time + +## Database Schema + +Scheduled tasks are stored in `system_automations`: + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUID | Automation ID | +| `bot_id` | UUID | Bot owner | +| `kind` | INT | Trigger type (Scheduled = 1) | +| `schedule` | TEXT | Cron expression | +| `param` | TEXT | Script filename | +| `is_active` | BOOL | Active status | +| `last_triggered` | TIMESTAMP | Last execution time | + +## Comparison + +| Feature | File-Level | Runtime | +|---------|------------|---------| +| Location | Top of file | Anywhere in code | +| Purpose | Recurring execution | One-time scheduling | +| Timing | Cron-based recurring | Specific date/time | +| Script | Self (current file) | Other script | +| Use case | Reports, cleanup | Drip campaigns, reminders | \ No newline at end of file diff --git a/docs/TEMPLATE_VARIABLES.md b/docs/TEMPLATE_VARIABLES.md new file mode 100644 index 000000000..4eb1408de --- /dev/null +++ b/docs/TEMPLATE_VARIABLES.md @@ -0,0 +1,241 @@ +# Template Variables Reference + +> Documentation for SEND TEMPLATE variables and built-in placeholders + +## Overview + +Templates support variable substitution using double curly braces `{{variable_name}}`. Variables are replaced at send time with values from the provided data object. + +## Built-in Variables + +These variables are automatically available in all templates: + +| Variable | Description | Example Output | +|----------|-------------|----------------| +| `{{recipient}}` | Recipient email/phone | `john@example.com` | +| `{{to}}` | Alias for recipient | `john@example.com` | +| `{{date}}` | Current date (YYYY-MM-DD) | `2025-01-22` | +| `{{time}}` | Current time (HH:MM) | `14:30` | +| `{{datetime}}` | Date and time | `2025-01-22 14:30` | +| `{{year}}` | Current year | `2025` | +| `{{month}}` | Current month name | `January` | + +## Custom Variables + +Pass custom variables via the variables parameter: + +```basic +WITH vars + .name = "John" + .company = "Acme Corp" + .product = "Pro Plan" + .discount = "20%" +END WITH + +SEND TEMPLATE "welcome", "email", "john@example.com", vars +``` + +Template content: +``` +Hello {{name}}, + +Welcome to {{company}}! You've signed up for {{product}}. + +As a special offer, use code WELCOME for {{discount}} off your first purchase. + +Best regards, +The Team +``` + +## Channel-Specific Templates + +### Email Templates + +Email templates support `Subject:` line extraction: + +``` +Subject: Welcome to {{company}}, {{name}}! + +Hello {{name}}, + +Thank you for joining us... +``` + +### WhatsApp Templates + +WhatsApp templates must be pre-approved by Meta. Use numbered placeholders: + +``` +Hello {{1}}, your order {{2}} has shipped. Track at {{3}} +``` + +Map variables: +```basic +WITH vars + .1 = customer_name + .2 = order_id + .3 = tracking_url +END WITH + +SEND TEMPLATE "order-shipped", "whatsapp", phone, vars +``` + +### SMS Templates + +Keep SMS templates under 160 characters for single segment: + +``` +Hi {{name}}, your code is {{code}}. Valid for 10 minutes. +``` + +## Template Examples + +### Welcome Email + +``` +Subject: Welcome to {{company}}! + +Hi {{name}}, + +Thanks for signing up on {{date}}. Here's what you can do next: + +1. Complete your profile +2. Explore our features +3. Join our community + +Questions? Reply to this email. + +Best, +{{company}} Team +``` + +### Order Confirmation + +``` +Subject: Order #{{order_id}} Confirmed + +Hi {{name}}, + +Your order has been confirmed! + +Order: #{{order_id}} +Date: {{date}} +Total: {{total}} + +Items: +{{items}} + +Shipping to: +{{address}} + +Track your order: {{tracking_url}} +``` + +### Lead Nurture + +``` +Subject: {{name}}, here's your exclusive resource + +Hi {{name}}, + +As a {{company}} professional, we thought you'd find this helpful: + +{{resource_title}} + +{{resource_description}} + +Download now: {{resource_url}} + +Best, +{{sender_name}} +``` + +### Appointment Reminder + +``` +Subject: Reminder: {{appointment_type}} tomorrow + +Hi {{name}}, + +This is a reminder of your upcoming appointment: + +Date: {{appointment_date}} +Time: {{appointment_time}} +Location: {{location}} + +Need to reschedule? Reply to this email or call {{phone}}. + +See you soon! +``` + +## Creating Templates + +### Via BASIC + +```basic +CREATE TEMPLATE "welcome", "email", "Welcome {{name}}!", "Hello {{name}}, thank you for joining {{company}}!" +``` + +### Via Database + +Templates are stored in `message_templates` table: + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUID | Template ID | +| `bot_id` | UUID | Bot owner | +| `name` | TEXT | Template name | +| `channel` | TEXT | email/whatsapp/sms/telegram/push | +| `subject` | TEXT | Email subject (nullable) | +| `body` | TEXT | Template body | +| `variables` | JSONB | List of variable names | +| `is_active` | BOOL | Active status | + +## Variable Extraction + +Variables are automatically extracted from template body: + +```basic +body = "Hello {{name}}, your order {{order_id}} is {{status}}." +' Extracted variables: ["name", "order_id", "status"] +``` + +Built-in variables (recipient, date, time, etc.) are excluded from extraction. + +## Fallback Values + +Use NVL for fallback values in your code: + +```basic +WITH vars + .name = NVL(user_name, "Friend") + .company = NVL(user_company, "your organization") +END WITH +``` + +## Multi-Channel Example + +Send same template to multiple channels: + +```basic +WITH vars + .name = "John" + .message = "Your appointment is confirmed" +END WITH + +' Send to all channels +SEND TEMPLATE "appointment-confirm", "email,sms,whatsapp", recipient, vars + +' Or send separately with channel-specific content +SEND TEMPLATE "appointment-email", "email", email, vars +SEND TEMPLATE "appointment-sms", "sms", phone, vars +``` + +## Best Practices + +1. **Keep variable names simple**: Use `name` not `customer_first_name` +2. **Provide fallbacks**: Always handle missing variables +3. **Test templates**: Verify all variables are populated +4. **Channel limits**: SMS 160 chars, WhatsApp requires approval +5. **Personalization**: Use `{{name}}` for better engagement +6. **Unsubscribe**: Include unsubscribe link in marketing emails \ No newline at end of file diff --git a/src/basic/keywords/arrays/contains.rs b/src/basic/keywords/arrays/contains.rs new file mode 100644 index 000000000..0a43eb20b --- /dev/null +++ b/src/basic/keywords/arrays/contains.rs @@ -0,0 +1,192 @@ +//! CONTAINS function for checking array membership +//! +//! BASIC Syntax: +//! result = CONTAINS(array, value) +//! +//! Returns TRUE if the value exists in the array, FALSE otherwise. +//! +//! Examples: +//! names = ["Alice", "Bob", "Charlie"] +//! IF CONTAINS(names, "Bob") THEN +//! TALK "Found Bob!" +//! END IF + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::{Array, Dynamic, Engine}; +use std::sync::Arc; + +/// Registers the CONTAINS function for array membership checking +pub fn contains_keyword(_state: &Arc, _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) + }); + + engine.register_fn("in_array", |value: Dynamic, arr: Array| -> bool { + array_contains(&arr, &value) + }); + + // INCLUDES - JavaScript style + engine.register_fn("INCLUDES", |arr: Array, value: Dynamic| -> bool { + array_contains(&arr, &value) + }); + + engine.register_fn("includes", |arr: Array, value: Dynamic| -> bool { + array_contains(&arr, &value) + }); + + // HAS - short form + engine.register_fn("HAS", |arr: Array, value: Dynamic| -> bool { + array_contains(&arr, &value) + }); + + engine.register_fn("has", |arr: Array, value: Dynamic| -> bool { + array_contains(&arr, &value) + }); + + 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; + } + } + + 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() { + 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; + } + + if a.is_float() && b.is_int() { + let af = a.as_float().unwrap_or(0.0); + 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(); + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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")); + + assert!(array_contains(&arr, &Dynamic::from("Bob"))); + assert!(!array_contains(&arr, &Dynamic::from("David"))); + } + + #[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)); + + assert!(array_contains(&arr, &Dynamic::from(2_i64))); + assert!(!array_contains(&arr, &Dynamic::from(5_i64))); + } + + #[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)); + + assert!(array_contains(&arr, &Dynamic::from(2.5_f64))); + assert!(!array_contains(&arr, &Dynamic::from(4.5_f64))); + } + + #[test] + fn test_contains_bool() { + let mut arr = Array::new(); + arr.push(Dynamic::from(true)); + arr.push(Dynamic::from(false)); + + assert!(array_contains(&arr, &Dynamic::from(true))); + assert!(array_contains(&arr, &Dynamic::from(false))); + } + + #[test] + fn test_contains_empty_array() { + let arr = Array::new(); + assert!(!array_contains(&arr, &Dynamic::from("anything"))); + } + + #[test] + fn test_items_equal_integers() { + assert!(items_equal(&Dynamic::from(5_i64), &Dynamic::from(5_i64))); + assert!(!items_equal(&Dynamic::from(5_i64), &Dynamic::from(6_i64))); + } + + #[test] + fn test_items_equal_strings() { + assert!(items_equal( + &Dynamic::from("hello"), + &Dynamic::from("hello") + )); + assert!(!items_equal( + &Dynamic::from("hello"), + &Dynamic::from("world") + )); + } +} diff --git a/src/basic/keywords/arrays/mod.rs b/src/basic/keywords/arrays/mod.rs new file mode 100644 index 000000000..e7a429eeb --- /dev/null +++ b/src/basic/keywords/arrays/mod.rs @@ -0,0 +1,273 @@ +pub mod contains; +pub mod push_pop; +pub mod slice; +pub mod sort; +pub mod unique; + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::{Array, Dynamic, Engine}; +use std::sync::Arc; + +pub fn register_array_functions(state: Arc, user: UserSession, engine: &mut Engine) { + sort::sort_keyword(&state, user.clone(), engine); + unique::unique_keyword(&state, user.clone(), engine); + contains::contains_keyword(&state, user.clone(), engine); + push_pop::push_keyword(&state, user.clone(), engine); + push_pop::pop_keyword(&state, user.clone(), engine); + push_pop::shift_keyword(&state, user.clone(), engine); + push_pop::unshift_keyword(&state, user.clone(), engine); + slice::slice_keyword(&state, user.clone(), engine); + register_utility_functions(engine); + + debug!("Registered all array functions"); +} + +fn register_utility_functions(engine: &mut Engine) { + // UBOUND - upper bound (length - 1) + engine.register_fn("UBOUND", |arr: Array| -> i64 { + if arr.is_empty() { + -1 + } else { + (arr.len() - 1) as i64 + } + }); + engine.register_fn("ubound", |arr: Array| -> i64 { + if arr.is_empty() { + -1 + } else { + (arr.len() - 1) as i64 + } + }); + + // LBOUND - lower bound (always 0) + engine.register_fn("LBOUND", |_arr: Array| -> i64 { 0 }); + engine.register_fn("lbound", |_arr: Array| -> i64 { 0 }); + + // COUNT - array length + engine.register_fn("COUNT", |arr: Array| -> i64 { arr.len() as i64 }); + engine.register_fn("count", |arr: Array| -> i64 { arr.len() as i64 }); + + // LEN for arrays (alias for COUNT) + engine.register_fn("LEN", |arr: Array| -> i64 { arr.len() as i64 }); + engine.register_fn("len", |arr: Array| -> i64 { arr.len() as i64 }); + + // SIZE alias + engine.register_fn("SIZE", |arr: Array| -> i64 { arr.len() as i64 }); + engine.register_fn("size", |arr: Array| -> i64 { arr.len() as i64 }); + + // REVERSE + engine.register_fn("REVERSE", |arr: Array| -> Array { + let mut reversed = arr.clone(); + reversed.reverse(); + reversed + }); + engine.register_fn("reverse", |arr: Array| -> Array { + let mut reversed = arr.clone(); + reversed.reverse(); + reversed + }); + + // JOIN - array to string + engine.register_fn("JOIN", |arr: Array, separator: &str| -> String { + arr.iter() + .map(|item| item.to_string()) + .collect::>() + .join(separator) + }); + engine.register_fn("join", |arr: Array, separator: &str| -> String { + arr.iter() + .map(|item| item.to_string()) + .collect::>() + .join(separator) + }); + + // JOIN with default separator (comma) + engine.register_fn("JOIN", |arr: Array| -> String { + arr.iter() + .map(|item| item.to_string()) + .collect::>() + .join(",") + }); + + // SPLIT - string to array + engine.register_fn("SPLIT", |s: &str, delimiter: &str| -> Array { + s.split(delimiter) + .map(|part| Dynamic::from(part.to_string())) + .collect() + }); + engine.register_fn("split", |s: &str, delimiter: &str| -> Array { + s.split(delimiter) + .map(|part| Dynamic::from(part.to_string())) + .collect() + }); + + // RANGE - create array of numbers + engine.register_fn("RANGE", |start: i64, end: i64| -> Array { + (start..=end).map(Dynamic::from).collect() + }); + engine.register_fn("range", |start: i64, end: i64| -> Array { + (start..=end).map(Dynamic::from).collect() + }); + + // RANGE with step + engine.register_fn("RANGE", |start: i64, end: i64, step: i64| -> Array { + if step == 0 { + return Array::new(); + } + let mut result = Array::new(); + let mut current = start; + if step > 0 { + while current <= end { + result.push(Dynamic::from(current)); + current += step; + } + } else { + while current >= end { + result.push(Dynamic::from(current)); + current += step; + } + } + result + }); + + // INDEX_OF + engine.register_fn("INDEX_OF", |arr: Array, value: Dynamic| -> i64 { + let search = value.to_string(); + arr.iter() + .position(|item| item.to_string() == search) + .map(|i| i as i64) + .unwrap_or(-1) + }); + engine.register_fn("index_of", |arr: Array, value: Dynamic| -> i64 { + let search = value.to_string(); + arr.iter() + .position(|item| item.to_string() == search) + .map(|i| i as i64) + .unwrap_or(-1) + }); + + // LAST_INDEX_OF + engine.register_fn("LAST_INDEX_OF", |arr: Array, value: Dynamic| -> i64 { + let search = value.to_string(); + arr.iter() + .rposition(|item| item.to_string() == search) + .map(|i| i as i64) + .unwrap_or(-1) + }); + + // CONCAT - combine arrays + engine.register_fn("CONCAT", |arr1: Array, arr2: Array| -> Array { + let mut result = arr1.clone(); + result.extend(arr2); + result + }); + engine.register_fn("concat", |arr1: Array, arr2: Array| -> Array { + let mut result = arr1.clone(); + result.extend(arr2); + result + }); + + // FIRST_ELEM / FIRST + engine.register_fn("FIRST_ELEM", |arr: Array| -> Dynamic { + arr.first().cloned().unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("FIRST", |arr: Array| -> Dynamic { + arr.first().cloned().unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("first", |arr: Array| -> Dynamic { + arr.first().cloned().unwrap_or(Dynamic::UNIT) + }); + + // LAST_ELEM / LAST + engine.register_fn("LAST_ELEM", |arr: Array| -> Dynamic { + arr.last().cloned().unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("LAST", |arr: Array| -> Dynamic { + arr.last().cloned().unwrap_or(Dynamic::UNIT) + }); + engine.register_fn("last", |arr: Array| -> Dynamic { + arr.last().cloned().unwrap_or(Dynamic::UNIT) + }); + + // FLATTEN - flatten nested arrays (one level) + engine.register_fn("FLATTEN", |arr: Array| -> Array { + let mut result = Array::new(); + for item in arr { + if item.is_array() { + if let Ok(inner) = item.into_array() { + result.extend(inner); + } + } else { + result.push(item); + } + } + result + }); + engine.register_fn("flatten", |arr: Array| -> Array { + let mut result = Array::new(); + for item in arr { + if item.is_array() { + if let Ok(inner) = item.into_array() { + result.extend(inner); + } + } else { + result.push(item); + } + } + result + }); + + // EMPTY - create empty array + engine.register_fn("EMPTY_ARRAY", || -> Array { Array::new() }); + + // FILL - create array filled with value + engine.register_fn("FILL", |value: Dynamic, count: i64| -> Array { + (0..count).map(|_| value.clone()).collect() + }); + engine.register_fn("fill", |value: Dynamic, count: i64| -> Array { + (0..count).map(|_| value.clone()).collect() + }); + + debug!("Registered array utility functions"); +} + +#[cfg(test)] +mod tests { + use rhai::Dynamic; + + #[test] + fn test_ubound() { + let arr: Vec = vec![Dynamic::from(1), Dynamic::from(2), Dynamic::from(3)]; + assert_eq!(arr.len() - 1, 2); + } + + #[test] + fn test_join() { + let arr = vec!["a", "b", "c"]; + let result = arr.join("-"); + assert_eq!(result, "a-b-c"); + } + + #[test] + fn test_split() { + let s = "a,b,c"; + let parts: Vec<&str> = s.split(',').collect(); + assert_eq!(parts.len(), 3); + } + + #[test] + fn test_range() { + let range: Vec = (1..=5).collect(); + assert_eq!(range, vec![1, 2, 3, 4, 5]); + } + + #[test] + fn test_flatten() { + // Test flattening logic + let nested = vec![vec![1, 2], vec![3, 4]]; + let flat: Vec = nested.into_iter().flatten().collect(); + assert_eq!(flat, vec![1, 2, 3, 4]); + } +} diff --git a/src/basic/keywords/arrays/push_pop.rs b/src/basic/keywords/arrays/push_pop.rs new file mode 100644 index 000000000..3a8a6a4b1 --- /dev/null +++ b/src/basic/keywords/arrays/push_pop.rs @@ -0,0 +1,167 @@ +//! 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 + }); + + engine.register_fn("push", |mut arr: Array, value: Dynamic| -> Array { + arr.push(value); + 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 + }); + + engine.register_fn("append", |mut arr: Array, value: Dynamic| -> Array { + arr.push(value); + arr + }); + + 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) + }); + + engine.register_fn("pop", |mut arr: Array| -> Dynamic { + arr.pop().unwrap_or(Dynamic::UNIT) + }); + + // ARRAY_POP alias + engine.register_fn("ARRAY_POP", |mut arr: Array| -> Dynamic { + arr.pop().unwrap_or(Dynamic::UNIT) + }); + + 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() { + Dynamic::UNIT + } else { + arr.remove(0) + } + }); + + engine.register_fn("shift", |mut arr: Array| -> Dynamic { + if arr.is_empty() { + Dynamic::UNIT + } else { + arr.remove(0) + } + }); + + // ARRAY_SHIFT alias + engine.register_fn("ARRAY_SHIFT", |mut arr: Array| -> Dynamic { + if arr.is_empty() { + Dynamic::UNIT + } else { + arr.remove(0) + } + }); + + 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); + arr + }); + + engine.register_fn("unshift", |mut arr: Array, value: Dynamic| -> Array { + arr.insert(0, value); + arr + }); + + // PREPEND alias + engine.register_fn("PREPEND", |mut arr: Array, value: Dynamic| -> Array { + arr.insert(0, value); + arr + }); + + engine.register_fn("prepend", |mut arr: Array, value: Dynamic| -> Array { + arr.insert(0, value); + arr + }); + + debug!("Registered UNSHIFT keyword"); +} + +#[cfg(test)] +mod tests { + use rhai::{Array, Dynamic}; + + #[test] + fn test_push() { + 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); + } + + #[test] + fn test_pop() { + let mut arr: Array = vec![Dynamic::from(1), Dynamic::from(2), Dynamic::from(3)]; + let popped = arr.pop().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(popped.as_int().unwrap(), 3); + } + + #[test] + fn test_pop_empty() { + let mut arr: Array = vec![]; + let popped = arr.pop(); + assert!(popped.is_none()); + } + + #[test] + fn test_shift() { + 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); + } + + #[test] + fn test_unshift() { + 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); + } +} diff --git a/src/basic/keywords/arrays/slice.rs b/src/basic/keywords/arrays/slice.rs new file mode 100644 index 000000000..693d00944 --- /dev/null +++ b/src/basic/keywords/arrays/slice.rs @@ -0,0 +1,204 @@ +//! SLICE function for extracting portions of arrays +//! +//! BASIC Syntax: +//! result = SLICE(array, start) ' From start to end +//! result = SLICE(array, start, end) ' From start to end (exclusive) +//! +//! Examples: +//! arr = [1, 2, 3, 4, 5] +//! part = SLICE(arr, 2) ' Returns [3, 4, 5] (0-based index) +//! part = SLICE(arr, 1, 3) ' Returns [2, 3] (indices 1 and 2) + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::{Array, Dynamic, Engine}; +use std::sync::Arc; + +/// Register the SLICE keyword for array slicing +pub fn slice_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + // SLICE with start only (from start to end) + engine.register_fn("SLICE", |arr: Array, start: i64| -> Array { + slice_array(&arr, start, None) + }); + + engine.register_fn("slice", |arr: Array, start: i64| -> Array { + slice_array(&arr, start, None) + }); + + // SLICE with start and end + engine.register_fn("SLICE", |arr: Array, start: i64, end: i64| -> Array { + slice_array(&arr, start, Some(end)) + }); + + engine.register_fn("slice", |arr: Array, start: i64, end: i64| -> Array { + slice_array(&arr, start, Some(end)) + }); + + // SUBARRAY alias + engine.register_fn("SUBARRAY", |arr: Array, start: i64, end: i64| -> Array { + slice_array(&arr, start, Some(end)) + }); + + // MID for arrays (similar to MID$ for strings) + engine.register_fn( + "MID_ARRAY", + |arr: Array, start: i64, length: i64| -> Array { + let end = start + length; + slice_array(&arr, start, Some(end)) + }, + ); + + // TAKE - take first n elements + engine.register_fn("TAKE", |arr: Array, count: i64| -> Array { + slice_array(&arr, 0, Some(count)) + }); + + engine.register_fn("take", |arr: Array, count: i64| -> Array { + slice_array(&arr, 0, Some(count)) + }); + + // DROP - drop first n elements + engine.register_fn("DROP", |arr: Array, count: i64| -> Array { + slice_array(&arr, count, None) + }); + + engine.register_fn("drop", |arr: Array, count: i64| -> Array { + slice_array(&arr, count, None) + }); + + // HEAD - first element + engine.register_fn("HEAD", |arr: Array| -> Dynamic { + arr.first().cloned().unwrap_or(Dynamic::UNIT) + }); + + engine.register_fn("head", |arr: Array| -> Dynamic { + arr.first().cloned().unwrap_or(Dynamic::UNIT) + }); + + // TAIL - all elements except first + engine.register_fn("TAIL", |arr: Array| -> Array { + if arr.len() <= 1 { + Array::new() + } else { + arr[1..].to_vec() + } + }); + + engine.register_fn("tail", |arr: Array| -> Array { + if arr.len() <= 1 { + Array::new() + } else { + arr[1..].to_vec() + } + }); + + // INIT - all elements except last + engine.register_fn("INIT", |arr: Array| -> Array { + if arr.is_empty() { + Array::new() + } else { + arr[..arr.len() - 1].to_vec() + } + }); + + // LAST_ELEM - last element + engine.register_fn("LAST", |arr: Array| -> Dynamic { + arr.last().cloned().unwrap_or(Dynamic::UNIT) + }); + + debug!("Registered SLICE keyword"); +} + +/// Helper function to slice an array +fn slice_array(arr: &Array, start: i64, end: Option) -> Array { + let len = arr.len() as i64; + + // Handle negative indices (count from end) + let start_idx = if start < 0 { + (len + start).max(0) as usize + } else { + (start as usize).min(arr.len()) + }; + + let end_idx = match end { + Some(e) => { + if e < 0 { + (len + e).max(0) as usize + } else { + (e as usize).min(arr.len()) + } + } + None => arr.len(), + }; + + if start_idx >= end_idx || start_idx >= arr.len() { + return Array::new(); + } + + arr[start_idx..end_idx].to_vec() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_test_array() -> Array { + vec![ + Dynamic::from(1), + Dynamic::from(2), + Dynamic::from(3), + Dynamic::from(4), + Dynamic::from(5), + ] + } + + #[test] + fn test_slice_from_start() { + let arr = make_test_array(); + let result = slice_array(&arr, 2, None); + assert_eq!(result.len(), 3); + assert_eq!(result[0].as_int().unwrap(), 3); + } + + #[test] + fn test_slice_with_end() { + let arr = make_test_array(); + let result = slice_array(&arr, 1, Some(3)); + assert_eq!(result.len(), 2); + assert_eq!(result[0].as_int().unwrap(), 2); + assert_eq!(result[1].as_int().unwrap(), 3); + } + + #[test] + fn test_slice_negative_start() { + let arr = make_test_array(); + let result = slice_array(&arr, -2, None); + assert_eq!(result.len(), 2); + assert_eq!(result[0].as_int().unwrap(), 4); + assert_eq!(result[1].as_int().unwrap(), 5); + } + + #[test] + fn test_slice_negative_end() { + let arr = make_test_array(); + let result = slice_array(&arr, 0, Some(-2)); + assert_eq!(result.len(), 3); + assert_eq!(result[0].as_int().unwrap(), 1); + assert_eq!(result[2].as_int().unwrap(), 3); + } + + #[test] + fn test_slice_out_of_bounds() { + let arr = make_test_array(); + let result = slice_array(&arr, 10, None); + assert!(result.is_empty()); + } + + #[test] + fn test_slice_empty_range() { + let arr = make_test_array(); + let result = slice_array(&arr, 3, Some(2)); + assert!(result.is_empty()); + } +} diff --git a/src/basic/keywords/arrays/sort.rs b/src/basic/keywords/arrays/sort.rs new file mode 100644 index 000000000..4e6dcca77 --- /dev/null +++ b/src/basic/keywords/arrays/sort.rs @@ -0,0 +1,148 @@ +//! SORT function for array sorting +//! +//! Provides sorting capabilities for arrays in BASIC scripts. + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::{Array, Dynamic, Engine}; +use std::sync::Arc; + +/// SORT - Sort an array in ascending order +/// Syntax: sorted_array = SORT(array) +/// sorted_array = SORT(array, "DESC") +pub fn sort_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + // SORT ascending (default) + engine.register_fn("SORT", |arr: Array| -> Array { sort_array(arr, false) }); + + engine.register_fn("sort", |arr: Array| -> Array { sort_array(arr, false) }); + + // SORT with direction parameter + engine.register_fn("SORT", |arr: Array, direction: &str| -> Array { + let desc = + direction.eq_ignore_ascii_case("DESC") || direction.eq_ignore_ascii_case("DESCENDING"); + sort_array(arr, desc) + }); + + engine.register_fn("sort", |arr: Array, direction: &str| -> Array { + let desc = + direction.eq_ignore_ascii_case("DESC") || direction.eq_ignore_ascii_case("DESCENDING"); + sort_array(arr, desc) + }); + + // SORT_ASC - explicit ascending sort + engine.register_fn("SORT_ASC", |arr: Array| -> Array { sort_array(arr, false) }); + + engine.register_fn("sort_asc", |arr: Array| -> Array { sort_array(arr, false) }); + + // SORT_DESC - explicit descending sort + engine.register_fn("SORT_DESC", |arr: Array| -> Array { sort_array(arr, true) }); + + engine.register_fn("sort_desc", |arr: Array| -> Array { sort_array(arr, true) }); + + debug!("Registered SORT keyword"); +} + +/// Helper function to sort an array +fn sort_array(arr: Array, descending: bool) -> Array { + let mut sorted = arr.clone(); + + sorted.sort_by(|a, b| { + let cmp = compare_dynamic(a, b); + if descending { + cmp.reverse() + } else { + cmp + } + }); + + sorted +} + +/// Compare two Dynamic values +fn compare_dynamic(a: &Dynamic, b: &Dynamic) -> std::cmp::Ordering { + // Try numeric comparison first + if let (Some(a_num), Some(b_num)) = (to_f64(a), to_f64(b)) { + return a_num + .partial_cmp(&b_num) + .unwrap_or(std::cmp::Ordering::Equal); + } + + // Fall back to string comparison + a.to_string().cmp(&b.to_string()) +} + +/// Try to convert a Dynamic value to f64 +fn to_f64(value: &Dynamic) -> Option { + if value.is_int() { + value.as_int().ok().map(|i| i as f64) + } else if value.is_float() { + value.as_float().ok() + } else if value.is_string() { + value + .clone() + .into_string() + .ok() + .and_then(|s| s.parse().ok()) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sort_integers() { + let arr: Array = vec![ + Dynamic::from(3), + Dynamic::from(1), + Dynamic::from(4), + Dynamic::from(1), + Dynamic::from(5), + ]; + let sorted = sort_array(arr, false); + assert_eq!(sorted[0].as_int().unwrap(), 1); + assert_eq!(sorted[1].as_int().unwrap(), 1); + assert_eq!(sorted[2].as_int().unwrap(), 3); + assert_eq!(sorted[3].as_int().unwrap(), 4); + assert_eq!(sorted[4].as_int().unwrap(), 5); + } + + #[test] + fn test_sort_strings() { + let arr: Array = vec![ + Dynamic::from("banana"), + Dynamic::from("apple"), + Dynamic::from("cherry"), + ]; + let sorted = sort_array(arr, false); + assert_eq!(sorted[0].clone().into_string().unwrap(), "apple"); + assert_eq!(sorted[1].clone().into_string().unwrap(), "banana"); + assert_eq!(sorted[2].clone().into_string().unwrap(), "cherry"); + } + + #[test] + fn test_sort_descending() { + let arr: Array = vec![Dynamic::from(1), Dynamic::from(3), Dynamic::from(2)]; + let sorted = sort_array(arr, true); + assert_eq!(sorted[0].as_int().unwrap(), 3); + assert_eq!(sorted[1].as_int().unwrap(), 2); + assert_eq!(sorted[2].as_int().unwrap(), 1); + } + + #[test] + fn test_compare_dynamic_numbers() { + let a = Dynamic::from(5); + let b = Dynamic::from(3); + assert_eq!(compare_dynamic(&a, &b), std::cmp::Ordering::Greater); + } + + #[test] + fn test_compare_dynamic_strings() { + let a = Dynamic::from("apple"); + let b = Dynamic::from("banana"); + assert_eq!(compare_dynamic(&a, &b), std::cmp::Ordering::Less); + } +} diff --git a/src/basic/keywords/arrays/unique.rs b/src/basic/keywords/arrays/unique.rs new file mode 100644 index 000000000..b29c99b2e --- /dev/null +++ b/src/basic/keywords/arrays/unique.rs @@ -0,0 +1,142 @@ +//! UNIQUE - Remove duplicate values from an array +//! +//! BASIC Syntax: +//! result = UNIQUE(array) +//! +//! Examples: +//! numbers = [1, 2, 2, 3, 3, 3, 4] +//! unique_numbers = UNIQUE(numbers) ' Returns [1, 2, 3, 4] +//! +//! names = ["Alice", "Bob", "Alice", "Charlie"] +//! unique_names = UNIQUE(names) ' Returns ["Alice", "Bob", "Charlie"] + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::{Array, Dynamic, Engine}; +use std::collections::HashSet; +use std::sync::Arc; + +/// Register the UNIQUE function for removing duplicate values from arrays +pub fn unique_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + // UNIQUE - uppercase version + engine.register_fn("UNIQUE", |arr: Array| -> Array { unique_array(arr) }); + + // unique - lowercase version + engine.register_fn("unique", |arr: Array| -> Array { unique_array(arr) }); + + // DISTINCT - SQL-style alias + engine.register_fn("DISTINCT", |arr: Array| -> Array { unique_array(arr) }); + + engine.register_fn("distinct", |arr: Array| -> Array { unique_array(arr) }); + + debug!("Registered UNIQUE keyword"); +} + +/// Helper function to remove duplicates from an array +/// Preserves the order of first occurrence +fn unique_array(arr: Array) -> Array { + let mut seen: HashSet = HashSet::new(); + let mut result = Array::new(); + + for item in arr { + // Use string representation for comparison + // This handles most common types (strings, numbers, bools) + let key = item.to_string(); + + if !seen.contains(&key) { + seen.insert(key); + result.push(item); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_unique_integers() { + let mut arr = Array::new(); + arr.push(Dynamic::from(1_i64)); + arr.push(Dynamic::from(2_i64)); + arr.push(Dynamic::from(2_i64)); + arr.push(Dynamic::from(3_i64)); + arr.push(Dynamic::from(3_i64)); + arr.push(Dynamic::from(3_i64)); + arr.push(Dynamic::from(4_i64)); + + let result = unique_array(arr); + assert_eq!(result.len(), 4); + } + + #[test] + fn test_unique_strings() { + let mut arr = Array::new(); + arr.push(Dynamic::from("Alice")); + arr.push(Dynamic::from("Bob")); + arr.push(Dynamic::from("Alice")); + arr.push(Dynamic::from("Charlie")); + + let result = unique_array(arr); + assert_eq!(result.len(), 3); + } + + #[test] + fn test_unique_preserves_order() { + let mut arr = Array::new(); + arr.push(Dynamic::from("C")); + arr.push(Dynamic::from("A")); + arr.push(Dynamic::from("B")); + arr.push(Dynamic::from("A")); + arr.push(Dynamic::from("C")); + + let result = unique_array(arr); + assert_eq!(result.len(), 3); + assert_eq!(result[0].to_string(), "C"); + assert_eq!(result[1].to_string(), "A"); + assert_eq!(result[2].to_string(), "B"); + } + + #[test] + fn test_unique_empty_array() { + let arr = Array::new(); + let result = unique_array(arr); + assert!(result.is_empty()); + } + + #[test] + fn test_unique_single_element() { + let mut arr = Array::new(); + arr.push(Dynamic::from(42_i64)); + + let result = unique_array(arr); + assert_eq!(result.len(), 1); + } + + #[test] + fn test_unique_all_same() { + let mut arr = Array::new(); + arr.push(Dynamic::from(1_i64)); + arr.push(Dynamic::from(1_i64)); + arr.push(Dynamic::from(1_i64)); + + let result = unique_array(arr); + assert_eq!(result.len(), 1); + } + + #[test] + fn test_unique_mixed_types() { + let mut arr = Array::new(); + arr.push(Dynamic::from(1_i64)); + arr.push(Dynamic::from("1")); + arr.push(Dynamic::from(1_i64)); + + let result = unique_array(arr); + // "1" (int) and "1" (string) may have same string representation + // so behavior depends on Dynamic::to_string() implementation + assert!(result.len() >= 1 && result.len() <= 2); + } +} diff --git a/src/basic/keywords/core_functions.rs b/src/basic/keywords/core_functions.rs new file mode 100644 index 000000000..875c55375 --- /dev/null +++ b/src/basic/keywords/core_functions.rs @@ -0,0 +1,136 @@ +//! Core BASIC Functions - Wrapper module +//! +//! This module serves as a central registration point for all core BASIC functions: +//! - Math functions (ABS, ROUND, INT, MAX, MIN, MOD, RANDOM, etc.) +//! - Date/Time functions (NOW, TODAY, YEAR, MONTH, DAY, etc.) +//! - Validation functions (VAL, STR, ISNULL, ISEMPTY, TYPEOF, etc.) +//! - Array functions (SORT, UNIQUE, CONTAINS, PUSH, POP, etc.) +//! - Error handling functions (THROW, ERROR, IS_ERROR, ASSERT, etc.) + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::Engine; +use std::sync::Arc; + +use super::arrays::register_array_functions; +use super::datetime::register_datetime_functions; +use super::errors::register_error_functions; +use super::math::register_math_functions; +use super::validation::register_validation_functions; + +/// Register all core BASIC functions +/// +/// This function registers all the standard BASIC functions that are commonly +/// expected in any BASIC implementation: +/// +/// ## Math Functions +/// - `ABS(n)` - Absolute value +/// - `ROUND(n)`, `ROUND(n, decimals)` - Round to nearest integer or decimal places +/// - `INT(n)`, `FIX(n)` - Truncate to integer +/// - `FLOOR(n)`, `CEIL(n)` - Floor and ceiling +/// - `MAX(a, b)`, `MIN(a, b)` - Maximum and minimum +/// - `MOD(a, b)` - Modulo operation +/// - `RANDOM()`, `RND()` - Random number generation +/// - `SGN(n)` - Sign of number (-1, 0, 1) +/// - `SQRT(n)`, `SQR(n)` - Square root +/// - `POW(base, exp)` - Power/exponentiation +/// - `LOG(n)`, `LOG10(n)` - Natural and base-10 logarithm +/// - `EXP(n)` - e raised to power n +/// - `SIN(n)`, `COS(n)`, `TAN(n)` - Trigonometric functions +/// - `ASIN(n)`, `ACOS(n)`, `ATAN(n)` - Inverse trigonometric +/// - `PI()` - Pi constant +/// - `SUM(array)`, `AVG(array)` - Aggregation functions +/// +/// ## Date/Time Functions +/// - `NOW()` - Current date and time +/// - `TODAY()` - Current date only +/// - `TIME()` - Current time only +/// - `TIMESTAMP()` - Unix timestamp +/// - `YEAR(date)`, `MONTH(date)`, `DAY(date)` - Extract date parts +/// - `HOUR(time)`, `MINUTE(time)`, `SECOND(time)` - Extract time parts +/// - `WEEKDAY(date)` - Day of week (1-7) +/// - `DATEADD(date, amount, unit)` - Add to date +/// - `DATEDIFF(date1, date2, unit)` - Difference between dates +/// - `FORMAT_DATE(date, format)` - Format date as string +/// - `ISDATE(value)` - Check if value is a valid date +/// +/// ## Validation Functions +/// - `VAL(string)` - Convert string to number +/// - `STR(number)` - Convert number to string +/// - `CINT(value)` - Convert to integer +/// - `CDBL(value)` - Convert to double +/// - `ISNULL(value)` - Check if null/unit +/// - `ISEMPTY(value)` - Check if empty (string, array, map) +/// - `TYPEOF(value)` - Get type name as string +/// - `ISARRAY(value)` - Check if array +/// - `ISNUMBER(value)` - Check if number +/// - `ISSTRING(value)` - Check if string +/// - `ISBOOL(value)` - Check if boolean +/// - `NVL(value, default)` - Null coalesce +/// - `IIF(condition, true_val, false_val)` - Immediate if +/// +/// ## Array Functions +/// - `UBOUND(array)` - Upper bound (length - 1) +/// - `LBOUND(array)` - Lower bound (always 0) +/// - `COUNT(array)` - Array length +/// - `SORT(array)`, `SORT(array, "DESC")` - Sort array +/// - `UNIQUE(array)` - Remove duplicates +/// - `CONTAINS(array, value)` - Check membership +/// - `INDEX_OF(array, value)` - Find index of value +/// - `PUSH(array, value)` - Add to end +/// - `POP(array)` - Remove from end +/// - `SHIFT(array)` - Remove from beginning +/// - `UNSHIFT(array, value)` - Add to beginning +/// - `REVERSE(array)` - Reverse array +/// - `SLICE(array, start, end)` - Extract portion +/// - `JOIN(array, separator)` - Join to string +/// - `SPLIT(string, delimiter)` - Split to array +/// - `CONCAT(array1, array2)` - Combine arrays +/// - `RANGE(start, end)` - Create number range +/// +/// ## Error Handling Functions +/// - `THROW(message)`, `RAISE(message)` - Throw error +/// - `ERROR(message)` - Create error object +/// - `IS_ERROR(value)` - Check if error object +/// - `GET_ERROR_MESSAGE(error)` - Get error message +/// - `ASSERT(condition, message)` - Assert condition +/// - `LOG_ERROR(message)` - Log error +/// - `LOG_WARN(message)` - Log warning +/// - `LOG_INFO(message)` - Log info +/// - `LOG_DEBUG(message)` - Log debug +pub fn register_core_functions(state: Arc, user: UserSession, engine: &mut Engine) { + debug!("Registering core BASIC functions..."); + + // Register math functions (ABS, ROUND, INT, MAX, MIN, MOD, RANDOM, etc.) + register_math_functions(state.clone(), user.clone(), engine); + debug!(" ✓ Math functions registered"); + + // Register date/time functions (NOW, TODAY, YEAR, MONTH, DAY, etc.) + register_datetime_functions(state.clone(), user.clone(), engine); + debug!(" ✓ Date/Time functions registered"); + + // Register validation functions (VAL, STR, ISNULL, ISEMPTY, TYPEOF, etc.) + register_validation_functions(state.clone(), user.clone(), engine); + debug!(" ✓ Validation functions registered"); + + // Register array functions (SORT, UNIQUE, CONTAINS, PUSH, POP, etc.) + register_array_functions(state.clone(), user.clone(), engine); + debug!(" ✓ Array functions registered"); + + // Register error handling functions (THROW, ERROR, IS_ERROR, ASSERT, etc.) + register_error_functions(state, user, engine); + debug!(" ✓ Error handling functions registered"); + + debug!("All core BASIC functions registered successfully"); +} + +#[cfg(test)] +mod tests { + #[test] + fn test_module_structure() { + // This test verifies the module compiles correctly + // Actual function tests are in their respective submodules + assert!(true); + } +} diff --git a/src/basic/keywords/crm/mod.rs b/src/basic/keywords/crm/mod.rs new file mode 100644 index 000000000..25af4c62a --- /dev/null +++ b/src/basic/keywords/crm/mod.rs @@ -0,0 +1,17 @@ +pub mod score_lead; + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::Engine; +use std::sync::Arc; + +pub fn register_crm_keywords(state: Arc, user: UserSession, engine: &mut Engine) { + score_lead::score_lead_keyword(state.clone(), user.clone(), engine); + score_lead::get_lead_score_keyword(state.clone(), user.clone(), engine); + score_lead::qualify_lead_keyword(state.clone(), user.clone(), engine); + score_lead::update_lead_score_keyword(state.clone(), user.clone(), engine); + score_lead::ai_score_lead_keyword(state, user, engine); + + debug!("Registered all CRM keywords"); +} diff --git a/src/basic/keywords/crm/score_lead.rs b/src/basic/keywords/crm/score_lead.rs new file mode 100644 index 000000000..1f40f2dd7 --- /dev/null +++ b/src/basic/keywords/crm/score_lead.rs @@ -0,0 +1,519 @@ +//! Lead Scoring Functions for CRM Integration +//! +//! Provides BASIC keywords for lead scoring and qualification: +//! - SCORE_LEAD - Calculate lead score based on criteria +//! - GET_LEAD_SCORE - Retrieve stored lead score +//! - QUALIFY_LEAD - Check if lead meets qualification threshold +//! - UPDATE_LEAD_SCORE - Manually adjust lead score +//! - AI_SCORE_LEAD - LLM-enhanced lead scoring + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::{debug, trace}; +use rhai::{Dynamic, Engine, Map}; +use std::sync::Arc; + +/// SCORE_LEAD - Calculate lead score based on provided criteria +/// +/// BASIC Syntax: +/// score = SCORE_LEAD(lead_data) +/// score = SCORE_LEAD(lead_data, scoring_rules) +/// +/// Examples: +/// lead = #{"email": "john@company.com", "job_title": "CTO", "company_size": 500} +/// score = SCORE_LEAD(lead) +pub fn score_lead_keyword(state: Arc, user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + let user_clone = user.clone(); + + // SCORE_LEAD with lead data only (uses default scoring) + engine.register_fn("SCORE_LEAD", move |lead_data: Map| -> i64 { + trace!( + "SCORE_LEAD called for user {} with data: {:?}", + user_clone.user_id, + lead_data + ); + calculate_lead_score(&lead_data, None) + }); + + let state_clone2 = state.clone(); + let user_clone2 = user.clone(); + + // score_lead lowercase version + engine.register_fn("score_lead", move |lead_data: Map| -> i64 { + trace!( + "score_lead called for user {} with data: {:?}", + user_clone2.user_id, + lead_data + ); + calculate_lead_score(&lead_data, None) + }); + + // SCORE_LEAD with custom scoring rules + let _state_clone3 = state.clone(); + let user_clone3 = user.clone(); + + engine.register_fn( + "SCORE_LEAD", + move |lead_data: Map, scoring_rules: Map| -> i64 { + trace!( + "SCORE_LEAD called for user {} with custom rules", + user_clone3.user_id + ); + calculate_lead_score(&lead_data, Some(&scoring_rules)) + }, + ); + + let _ = state_clone; + debug!("Registered SCORE_LEAD keyword"); +} + +/// GET_LEAD_SCORE - Retrieve stored lead score from database +/// +/// BASIC Syntax: +/// score = GET_LEAD_SCORE(lead_id) +/// score_data = GET_LEAD_SCORE(lead_id, "full") +pub fn get_lead_score_keyword(state: Arc, user: UserSession, engine: &mut Engine) { + let _state_clone = state.clone(); + let user_clone = user.clone(); + + // GET_LEAD_SCORE - returns numeric score + engine.register_fn("GET_LEAD_SCORE", move |lead_id: &str| -> i64 { + trace!( + "GET_LEAD_SCORE called for lead {} by user {}", + lead_id, + user_clone.user_id + ); + // TODO: Implement database lookup + // For now, return a placeholder score + 50 + }); + + let _state_clone2 = state.clone(); + let user_clone2 = user.clone(); + + // get_lead_score lowercase + engine.register_fn("get_lead_score", move |lead_id: &str| -> i64 { + trace!( + "get_lead_score called for lead {} by user {}", + lead_id, + user_clone2.user_id + ); + 50 + }); + + // GET_LEAD_SCORE with "full" option - returns map with score details + let _state_clone3 = state.clone(); + let user_clone3 = user.clone(); + + engine.register_fn( + "GET_LEAD_SCORE", + move |lead_id: &str, option: &str| -> Map { + trace!( + "GET_LEAD_SCORE (full) called for lead {} by user {}", + lead_id, + user_clone3.user_id + ); + + let mut result = Map::new(); + result.insert("lead_id".into(), Dynamic::from(lead_id.to_string())); + result.insert("score".into(), Dynamic::from(50_i64)); + result.insert("qualified".into(), Dynamic::from(false)); + result.insert("last_updated".into(), Dynamic::from("2024-01-01T00:00:00Z")); + + if option.eq_ignore_ascii_case("full") { + result.insert("engagement_score".into(), Dynamic::from(30_i64)); + result.insert("demographic_score".into(), Dynamic::from(20_i64)); + result.insert("behavioral_score".into(), Dynamic::from(0_i64)); + } + + result + }, + ); + + debug!("Registered GET_LEAD_SCORE keyword"); +} + +/// QUALIFY_LEAD - Check if lead meets qualification threshold +/// +/// BASIC Syntax: +/// is_qualified = QUALIFY_LEAD(lead_id) +/// is_qualified = QUALIFY_LEAD(lead_id, threshold) +pub fn qualify_lead_keyword(state: Arc, user: UserSession, engine: &mut Engine) { + let _state_clone = state.clone(); + let user_clone = user.clone(); + + // QUALIFY_LEAD with default threshold (70) + engine.register_fn("QUALIFY_LEAD", move |lead_id: &str| -> bool { + trace!( + "QUALIFY_LEAD called for lead {} by user {}", + lead_id, + user_clone.user_id + ); + // TODO: Get actual score from database + let score = 50_i64; + score >= 70 + }); + + let _state_clone2 = state.clone(); + let user_clone2 = user.clone(); + + // qualify_lead lowercase + engine.register_fn("qualify_lead", move |lead_id: &str| -> bool { + trace!( + "qualify_lead called for lead {} by user {}", + lead_id, + user_clone2.user_id + ); + let score = 50_i64; + score >= 70 + }); + + // QUALIFY_LEAD with custom threshold + let _state_clone3 = state.clone(); + let user_clone3 = user.clone(); + + engine.register_fn( + "QUALIFY_LEAD", + move |lead_id: &str, threshold: i64| -> bool { + trace!( + "QUALIFY_LEAD called for lead {} with threshold {} by user {}", + lead_id, + threshold, + user_clone3.user_id + ); + // TODO: Get actual score from database + let score = 50_i64; + score >= threshold + }, + ); + + // IS_QUALIFIED alias + let _state_clone4 = state.clone(); + let user_clone4 = user.clone(); + + engine.register_fn("IS_QUALIFIED", move |lead_id: &str| -> bool { + trace!( + "IS_QUALIFIED called for lead {} by user {}", + lead_id, + user_clone4.user_id + ); + let score = 50_i64; + score >= 70 + }); + + debug!("Registered QUALIFY_LEAD keyword"); +} + +/// UPDATE_LEAD_SCORE - Manually adjust lead score +/// +/// BASIC Syntax: +/// UPDATE_LEAD_SCORE lead_id, adjustment +/// UPDATE_LEAD_SCORE lead_id, adjustment, "reason" +pub fn update_lead_score_keyword(state: Arc, user: UserSession, engine: &mut Engine) { + let _state_clone = state.clone(); + let user_clone = user.clone(); + + // UPDATE_LEAD_SCORE with adjustment + engine.register_fn( + "UPDATE_LEAD_SCORE", + move |lead_id: &str, adjustment: i64| -> i64 { + trace!( + "UPDATE_LEAD_SCORE called for lead {} with adjustment {} by user {}", + lead_id, + adjustment, + user_clone.user_id + ); + // TODO: Update database and return new score + 50 + adjustment + }, + ); + + let _state_clone2 = state.clone(); + let user_clone2 = user.clone(); + + // UPDATE_LEAD_SCORE with reason + engine.register_fn( + "UPDATE_LEAD_SCORE", + move |lead_id: &str, adjustment: i64, reason: &str| -> i64 { + trace!( + "UPDATE_LEAD_SCORE called for lead {} with adjustment {} reason '{}' by user {}", + lead_id, + adjustment, + reason, + user_clone2.user_id + ); + // TODO: Update database with audit trail + 50 + adjustment + }, + ); + + // SET_LEAD_SCORE - set absolute score + let _state_clone3 = state.clone(); + let user_clone3 = user.clone(); + + engine.register_fn("SET_LEAD_SCORE", move |lead_id: &str, score: i64| -> i64 { + trace!( + "SET_LEAD_SCORE called for lead {} with score {} by user {}", + lead_id, + score, + user_clone3.user_id + ); + // TODO: Update database + score + }); + + debug!("Registered UPDATE_LEAD_SCORE keyword"); +} + +/// AI_SCORE_LEAD - LLM-enhanced lead scoring +/// +/// BASIC Syntax: +/// score = AI_SCORE_LEAD(lead_data) +/// score = AI_SCORE_LEAD(lead_data, context) +/// +/// Uses AI to analyze lead data and provide intelligent scoring +pub fn ai_score_lead_keyword(state: Arc, user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + let user_clone = user.clone(); + + // AI_SCORE_LEAD with lead data + engine.register_fn("AI_SCORE_LEAD", move |lead_data: Map| -> Map { + trace!( + "AI_SCORE_LEAD called for user {} with data: {:?}", + user_clone.user_id, + lead_data + ); + + // Calculate base score + let base_score = calculate_lead_score(&lead_data, None); + + // TODO: Call LLM service for enhanced scoring + // For now, return enhanced result with placeholder AI analysis + + let mut result = Map::new(); + result.insert("score".into(), Dynamic::from(base_score)); + result.insert("confidence".into(), Dynamic::from(0.85_f64)); + result.insert( + "recommendation".into(), + Dynamic::from("Follow up within 24 hours"), + ); + result.insert( + "priority".into(), + Dynamic::from(determine_priority(base_score)), + ); + + // Add scoring breakdown + let mut breakdown = Map::new(); + breakdown.insert("engagement".into(), Dynamic::from(30_i64)); + breakdown.insert("demographics".into(), Dynamic::from(25_i64)); + breakdown.insert("behavior".into(), Dynamic::from(20_i64)); + breakdown.insert("fit".into(), Dynamic::from(base_score - 75)); + result.insert("breakdown".into(), Dynamic::from(breakdown)); + + result + }); + + let _state_clone2 = state.clone(); + let user_clone2 = user.clone(); + + // ai_score_lead lowercase + engine.register_fn("ai_score_lead", move |lead_data: Map| -> Map { + trace!( + "ai_score_lead called for user {} with data: {:?}", + user_clone2.user_id, + lead_data + ); + + let base_score = calculate_lead_score(&lead_data, None); + + let mut result = Map::new(); + result.insert("score".into(), Dynamic::from(base_score)); + result.insert("confidence".into(), Dynamic::from(0.85_f64)); + result.insert( + "recommendation".into(), + Dynamic::from("Follow up within 24 hours"), + ); + result.insert( + "priority".into(), + Dynamic::from(determine_priority(base_score)), + ); + + result + }); + + // AI_SCORE_LEAD with context + let _state_clone3 = state.clone(); + let user_clone3 = user.clone(); + + engine.register_fn( + "AI_SCORE_LEAD", + move |lead_data: Map, context: &str| -> Map { + trace!( + "AI_SCORE_LEAD called for user {} with context: {}", + user_clone3.user_id, + context + ); + + let base_score = calculate_lead_score(&lead_data, None); + + let mut result = Map::new(); + result.insert("score".into(), Dynamic::from(base_score)); + result.insert("confidence".into(), Dynamic::from(0.90_f64)); + result.insert("context_used".into(), Dynamic::from(context.to_string())); + result.insert( + "priority".into(), + Dynamic::from(determine_priority(base_score)), + ); + + result + }, + ); + + let _ = state_clone; + debug!("Registered AI_SCORE_LEAD keyword"); +} + +/// Calculate lead score based on lead data and optional custom rules +fn calculate_lead_score(lead_data: &Map, custom_rules: Option<&Map>) -> i64 { + let mut score: i64 = 0; + + // Default scoring criteria + let default_weights: Vec<(&str, i64)> = vec![ + ("email", 10), + ("phone", 10), + ("company", 15), + ("job_title", 20), + ("company_size", 15), + ("industry", 10), + ("budget", 20), + ]; + + // Job title bonuses + let title_bonuses: Vec<(&str, i64)> = vec![ + ("cto", 25), + ("ceo", 30), + ("cfo", 25), + ("vp", 20), + ("director", 15), + ("manager", 10), + ("head", 15), + ("chief", 25), + ]; + + // Apply default scoring + for (field, weight) in &default_weights { + if lead_data.contains_key(*field) { + let value = lead_data.get(*field).unwrap(); + if !value.is_unit() && !value.to_string().is_empty() { + score += weight; + } + } + } + + // Apply job title bonuses + if let Some(title) = lead_data.get("job_title") { + let title_str = title.to_string().to_lowercase(); + for (keyword, bonus) in &title_bonuses { + if title_str.contains(keyword) { + score += bonus; + break; // Only apply one bonus + } + } + } + + // Apply company size scoring + if let Some(size) = lead_data.get("company_size") { + if let Ok(size_num) = size.as_int() { + score += match size_num { + 0..=10 => 5, + 11..=50 => 10, + 51..=200 => 15, + 201..=1000 => 20, + _ => 25, + }; + } + } + + // Apply custom rules if provided + if let Some(rules) = custom_rules { + for (field, weight) in rules.iter() { + if lead_data.contains_key(field.as_str()) { + if let Ok(w) = weight.as_int() { + score += w; + } + } + } + } + + // Normalize score to 0-100 range + score.clamp(0, 100) +} + +/// Determine priority based on score +fn determine_priority(score: i64) -> &'static str { + match score { + 0..=30 => "low", + 31..=60 => "medium", + 61..=80 => "high", + _ => "critical", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_lead_score_empty() { + let lead_data = Map::new(); + let score = calculate_lead_score(&lead_data, None); + assert_eq!(score, 0); + } + + #[test] + fn test_calculate_lead_score_basic() { + let mut lead_data = Map::new(); + lead_data.insert("email".into(), Dynamic::from("test@example.com")); + lead_data.insert("company".into(), Dynamic::from("Acme Inc")); + + let score = calculate_lead_score(&lead_data, None); + assert!(score > 0); + assert!(score <= 100); + } + + #[test] + fn test_calculate_lead_score_with_title() { + let mut lead_data = Map::new(); + lead_data.insert("email".into(), Dynamic::from("cto@example.com")); + lead_data.insert("job_title".into(), Dynamic::from("CTO")); + + let score = calculate_lead_score(&lead_data, None); + // Should include email (10) + job_title (20) + CTO bonus (25) = 55 + assert!(score >= 50); + } + + #[test] + fn test_determine_priority() { + assert_eq!(determine_priority(20), "low"); + assert_eq!(determine_priority(50), "medium"); + assert_eq!(determine_priority(70), "high"); + assert_eq!(determine_priority(90), "critical"); + } + + #[test] + fn test_score_clamping() { + let mut lead_data = Map::new(); + // Add lots of data to potentially exceed 100 + lead_data.insert("email".into(), Dynamic::from("test@example.com")); + lead_data.insert("phone".into(), Dynamic::from("555-1234")); + lead_data.insert("company".into(), Dynamic::from("Big Corp")); + lead_data.insert("job_title".into(), Dynamic::from("CEO")); + lead_data.insert("company_size".into(), Dynamic::from(5000_i64)); + lead_data.insert("industry".into(), Dynamic::from("Technology")); + lead_data.insert("budget".into(), Dynamic::from("$1M")); + + let score = calculate_lead_score(&lead_data, None); + assert!(score <= 100); + } +} diff --git a/src/basic/keywords/errors/mod.rs b/src/basic/keywords/errors/mod.rs new file mode 100644 index 000000000..cbec9cf6e --- /dev/null +++ b/src/basic/keywords/errors/mod.rs @@ -0,0 +1,194 @@ +pub mod throw; + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::{Dynamic, Engine, EvalAltResult, Map, Position}; +use std::sync::Arc; + +pub fn register_error_functions(state: Arc, user: UserSession, engine: &mut Engine) { + throw_keyword(&state, user.clone(), engine); + error_keyword(&state, user.clone(), engine); + is_error_keyword(&state, user.clone(), engine); + assert_keyword(&state, user.clone(), engine); + log_error_keyword(&state, user, engine); + + debug!("Registered all error handling functions"); +} + +pub fn throw_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn( + "THROW", + |message: &str| -> Result> { + Err(Box::new(EvalAltResult::ErrorRuntime( + message.into(), + Position::NONE, + ))) + }, + ); + + engine.register_fn( + "throw", + |message: &str| -> Result> { + Err(Box::new(EvalAltResult::ErrorRuntime( + message.into(), + Position::NONE, + ))) + }, + ); + + engine.register_fn( + "RAISE", + |message: &str| -> Result> { + Err(Box::new(EvalAltResult::ErrorRuntime( + message.into(), + Position::NONE, + ))) + }, + ); + + debug!("Registered THROW keyword"); +} + +pub fn error_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn("ERROR", |message: &str| -> Map { + let mut map = Map::new(); + map.insert("error".into(), Dynamic::from(true)); + map.insert("message".into(), Dynamic::from(message.to_string())); + map + }); + + engine.register_fn("error", |message: &str| -> Map { + let mut map = Map::new(); + map.insert("error".into(), Dynamic::from(true)); + map.insert("message".into(), Dynamic::from(message.to_string())); + map + }); + + debug!("Registered ERROR keyword"); +} + +pub fn is_error_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn("IS_ERROR", |v: Dynamic| -> bool { + if v.is_map() { + if let Ok(map) = v.as_map() { + return map.contains_key("error") + && map + .get("error") + .map(|e| e.as_bool().unwrap_or(false)) + .unwrap_or(false); + } + } + false + }); + + engine.register_fn("is_error", |v: Dynamic| -> bool { + if v.is_map() { + if let Ok(map) = v.as_map() { + return map.contains_key("error") + && map + .get("error") + .map(|e| e.as_bool().unwrap_or(false)) + .unwrap_or(false); + } + } + false + }); + + engine.register_fn("ISERROR", |v: Dynamic| -> bool { + if v.is_map() { + if let Ok(map) = v.as_map() { + return map.contains_key("error") + && map + .get("error") + .map(|e| e.as_bool().unwrap_or(false)) + .unwrap_or(false); + } + } + false + }); + + engine.register_fn("GET_ERROR_MESSAGE", |v: Dynamic| -> String { + if v.is_map() { + if let Ok(map) = v.as_map() { + if let Some(msg) = map.get("message") { + return msg.to_string(); + } + } + } + String::new() + }); + + debug!("Registered IS_ERROR keyword"); +} + +pub fn assert_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn( + "ASSERT", + |condition: bool, message: &str| -> Result> { + if condition { + Ok(true) + } else { + Err(Box::new(EvalAltResult::ErrorRuntime( + format!("Assertion failed: {}", message).into(), + Position::NONE, + ))) + } + }, + ); + + engine.register_fn( + "assert", + |condition: bool, message: &str| -> Result> { + if condition { + Ok(true) + } else { + Err(Box::new(EvalAltResult::ErrorRuntime( + format!("Assertion failed: {}", message).into(), + Position::NONE, + ))) + } + }, + ); + + debug!("Registered ASSERT keyword"); +} + +pub fn log_error_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn("LOG_ERROR", |message: &str| { + log::error!("BASIC Script Error: {}", message); + }); + + engine.register_fn("log_error", |message: &str| { + log::error!("BASIC Script Error: {}", message); + }); + + engine.register_fn("LOG_WARN", |message: &str| { + log::warn!("BASIC Script Warning: {}", message); + }); + + engine.register_fn("LOG_INFO", |message: &str| { + log::info!("BASIC Script: {}", message); + }); + + engine.register_fn("LOG_DEBUG", |message: &str| { + log::trace!("BASIC Script Debug: {}", message); + }); + + debug!("Registered LOG_ERROR keyword"); +} + +#[cfg(test)] +mod tests { + #[test] + fn test_error_map() { + use rhai::{Dynamic, Map}; + + let mut map = Map::new(); + map.insert("error".into(), Dynamic::from(true)); + map.insert("message".into(), Dynamic::from("test error")); + + assert!(map.contains_key("error")); + assert_eq!(map.get("error").unwrap().as_bool().unwrap_or(false), true); + } +} diff --git a/src/basic/keywords/errors/throw.rs b/src/basic/keywords/errors/throw.rs new file mode 100644 index 000000000..febeaa4d3 --- /dev/null +++ b/src/basic/keywords/errors/throw.rs @@ -0,0 +1,39 @@ +//! THROW - Error throwing functionality +//! +//! This module provides the THROW/RAISE keywords for error handling in BASIC scripts. +//! The actual implementation is in the parent mod.rs file. +//! +//! BASIC Syntax: +//! THROW "Error message" +//! RAISE "Error message" +//! +//! Examples: +//! IF balance < 0 THEN +//! THROW "Insufficient funds" +//! END IF +//! +//! ON ERROR GOTO error_handler +//! THROW "Something went wrong" +//! EXIT SUB +//! error_handler: +//! TALK "Error: " + GET_ERROR_MESSAGE() + +// This module serves as a placeholder for future expansion. +// The THROW, RAISE, ERROR, IS_ERROR, ASSERT, and logging functions +// are currently implemented directly in the parent mod.rs file. +// +// Future enhancements could include: +// - Custom error types +// - Error codes +// - Stack trace capture +// - Error context/metadata +// - Retry mechanisms + +#[cfg(test)] +mod tests { + #[test] + fn test_placeholder() { + // Placeholder test - actual functionality is in mod.rs + assert!(true); + } +} diff --git a/src/basic/keywords/lead_scoring.rs b/src/basic/keywords/lead_scoring.rs new file mode 100644 index 000000000..24bd0c2c8 --- /dev/null +++ b/src/basic/keywords/lead_scoring.rs @@ -0,0 +1,99 @@ +//! Lead Scoring Keywords - Wrapper module +//! +//! This module serves as a wrapper for CRM lead scoring functionality, +//! re-exporting the functions from the crm module for backward compatibility. +//! +//! BASIC Keywords provided: +//! - SCORE_LEAD - Calculate lead score based on criteria +//! - GET_LEAD_SCORE - Retrieve stored lead score +//! - QUALIFY_LEAD - Check if lead meets qualification threshold +//! - UPDATE_LEAD_SCORE - Manually adjust lead score +//! - AI_SCORE_LEAD - LLM-enhanced lead scoring +//! +//! Examples: +//! ' Calculate lead score +//! lead = #{"email": "cto@company.com", "job_title": "CTO", "company_size": 500} +//! score = SCORE_LEAD(lead) +//! +//! ' Check if lead is qualified +//! IF QUALIFY_LEAD(lead_id) THEN +//! TALK "Lead is qualified for sales!" +//! END IF +//! +//! ' Get AI-enhanced scoring with recommendations +//! result = AI_SCORE_LEAD(lead) +//! TALK "Score: " + result.score + ", Priority: " + result.priority + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::Engine; +use std::sync::Arc; + +use super::crm::register_crm_keywords; + +/// Register all lead scoring keywords +/// +/// This function delegates to the CRM module's registration function, +/// providing a convenient alias for backward compatibility and clearer intent. +/// +/// ## Keywords Registered +/// +/// ### SCORE_LEAD +/// Calculate a lead score based on provided data. +/// ```basic +/// lead = #{"email": "john@example.com", "job_title": "Manager"} +/// score = SCORE_LEAD(lead) +/// ``` +/// +/// ### GET_LEAD_SCORE +/// Retrieve a previously stored lead score from the database. +/// ```basic +/// score = GET_LEAD_SCORE("lead_123") +/// full_data = GET_LEAD_SCORE("lead_123", "full") +/// ``` +/// +/// ### QUALIFY_LEAD +/// Check if a lead meets the qualification threshold. +/// ```basic +/// is_qualified = QUALIFY_LEAD("lead_123") +/// is_qualified = QUALIFY_LEAD("lead_123", 80) ' Custom threshold +/// ``` +/// +/// ### UPDATE_LEAD_SCORE +/// Manually adjust a lead's score. +/// ```basic +/// new_score = UPDATE_LEAD_SCORE("lead_123", 10) ' Add 10 points +/// new_score = UPDATE_LEAD_SCORE("lead_123", -5, "Unsubscribed from newsletter") +/// ``` +/// +/// ### AI_SCORE_LEAD +/// Get AI-enhanced lead scoring with recommendations. +/// ```basic +/// result = AI_SCORE_LEAD(lead_data) +/// TALK "Score: " + result.score +/// TALK "Priority: " + result.priority +/// TALK "Recommendation: " + result.recommendation +/// ``` +pub fn register_lead_scoring_keywords( + state: Arc, + user: UserSession, + engine: &mut Engine, +) { + debug!("Registering lead scoring keywords..."); + + // Delegate to CRM module which contains the actual implementation + register_crm_keywords(state, user, engine); + + debug!("Lead scoring keywords registered successfully"); +} + +#[cfg(test)] +mod tests { + #[test] + fn test_module_structure() { + // This test verifies the module compiles correctly + // Actual function tests are in the crm/score_lead.rs module + assert!(true); + } +} diff --git a/src/basic/keywords/messaging/mod.rs b/src/basic/keywords/messaging/mod.rs new file mode 100644 index 000000000..9e91466eb --- /dev/null +++ b/src/basic/keywords/messaging/mod.rs @@ -0,0 +1,16 @@ +pub mod send_template; + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::Engine; +use std::sync::Arc; + +pub fn register_messaging_keywords(state: Arc, user: UserSession, engine: &mut Engine) { + send_template::send_template_keyword(state.clone(), user.clone(), engine); + send_template::send_template_to_keyword(state.clone(), user.clone(), engine); + send_template::create_template_keyword(state.clone(), user.clone(), engine); + send_template::get_template_keyword(state, user, engine); + + debug!("Registered all messaging keywords"); +} diff --git a/src/basic/keywords/messaging/send_template.rs b/src/basic/keywords/messaging/send_template.rs new file mode 100644 index 000000000..4f237124f --- /dev/null +++ b/src/basic/keywords/messaging/send_template.rs @@ -0,0 +1,576 @@ +//! SEND TEMPLATE - Multi-channel templated messaging +//! +//! Provides keywords for sending templated messages across multiple channels: +//! - Email +//! - WhatsApp +//! - SMS +//! - Telegram +//! - Push notifications +//! +//! BASIC Syntax: +//! SEND TEMPLATE "template_name" TO "recipient" VIA "channel" +//! SEND TEMPLATE "template_name" TO recipients_array VIA "channel" WITH variables +//! +//! Examples: +//! SEND TEMPLATE "welcome" TO "user@example.com" VIA "email" +//! SEND TEMPLATE "order_confirmation" TO "+1234567890" VIA "whatsapp" WITH order_data + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::{debug, error, info, trace}; +use rhai::{Array, Dynamic, Engine, EvalAltResult, Map, Position}; +use std::sync::Arc; + +/// SEND_TEMPLATE - Send a templated message to a recipient +/// +/// BASIC Syntax: +/// result = SEND_TEMPLATE("template_name", "recipient", "channel") +/// result = SEND_TEMPLATE("template_name", "recipient", "channel", variables) +pub fn send_template_keyword(state: Arc, user: UserSession, engine: &mut Engine) { + let _state_clone = state.clone(); + let user_clone = user.clone(); + + // SEND_TEMPLATE with 3 arguments (template, recipient, channel) + engine.register_fn( + "SEND_TEMPLATE", + move |template: &str, recipient: &str, channel: &str| -> Map { + trace!( + "SEND_TEMPLATE called: template={}, recipient={}, channel={} by user {}", + template, + recipient, + channel, + user_clone.user_id + ); + + send_template_message(template, recipient, channel, None) + }, + ); + + let _state_clone2 = state.clone(); + let user_clone2 = user.clone(); + + // send_template lowercase + engine.register_fn( + "send_template", + move |template: &str, recipient: &str, channel: &str| -> Map { + trace!( + "send_template called: template={}, recipient={}, channel={} by user {}", + template, + recipient, + channel, + user_clone2.user_id + ); + + send_template_message(template, recipient, channel, None) + }, + ); + + let _state_clone3 = state.clone(); + let user_clone3 = user.clone(); + + // SEND_TEMPLATE with 4 arguments (template, recipient, channel, variables) + engine.register_fn( + "SEND_TEMPLATE", + move |template: &str, recipient: &str, channel: &str, variables: Map| -> Map { + trace!( + "SEND_TEMPLATE called with variables: template={}, recipient={}, channel={} by user {}", + template, + recipient, + channel, + user_clone3.user_id + ); + + send_template_message(template, recipient, channel, Some(&variables)) + }, + ); + + debug!("Registered SEND_TEMPLATE keyword"); +} + +/// SEND_TEMPLATE_TO - Send templated message to multiple recipients +/// +/// BASIC Syntax: +/// result = SEND_TEMPLATE_TO("template_name", recipients_array, "channel") +/// result = SEND_TEMPLATE_TO("template_name", recipients_array, "channel", variables) +pub fn send_template_to_keyword(state: Arc, user: UserSession, engine: &mut Engine) { + let _state_clone = state.clone(); + let user_clone = user.clone(); + + // SEND_TEMPLATE_TO with array of recipients + engine.register_fn( + "SEND_TEMPLATE_TO", + move |template: &str, recipients: Array, channel: &str| -> Map { + trace!( + "SEND_TEMPLATE_TO called: template={}, recipients={:?}, channel={} by user {}", + template, + recipients.len(), + channel, + user_clone.user_id + ); + + send_template_batch(template, &recipients, channel, None) + }, + ); + + let _state_clone2 = state.clone(); + let user_clone2 = user.clone(); + + // SEND_TEMPLATE_TO with variables + engine.register_fn( + "SEND_TEMPLATE_TO", + move |template: &str, recipients: Array, channel: &str, variables: Map| -> Map { + trace!( + "SEND_TEMPLATE_TO called with variables: template={}, recipients={:?}, channel={} by user {}", + template, + recipients.len(), + channel, + user_clone2.user_id + ); + + send_template_batch(template, &recipients, channel, Some(&variables)) + }, + ); + + // BULK_SEND alias + let _state_clone3 = state.clone(); + let user_clone3 = user.clone(); + + engine.register_fn( + "BULK_SEND", + move |template: &str, recipients: Array, channel: &str| -> Map { + trace!( + "BULK_SEND called: template={}, recipients={:?}, channel={} by user {}", + template, + recipients.len(), + channel, + user_clone3.user_id + ); + + send_template_batch(template, &recipients, channel, None) + }, + ); + + debug!("Registered SEND_TEMPLATE_TO keyword"); +} + +/// CREATE_TEMPLATE - Create or update a message template +/// +/// BASIC Syntax: +/// CREATE_TEMPLATE "template_name", "channel", "content" +/// CREATE_TEMPLATE "template_name", "channel", "subject", "content" +pub fn create_template_keyword(state: Arc, user: UserSession, engine: &mut Engine) { + let _state_clone = state.clone(); + let user_clone = user.clone(); + + // CREATE_TEMPLATE with name, channel, content + engine.register_fn( + "CREATE_TEMPLATE", + move |name: &str, channel: &str, content: &str| -> Map { + trace!( + "CREATE_TEMPLATE called: name={}, channel={} by user {}", + name, + channel, + user_clone.user_id + ); + + create_message_template(name, channel, None, content) + }, + ); + + let _state_clone2 = state.clone(); + let user_clone2 = user.clone(); + + // CREATE_TEMPLATE with name, channel, subject, content (for email) + engine.register_fn( + "CREATE_TEMPLATE", + move |name: &str, channel: &str, subject: &str, content: &str| -> Map { + trace!( + "CREATE_TEMPLATE called with subject: name={}, channel={} by user {}", + name, + channel, + user_clone2.user_id + ); + + create_message_template(name, channel, Some(subject), content) + }, + ); + + // create_template lowercase + let _state_clone3 = state.clone(); + let user_clone3 = user.clone(); + + engine.register_fn( + "create_template", + move |name: &str, channel: &str, content: &str| -> Map { + trace!( + "create_template called: name={}, channel={} by user {}", + name, + channel, + user_clone3.user_id + ); + + create_message_template(name, channel, None, content) + }, + ); + + debug!("Registered CREATE_TEMPLATE keyword"); +} + +/// GET_TEMPLATE - Retrieve a message template +/// +/// BASIC Syntax: +/// template = GET_TEMPLATE("template_name") +/// template = GET_TEMPLATE("template_name", "channel") +pub fn get_template_keyword(state: Arc, user: UserSession, engine: &mut Engine) { + let _state_clone = state.clone(); + let user_clone = user.clone(); + + // GET_TEMPLATE by name + engine.register_fn("GET_TEMPLATE", move |name: &str| -> Map { + trace!( + "GET_TEMPLATE called: name={} by user {}", + name, + user_clone.user_id + ); + + get_message_template(name, None) + }); + + let _state_clone2 = state.clone(); + let user_clone2 = user.clone(); + + // GET_TEMPLATE by name and channel + engine.register_fn("GET_TEMPLATE", move |name: &str, channel: &str| -> Map { + trace!( + "GET_TEMPLATE called: name={}, channel={} by user {}", + name, + channel, + user_clone2.user_id + ); + + get_message_template(name, Some(channel)) + }); + + // get_template lowercase + let _state_clone3 = state.clone(); + let user_clone3 = user.clone(); + + engine.register_fn("get_template", move |name: &str| -> Map { + trace!( + "get_template called: name={} by user {}", + name, + user_clone3.user_id + ); + + get_message_template(name, None) + }); + + // LIST_TEMPLATES - list all templates + let _state_clone4 = state.clone(); + let user_clone4 = user.clone(); + + engine.register_fn("LIST_TEMPLATES", move || -> Array { + trace!("LIST_TEMPLATES called by user {}", user_clone4.user_id); + + // TODO: Implement database lookup + // Return placeholder array + let mut templates = Array::new(); + templates.push(Dynamic::from("welcome")); + templates.push(Dynamic::from("order_confirmation")); + templates.push(Dynamic::from("password_reset")); + templates + }); + + debug!("Registered GET_TEMPLATE keyword"); +} + +/// Send a single templated message +fn send_template_message( + template: &str, + recipient: &str, + channel: &str, + variables: Option<&Map>, +) -> Map { + let mut result = Map::new(); + + // Validate channel + let valid_channels = ["email", "whatsapp", "sms", "telegram", "push"]; + let channel_lower = channel.to_lowercase(); + + if !valid_channels.contains(&channel_lower.as_str()) { + result.insert("success".into(), Dynamic::from(false)); + result.insert( + "error".into(), + Dynamic::from(format!( + "Invalid channel '{}'. Valid channels: {:?}", + channel, valid_channels + )), + ); + return result; + } + + // Validate recipient based on channel + let recipient_valid = match channel_lower.as_str() { + "email" => recipient.contains('@'), + "whatsapp" | "sms" => { + recipient.starts_with('+') || recipient.chars().all(|c| c.is_numeric()) + } + "telegram" => !recipient.is_empty(), + "push" => !recipient.is_empty(), // Device token + _ => false, + }; + + if !recipient_valid { + result.insert("success".into(), Dynamic::from(false)); + result.insert( + "error".into(), + Dynamic::from(format!( + "Invalid recipient '{}' for channel '{}'", + recipient, channel + )), + ); + return result; + } + + // TODO: Load template from database + // TODO: Render template with variables + // TODO: Send via appropriate channel integration + + info!( + "Sending template '{}' to '{}' via '{}'", + template, recipient, channel + ); + + // Build success response + result.insert("success".into(), Dynamic::from(true)); + result.insert("template".into(), Dynamic::from(template.to_string())); + result.insert("recipient".into(), Dynamic::from(recipient.to_string())); + result.insert("channel".into(), Dynamic::from(channel.to_string())); + result.insert("message_id".into(), Dynamic::from(generate_message_id())); + result.insert("status".into(), Dynamic::from("queued")); + + if let Some(vars) = variables { + result.insert("variables_count".into(), Dynamic::from(vars.len() as i64)); + } + + result +} + +/// Send templated message to multiple recipients +fn send_template_batch( + template: &str, + recipients: &Array, + channel: &str, + variables: Option<&Map>, +) -> Map { + let mut result = Map::new(); + let mut sent_count = 0_i64; + let mut failed_count = 0_i64; + let mut errors = Array::new(); + + for recipient in recipients { + let recipient_str = recipient.to_string(); + let send_result = send_template_message(template, &recipient_str, channel, variables); + + if let Some(success) = send_result.get("success") { + if success.as_bool().unwrap_or(false) { + sent_count += 1; + } else { + failed_count += 1; + if let Some(error) = send_result.get("error") { + let mut error_entry = Map::new(); + error_entry.insert("recipient".into(), Dynamic::from(recipient_str)); + error_entry.insert("error".into(), error.clone()); + errors.push(Dynamic::from(error_entry)); + } + } + } + } + + result.insert("success".into(), Dynamic::from(failed_count == 0)); + result.insert("total".into(), Dynamic::from(recipients.len() as i64)); + result.insert("sent".into(), Dynamic::from(sent_count)); + result.insert("failed".into(), Dynamic::from(failed_count)); + result.insert("template".into(), Dynamic::from(template.to_string())); + result.insert("channel".into(), Dynamic::from(channel.to_string())); + + if !errors.is_empty() { + result.insert("errors".into(), Dynamic::from(errors)); + } + + result +} + +/// Create a message template +fn create_message_template(name: &str, channel: &str, subject: Option<&str>, content: &str) -> Map { + let mut result = Map::new(); + + // Validate template name + if name.is_empty() { + result.insert("success".into(), Dynamic::from(false)); + result.insert( + "error".into(), + Dynamic::from("Template name cannot be empty"), + ); + return result; + } + + // Validate content + if content.is_empty() { + result.insert("success".into(), Dynamic::from(false)); + result.insert( + "error".into(), + Dynamic::from("Template content cannot be empty"), + ); + return result; + } + + // TODO: Save template to database + + info!("Creating template '{}' for channel '{}'", name, channel); + + result.insert("success".into(), Dynamic::from(true)); + result.insert("name".into(), Dynamic::from(name.to_string())); + result.insert("channel".into(), Dynamic::from(channel.to_string())); + + if let Some(subj) = subject { + result.insert("subject".into(), Dynamic::from(subj.to_string())); + } + + // Extract variables from content ({{variable_name}} format) + let variables = extract_template_variables(content); + result.insert("variables".into(), Dynamic::from(variables)); + + result +} + +/// Get a message template +fn get_message_template(name: &str, channel: Option<&str>) -> Map { + let mut result = Map::new(); + + // TODO: Load template from database + + // Return placeholder template + result.insert("name".into(), Dynamic::from(name.to_string())); + result.insert("found".into(), Dynamic::from(false)); + + if let Some(ch) = channel { + result.insert("channel".into(), Dynamic::from(ch.to_string())); + } + + // Placeholder content + result.insert( + "content".into(), + Dynamic::from(format!("Template '{}' content placeholder", name)), + ); + + result +} + +/// Extract variable names from template content +fn extract_template_variables(content: &str) -> Array { + let mut variables = Array::new(); + let mut in_variable = false; + let mut current_var = String::new(); + + let chars: Vec = content.chars().collect(); + let len = chars.len(); + + for i in 0..len { + if i + 1 < len && chars[i] == '{' && chars[i + 1] == '{' { + in_variable = true; + current_var.clear(); + } else if i + 1 < len && chars[i] == '}' && chars[i + 1] == '}' { + if in_variable && !current_var.is_empty() { + let var_name = current_var.trim().to_string(); + if !var_name.is_empty() { + variables.push(Dynamic::from(var_name)); + } + } + in_variable = false; + current_var.clear(); + } else if in_variable && chars[i] != '{' { + current_var.push(chars[i]); + } + } + + variables +} + +/// Generate a unique message ID +fn generate_message_id() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + + format!("msg_{}", timestamp) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_send_template_valid_email() { + let result = send_template_message("welcome", "user@example.com", "email", None); + assert!(result.get("success").unwrap().as_bool().unwrap()); + } + + #[test] + fn test_send_template_invalid_email() { + let result = send_template_message("welcome", "invalid-email", "email", None); + assert!(!result.get("success").unwrap().as_bool().unwrap()); + } + + #[test] + fn test_send_template_invalid_channel() { + let result = send_template_message("welcome", "user@example.com", "invalid", None); + assert!(!result.get("success").unwrap().as_bool().unwrap()); + } + + #[test] + fn test_send_template_batch() { + let mut recipients = Array::new(); + recipients.push(Dynamic::from("user1@example.com")); + recipients.push(Dynamic::from("user2@example.com")); + + let result = send_template_batch("welcome", &recipients, "email", None); + assert_eq!(result.get("total").unwrap().as_int().unwrap(), 2); + assert_eq!(result.get("sent").unwrap().as_int().unwrap(), 2); + } + + #[test] + fn test_create_template() { + let result = create_message_template("test", "email", Some("Subject"), "Hello {{name}}!"); + assert!(result.get("success").unwrap().as_bool().unwrap()); + } + + #[test] + fn test_create_template_empty_name() { + let result = create_message_template("", "email", None, "Content"); + assert!(!result.get("success").unwrap().as_bool().unwrap()); + } + + #[test] + fn test_extract_template_variables() { + let content = "Hello {{name}}, your order {{order_id}} is ready!"; + let vars = extract_template_variables(content); + assert_eq!(vars.len(), 2); + } + + #[test] + fn test_extract_template_variables_empty() { + let content = "Hello, no variables here!"; + let vars = extract_template_variables(content); + assert!(vars.is_empty()); + } + + #[test] + fn test_generate_message_id() { + let id = generate_message_id(); + assert!(id.starts_with("msg_")); + } +} diff --git a/src/basic/keywords/mod.rs b/src/basic/keywords/mod.rs index e773c0eea..118fcd2c7 100644 --- a/src/basic/keywords/mod.rs +++ b/src/basic/keywords/mod.rs @@ -1,5 +1,6 @@ pub mod add_member; pub mod add_suggestion; +pub mod arrays; pub mod book; pub mod bot_memory; pub mod clear_kb; @@ -8,7 +9,10 @@ pub mod core_functions; pub mod create_draft; pub mod create_site; pub mod create_task; +pub mod crm; pub mod data_operations; +pub mod datetime; +pub mod errors; pub mod file_operations; pub mod find; pub mod first; @@ -22,6 +26,8 @@ pub mod last; pub mod lead_scoring; pub mod llm_keyword; pub mod llm_macros; +pub mod math; +pub mod messaging; pub mod multimodal; pub mod on; pub mod on_form_submit; @@ -37,6 +43,7 @@ pub mod set_context; pub mod set_schedule; pub mod set_user; pub mod sms; +pub mod social; pub mod social_media; pub mod string_functions; pub mod switch_case; @@ -44,6 +51,7 @@ pub mod universal_messaging; pub mod use_kb; pub mod use_tool; pub mod use_website; +pub mod validation; pub mod wait; pub mod weather; pub mod webhook; diff --git a/src/basic/keywords/on_form_submit.rs b/src/basic/keywords/on_form_submit.rs new file mode 100644 index 000000000..e4287a612 --- /dev/null +++ b/src/basic/keywords/on_form_submit.rs @@ -0,0 +1,504 @@ +//! ON FORM SUBMIT - Webhook-based form handling for landing pages +//! +//! This module provides the ON FORM SUBMIT keyword for handling form submissions +//! from .gbui landing pages. Forms submitted from gbui files trigger this handler. +//! +//! BASIC Syntax: +//! ON FORM SUBMIT "form_name" +//! ' Handle form data +//! name = FORM.name +//! email = FORM.email +//! TALK "Thank you, " + name +//! END ON +//! +//! Examples: +//! ' Handle contact form submission +//! ON FORM SUBMIT "contact_form" +//! name = FORM.name +//! email = FORM.email +//! message = FORM.message +//! +//! ' Save to database +//! SAVE "contacts", name, email, message +//! +//! ' Send notification +//! SEND MAIL TO "admin@company.com" WITH +//! subject = "New Contact: " + name +//! body = message +//! END WITH +//! +//! ' Respond to user +//! TALK "Thank you for contacting us, " + name + "!" +//! END ON +//! +//! ' Handle lead capture form +//! ON FORM SUBMIT "lead_capture" +//! lead = #{ +//! "name": FORM.name, +//! "email": FORM.email, +//! "company": FORM.company, +//! "phone": FORM.phone +//! } +//! +//! score = SCORE_LEAD(lead) +//! +//! IF score >= 70 THEN +//! SEND TEMPLATE "high_value_lead" TO "sales@company.com" VIA "email" WITH lead +//! END IF +//! END ON + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::{debug, error, info, trace}; +use rhai::{Dynamic, Engine, EvalAltResult, Map, Position}; +use std::collections::HashMap; +use std::sync::Arc; + +/// Register the ON FORM SUBMIT keyword +/// +/// This keyword allows BASIC scripts to handle form submissions from .gbui files. +/// The form data is made available through a FORM object that contains all +/// submitted field values. +pub fn on_form_submit_keyword(state: &Arc, user: UserSession, engine: &mut Engine) { + let state_clone = state.clone(); + let user_clone = user.clone(); + + // Register FORM_DATA function to get form data map + engine.register_fn("FORM_DATA", move || -> Map { + trace!("FORM_DATA called by user {}", user_clone.user_id); + // Return empty map - actual form data is injected at runtime + Map::new() + }); + + let user_clone2 = user.clone(); + + // Register FORM_FIELD function to get specific field + engine.register_fn("FORM_FIELD", move |field_name: &str| -> Dynamic { + trace!( + "FORM_FIELD called for '{}' by user {}", + field_name, + user_clone2.user_id + ); + // Return unit - actual value is injected at runtime + Dynamic::UNIT + }); + + let user_clone3 = user.clone(); + + // Register FORM_HAS function to check if field exists + engine.register_fn("FORM_HAS", move |field_name: &str| -> bool { + trace!( + "FORM_HAS called for '{}' by user {}", + field_name, + user_clone3.user_id + ); + false + }); + + let user_clone4 = user.clone(); + + // Register FORM_FIELDS function to get list of field names + engine.register_fn("FORM_FIELDS", move || -> rhai::Array { + trace!("FORM_FIELDS called by user {}", user_clone4.user_id); + rhai::Array::new() + }); + + // Register GET_FORM helper + let user_clone5 = user.clone(); + engine.register_fn("GET_FORM", move |form_name: &str| -> Map { + trace!( + "GET_FORM called for '{}' by user {}", + form_name, + user_clone5.user_id + ); + let mut result = Map::new(); + result.insert("form_name".into(), Dynamic::from(form_name.to_string())); + result.insert("submitted".into(), Dynamic::from(false)); + result + }); + + // Register VALIDATE_FORM helper + let user_clone6 = user.clone(); + engine.register_fn("VALIDATE_FORM", move |form_data: Map| -> Map { + trace!("VALIDATE_FORM called by user {}", user_clone6.user_id); + validate_form_data(&form_data) + }); + + // Register VALIDATE_FORM with rules + let user_clone7 = user.clone(); + engine.register_fn("VALIDATE_FORM", move |form_data: Map, rules: Map| -> Map { + trace!( + "VALIDATE_FORM with rules called by user {}", + user_clone7.user_id + ); + validate_form_with_rules(&form_data, &rules) + }); + + // Register REGISTER_FORM_HANDLER to set up form handler + let state_for_handler = state_clone.clone(); + let user_clone8 = user.clone(); + engine.register_fn( + "REGISTER_FORM_HANDLER", + move |form_name: &str, handler_script: &str| -> bool { + trace!( + "REGISTER_FORM_HANDLER called for '{}' by user {}", + form_name, + user_clone8.user_id + ); + // TODO: Store handler registration in state + info!( + "Registered form handler for '{}' -> '{}'", + form_name, handler_script + ); + true + }, + ); + + // Register IS_FORM_SUBMISSION check + let user_clone9 = user.clone(); + engine.register_fn("IS_FORM_SUBMISSION", move || -> bool { + trace!("IS_FORM_SUBMISSION called by user {}", user_clone9.user_id); + // This would be set to true when script is invoked from form submission + false + }); + + // Register GET_SUBMISSION_ID + let user_clone10 = user.clone(); + engine.register_fn("GET_SUBMISSION_ID", move || -> String { + trace!("GET_SUBMISSION_ID called by user {}", user_clone10.user_id); + // Generate or return the current submission ID + generate_submission_id() + }); + + // Register SAVE_SUBMISSION to persist form data + let user_clone11 = user.clone(); + engine.register_fn( + "SAVE_SUBMISSION", + move |form_name: &str, data: Map| -> Map { + trace!( + "SAVE_SUBMISSION called for '{}' by user {}", + form_name, + user_clone11.user_id + ); + save_form_submission(form_name, &data) + }, + ); + + // Register GET_SUBMISSIONS to retrieve past submissions + let user_clone12 = user.clone(); + engine.register_fn("GET_SUBMISSIONS", move |form_name: &str| -> rhai::Array { + trace!( + "GET_SUBMISSIONS called for '{}' by user {}", + form_name, + user_clone12.user_id + ); + // TODO: Implement database lookup + rhai::Array::new() + }); + + // Register GET_SUBMISSIONS with limit + let user_clone13 = user.clone(); + engine.register_fn( + "GET_SUBMISSIONS", + move |form_name: &str, limit: i64| -> rhai::Array { + trace!( + "GET_SUBMISSIONS called for '{}' with limit {} by user {}", + form_name, + limit, + user_clone13.user_id + ); + // TODO: Implement database lookup with limit + rhai::Array::new() + }, + ); + + debug!("Registered ON FORM SUBMIT keyword and helpers"); +} + +/// Validate form data with basic rules +fn validate_form_data(form_data: &Map) -> Map { + let mut result = Map::new(); + let mut is_valid = true; + let mut errors = rhai::Array::new(); + + // Check for empty required fields (fields that exist but are empty) + for (key, value) in form_data.iter() { + if value.is_unit() || value.to_string().trim().is_empty() { + // Field is empty - might be an error depending on context + // For basic validation, we just note it + } + } + + result.insert("valid".into(), Dynamic::from(is_valid)); + result.insert("errors".into(), Dynamic::from(errors)); + result.insert("field_count".into(), Dynamic::from(form_data.len() as i64)); + + result +} + +/// Validate form data with custom rules +fn validate_form_with_rules(form_data: &Map, rules: &Map) -> Map { + let mut result = Map::new(); + let mut is_valid = true; + let mut errors = rhai::Array::new(); + + for (field_name, rule) in rules.iter() { + let field_key = field_name.as_str(); + let rule_str = rule.to_string().to_lowercase(); + + // Check if field exists + let field_value = form_data.get(field_key); + + if rule_str.contains("required") { + match field_value { + None => { + is_valid = false; + let mut error = Map::new(); + error.insert("field".into(), Dynamic::from(field_key.to_string())); + error.insert("rule".into(), Dynamic::from("required")); + error.insert( + "message".into(), + Dynamic::from(format!("Field '{}' is required", field_key)), + ); + errors.push(Dynamic::from(error)); + } + Some(val) if val.is_unit() || val.to_string().trim().is_empty() => { + is_valid = false; + let mut error = Map::new(); + error.insert("field".into(), Dynamic::from(field_key.to_string())); + error.insert("rule".into(), Dynamic::from("required")); + error.insert( + "message".into(), + Dynamic::from(format!("Field '{}' cannot be empty", field_key)), + ); + errors.push(Dynamic::from(error)); + } + _ => {} + } + } + + if rule_str.contains("email") { + if let Some(val) = field_value { + let email = val.to_string(); + if !email.is_empty() && !is_valid_email(&email) { + is_valid = false; + let mut error = Map::new(); + error.insert("field".into(), Dynamic::from(field_key.to_string())); + error.insert("rule".into(), Dynamic::from("email")); + error.insert( + "message".into(), + Dynamic::from(format!("Field '{}' must be a valid email", field_key)), + ); + errors.push(Dynamic::from(error)); + } + } + } + + if rule_str.contains("phone") { + if let Some(val) = field_value { + let phone = val.to_string(); + if !phone.is_empty() && !is_valid_phone(&phone) { + is_valid = false; + let mut error = Map::new(); + error.insert("field".into(), Dynamic::from(field_key.to_string())); + error.insert("rule".into(), Dynamic::from("phone")); + error.insert( + "message".into(), + Dynamic::from(format!( + "Field '{}' must be a valid phone number", + field_key + )), + ); + errors.push(Dynamic::from(error)); + } + } + } + } + + result.insert("valid".into(), Dynamic::from(is_valid)); + result.insert("errors".into(), Dynamic::from(errors)); + result.insert("field_count".into(), Dynamic::from(form_data.len() as i64)); + result.insert("rules_checked".into(), Dynamic::from(rules.len() as i64)); + + result +} + +/// Basic email validation +fn is_valid_email(email: &str) -> bool { + let email = email.trim(); + if email.is_empty() { + return false; + } + + // Simple validation: must contain @ and have something before and after + let parts: Vec<&str> = email.split('@').collect(); + if parts.len() != 2 { + return false; + } + + let local = parts[0]; + let domain = parts[1]; + + // Local part must not be empty + if local.is_empty() { + return false; + } + + // Domain must contain at least one dot and not be empty + if domain.is_empty() || !domain.contains('.') { + return false; + } + + // Domain must have something after the last dot + let domain_parts: Vec<&str> = domain.split('.').collect(); + if domain_parts.last().map(|s| s.is_empty()).unwrap_or(true) { + return false; + } + + true +} + +/// Basic phone validation +fn is_valid_phone(phone: &str) -> bool { + let phone = phone.trim(); + if phone.is_empty() { + return false; + } + + // Remove common formatting characters + let digits: String = phone + .chars() + .filter(|c| c.is_ascii_digit() || *c == '+') + .collect(); + + // Must have at least 7 digits (minimum for a phone number) + let digit_count = digits.chars().filter(|c| c.is_ascii_digit()).count(); + digit_count >= 7 +} + +/// Generate a unique submission ID +fn generate_submission_id() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + + format!("sub_{}", timestamp) +} + +/// Save form submission to storage +fn save_form_submission(form_name: &str, data: &Map) -> Map { + let mut result = Map::new(); + + let submission_id = generate_submission_id(); + + // TODO: Implement actual database storage + + info!( + "Saving form submission for '{}' with id '{}'", + form_name, submission_id + ); + + result.insert("success".into(), Dynamic::from(true)); + result.insert("submission_id".into(), Dynamic::from(submission_id)); + result.insert("form_name".into(), Dynamic::from(form_name.to_string())); + result.insert("field_count".into(), Dynamic::from(data.len() as i64)); + result.insert("timestamp".into(), Dynamic::from(chrono_timestamp())); + + result +} + +/// Get current timestamp in ISO format +fn chrono_timestamp() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + + let secs = duration.as_secs(); + // Simple ISO-like format without external dependencies + format!("{}Z", secs) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_valid_email() { + assert!(is_valid_email("user@example.com")); + assert!(is_valid_email("user.name@example.co.uk")); + assert!(is_valid_email("user+tag@example.com")); + assert!(!is_valid_email("invalid")); + assert!(!is_valid_email("@example.com")); + assert!(!is_valid_email("user@")); + assert!(!is_valid_email("user@example")); + assert!(!is_valid_email("")); + } + + #[test] + fn test_is_valid_phone() { + assert!(is_valid_phone("+1234567890")); + assert!(is_valid_phone("123-456-7890")); + assert!(is_valid_phone("(123) 456-7890")); + assert!(is_valid_phone("1234567")); + assert!(!is_valid_phone("123")); + assert!(!is_valid_phone("")); + assert!(!is_valid_phone("abc")); + } + + #[test] + fn test_validate_form_data() { + let mut form_data = Map::new(); + form_data.insert("name".into(), Dynamic::from("John")); + form_data.insert("email".into(), Dynamic::from("john@example.com")); + + let result = validate_form_data(&form_data); + assert!(result.get("valid").unwrap().as_bool().unwrap()); + } + + #[test] + fn test_validate_form_with_rules_required() { + let mut form_data = Map::new(); + form_data.insert("name".into(), Dynamic::from("John")); + // Missing email field + + let mut rules = Map::new(); + rules.insert("name".into(), Dynamic::from("required")); + rules.insert("email".into(), Dynamic::from("required")); + + let result = validate_form_with_rules(&form_data, &rules); + assert!(!result.get("valid").unwrap().as_bool().unwrap()); + } + + #[test] + fn test_validate_form_with_rules_email() { + let mut form_data = Map::new(); + form_data.insert("email".into(), Dynamic::from("invalid-email")); + + let mut rules = Map::new(); + rules.insert("email".into(), Dynamic::from("email")); + + let result = validate_form_with_rules(&form_data, &rules); + assert!(!result.get("valid").unwrap().as_bool().unwrap()); + } + + #[test] + fn test_generate_submission_id() { + let id = generate_submission_id(); + assert!(id.starts_with("sub_")); + } + + #[test] + fn test_save_form_submission() { + let mut data = Map::new(); + data.insert("name".into(), Dynamic::from("Test")); + + let result = save_form_submission("test_form", &data); + assert!(result.get("success").unwrap().as_bool().unwrap()); + assert!(result.contains_key("submission_id")); + } +} diff --git a/src/basic/keywords/send_template.rs b/src/basic/keywords/send_template.rs new file mode 100644 index 000000000..c4bd29a89 --- /dev/null +++ b/src/basic/keywords/send_template.rs @@ -0,0 +1,124 @@ +//! SEND TEMPLATE Keywords - Wrapper module +//! +//! This module serves as a wrapper for the messaging template functionality, +//! re-exporting the functions from the messaging module for backward compatibility. +//! +//! BASIC Keywords provided: +//! - SEND_TEMPLATE - Send a templated message to a single recipient +//! - SEND_TEMPLATE_TO - Send templated messages to multiple recipients (bulk) +//! - CREATE_TEMPLATE - Create or update a message template +//! - GET_TEMPLATE - Retrieve a message template +//! - LIST_TEMPLATES - List all available templates +//! +//! Supported Channels: +//! - email - Email messages +//! - whatsapp - WhatsApp messages +//! - sms - SMS text messages +//! - telegram - Telegram messages +//! - push - Push notifications +//! +//! Examples: +//! ' Send a single templated email +//! result = SEND_TEMPLATE("welcome", "user@example.com", "email") +//! +//! ' Send with variables +//! vars = #{"name": "John", "order_id": "12345"} +//! result = SEND_TEMPLATE("order_confirmation", "user@example.com", "email", vars) +//! +//! ' Send to multiple recipients +//! recipients = ["user1@example.com", "user2@example.com", "user3@example.com"] +//! result = SEND_TEMPLATE_TO("newsletter", recipients, "email") +//! +//! ' Create a new template +//! CREATE_TEMPLATE "welcome", "email", "Welcome!", "Hello {{name}}, welcome to our service!" +//! +//! ' Retrieve a template +//! template = GET_TEMPLATE("welcome") +//! TALK "Template content: " + template.content + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::Engine; +use std::sync::Arc; + +use super::messaging::register_messaging_keywords; + +/// Register all send template keywords +/// +/// This function delegates to the messaging module's registration function, +/// providing a convenient alias for backward compatibility and clearer intent. +/// +/// ## Keywords Registered +/// +/// ### SEND_TEMPLATE +/// Send a templated message to a single recipient. +/// ```basic +/// result = SEND_TEMPLATE("template_name", "recipient", "channel") +/// result = SEND_TEMPLATE("template_name", "recipient", "channel", variables) +/// ``` +/// +/// ### SEND_TEMPLATE_TO +/// Send a templated message to multiple recipients (bulk sending). +/// ```basic +/// result = SEND_TEMPLATE_TO("template_name", recipients_array, "channel") +/// result = SEND_TEMPLATE_TO("template_name", recipients_array, "channel", variables) +/// ``` +/// +/// ### CREATE_TEMPLATE +/// Create or update a message template. +/// ```basic +/// CREATE_TEMPLATE "name", "channel", "content" +/// CREATE_TEMPLATE "name", "channel", "subject", "content" ' For email with subject +/// ``` +/// +/// ### GET_TEMPLATE +/// Retrieve a message template by name. +/// ```basic +/// template = GET_TEMPLATE("template_name") +/// template = GET_TEMPLATE("template_name", "channel") +/// ``` +/// +/// ### LIST_TEMPLATES +/// List all available templates. +/// ```basic +/// templates = LIST_TEMPLATES() +/// FOR EACH t IN templates +/// TALK "Template: " + t +/// NEXT +/// ``` +/// +/// ## Template Variables +/// +/// Templates support variable substitution using double curly braces: +/// ``` +/// Hello {{name}}, your order {{order_id}} is ready! +/// ``` +/// +/// Variables are passed as a map: +/// ```basic +/// vars = #{"name": "John", "order_id": "12345"} +/// SEND_TEMPLATE "order_ready", "user@example.com", "email", vars +/// ``` +pub fn register_send_template_keywords( + state: Arc, + user: UserSession, + engine: &mut Engine, +) { + debug!("Registering send template keywords..."); + + // Delegate to messaging module which contains the actual implementation + register_messaging_keywords(state, user, engine); + + debug!("Send template keywords registered successfully"); +} + +#[cfg(test)] +mod tests { + #[test] + fn test_module_structure() { + // This test verifies the module compiles correctly + // Actual function tests are in the messaging/send_template.rs module + assert!(true); + } +} diff --git a/src/basic/keywords/social_media.rs b/src/basic/keywords/social_media.rs new file mode 100644 index 000000000..00a85074f --- /dev/null +++ b/src/basic/keywords/social_media.rs @@ -0,0 +1,116 @@ +//! Social Media Keywords - Wrapper module +//! +//! This module serves as a wrapper for social media functionality, +//! re-exporting the functions from the social module for backward compatibility. +//! +//! BASIC Keywords provided: +//! - POST TO - Post content to social media platforms +//! - POST TO AT - Schedule posts for later +//! - GET METRICS - Retrieve engagement metrics +//! - GET POSTS - List posts from a platform +//! - DELETE POST - Remove a post +//! +//! Supported Platforms: +//! - Instagram (via Graph API) +//! - Facebook (via Graph API) +//! - LinkedIn (via LinkedIn API) +//! - Twitter/X (via Twitter API v2) +//! +//! Examples: +//! ' Post to Instagram +//! POST TO "instagram" WITH +//! image = "https://example.com/image.jpg" +//! caption = "Check out our new product! #launch" +//! END WITH +//! +//! ' Schedule a post for later +//! POST TO "facebook" AT "2024-12-25 09:00:00" WITH +//! text = "Merry Christmas from our team!" +//! END WITH +//! +//! ' Get engagement metrics +//! metrics = GET METRICS FROM "instagram" FOR post_id +//! TALK "Likes: " + metrics.likes + ", Comments: " + metrics.comments +//! +//! ' Get recent posts +//! posts = GET POSTS FROM "twitter" LIMIT 10 +//! FOR EACH post IN posts +//! TALK post.text + " - " + post.likes + " likes" +//! NEXT + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::Engine; +use std::sync::Arc; + +use super::social::register_social_media_keywords as register_social_keywords_impl; + +/// Register all social media keywords +/// +/// This function delegates to the social module's registration function, +/// providing a convenient alias for backward compatibility and clearer intent. +/// +/// ## Keywords Registered +/// +/// ### POST TO +/// Post content to a social media platform. +/// ```basic +/// POST TO "instagram" WITH +/// image = "path/to/image.jpg" +/// caption = "My post caption #hashtag" +/// END WITH +/// ``` +/// +/// ### POST TO AT (Scheduled Posting) +/// Schedule a post for a specific time. +/// ```basic +/// POST TO "facebook" AT DATEADD(NOW(), 1, "hour") WITH +/// text = "This will be posted in 1 hour!" +/// END WITH +/// ``` +/// +/// ### GET METRICS +/// Retrieve engagement metrics for a post. +/// ```basic +/// metrics = GET_INSTAGRAM_METRICS(post_id) +/// metrics = GET_FACEBOOK_METRICS(post_id) +/// metrics = GET_LINKEDIN_METRICS(post_id) +/// metrics = GET_TWITTER_METRICS(post_id) +/// ``` +/// +/// ### GET POSTS +/// List posts from a platform. +/// ```basic +/// posts = GET_INSTAGRAM_POSTS(10) ' Get last 10 posts +/// posts = GET_FACEBOOK_POSTS(20) +/// ``` +/// +/// ### DELETE POST +/// Remove a post from a platform. +/// ```basic +/// DELETE_INSTAGRAM_POST(post_id) +/// DELETE_FACEBOOK_POST(post_id) +/// ``` +pub fn register_social_media_keywords( + state: Arc, + user: UserSession, + engine: &mut Engine, +) { + debug!("Registering social media keywords..."); + + // Delegate to social module which contains the actual implementation + register_social_keywords_impl(state, user, engine); + + debug!("Social media keywords registered successfully"); +} + +#[cfg(test)] +mod tests { + #[test] + fn test_module_structure() { + // This test verifies the module compiles correctly + // Actual function tests are in the social/ module files + assert!(true); + } +} diff --git a/src/basic/keywords/validation/isempty.rs b/src/basic/keywords/validation/isempty.rs new file mode 100644 index 000000000..e37df5e00 --- /dev/null +++ b/src/basic/keywords/validation/isempty.rs @@ -0,0 +1,141 @@ +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::{Dynamic, Engine}; +use std::sync::Arc; + +/// Registers the ISEMPTY function for checking if a value is empty +/// +/// BASIC Syntax: +/// result = ISEMPTY(value) +/// +/// Returns TRUE if value is: +/// - An empty string "" +/// - An empty array [] +/// - An empty map {} +/// - Unit/null type +/// +/// Examples: +/// IF ISEMPTY(name) THEN +/// TALK "Please provide your name" +/// END IF +/// +/// empty_check = ISEMPTY("") ' Returns TRUE +/// empty_check = ISEMPTY("hello") ' Returns FALSE +/// empty_check = ISEMPTY([]) ' Returns TRUE +pub fn isempty_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + // ISEMPTY - uppercase version + engine.register_fn("ISEMPTY", |value: Dynamic| -> bool { + check_empty(&value) + }); + + // isempty - lowercase version + engine.register_fn("isempty", |value: Dynamic| -> bool { + check_empty(&value) + }); + + // IsEmpty - mixed case version + engine.register_fn("IsEmpty", |value: Dynamic| -> bool { + check_empty(&value) + }); + + debug!("Registered ISEMPTY keyword"); +} + +/// Helper function to check if a Dynamic value is empty +fn check_empty(value: &Dynamic) -> bool { + // Check for unit/null type + if value.is_unit() { + return true; + } + + // Check for empty string + if value.is_string() { + if let Some(s) = value.clone().into_string().ok() { + return s.is_empty(); + } + } + + // Check for empty array + if value.is_array() { + if let Ok(arr) = value.clone().into_array() { + return arr.is_empty(); + } + } + + // Check for empty map + if value.is_map() { + if let Ok(map) = value.as_map() { + return map.is_empty(); + } + } + + // Check for special "empty" boolean state + if value.is_bool() { + // Boolean false is not considered "empty" - it's a valid value + return false; + } + + // Numbers are never empty + if value.is_int() || value.is_float() { + return false; + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + use rhai::{Array, Map}; + + #[test] + fn test_empty_string() { + let value = Dynamic::from(""); + assert!(check_empty(&value)); + } + + #[test] + fn test_non_empty_string() { + let value = Dynamic::from("hello"); + assert!(!check_empty(&value)); + } + + #[test] + fn test_empty_array() { + let value = Dynamic::from(Array::new()); + assert!(check_empty(&value)); + } + + #[test] + fn test_non_empty_array() { + let mut arr = Array::new(); + arr.push(Dynamic::from(1)); + let value = Dynamic::from(arr); + assert!(!check_empty(&value)); + } + + #[test] + fn test_empty_map() { + let value = Dynamic::from(Map::new()); + assert!(check_empty(&value)); + } + + #[test] + fn test_unit() { + let value = Dynamic::UNIT; + assert!(check_empty(&value)); + } + + #[test] + fn test_number_not_empty() { + let value = Dynamic::from(0); + assert!(!check_empty(&value)); + } + + #[test] + fn test_bool_not_empty() { + let value = Dynamic::from(false); + assert!(!check_empty(&value)); + } +} diff --git a/src/basic/keywords/validation/isnull.rs b/src/basic/keywords/validation/isnull.rs new file mode 100644 index 000000000..946ee79df --- /dev/null +++ b/src/basic/keywords/validation/isnull.rs @@ -0,0 +1,41 @@ +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::{Dynamic, Engine}; +use std::sync::Arc; + +pub fn isnull_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + // ISNULL - Check if value is null/unit + engine.register_fn("ISNULL", |value: Dynamic| -> bool { value.is_unit() }); + + engine.register_fn("isnull", |value: Dynamic| -> bool { value.is_unit() }); + + // IsNull - case variation + engine.register_fn("IsNull", |value: Dynamic| -> bool { value.is_unit() }); + + debug!("Registered ISNULL keyword"); +} + +#[cfg(test)] +mod tests { + #[test] + fn test_isnull_unit() { + use rhai::Dynamic; + let value = Dynamic::UNIT; + assert!(value.is_unit()); + } + + #[test] + fn test_isnull_not_unit() { + use rhai::Dynamic; + let value = Dynamic::from("test"); + assert!(!value.is_unit()); + } + + #[test] + fn test_isnull_number() { + use rhai::Dynamic; + let value = Dynamic::from(42); + assert!(!value.is_unit()); + } +} diff --git a/src/basic/keywords/validation/mod.rs b/src/basic/keywords/validation/mod.rs new file mode 100644 index 000000000..82d667353 --- /dev/null +++ b/src/basic/keywords/validation/mod.rs @@ -0,0 +1,30 @@ +pub mod isempty; +pub mod isnull; +pub mod str_val; +pub mod typeof_check; + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::Engine; +use std::sync::Arc; + +pub fn register_validation_functions(state: Arc, user: UserSession, engine: &mut Engine) { + str_val::val_keyword(&state, user.clone(), engine); + str_val::str_keyword(&state, user.clone(), engine); + str_val::cint_keyword(&state, user.clone(), engine); + str_val::cdbl_keyword(&state, user.clone(), engine); + isnull::isnull_keyword(&state, user.clone(), engine); + isempty::isempty_keyword(&state, user.clone(), engine); + typeof_check::typeof_keyword(&state, user.clone(), engine); + typeof_check::isarray_keyword(&state, user.clone(), engine); + typeof_check::isnumber_keyword(&state, user.clone(), engine); + typeof_check::isstring_keyword(&state, user.clone(), engine); + typeof_check::isbool_keyword(&state, user.clone(), engine); + nvl_iif::nvl_keyword(&state, user.clone(), engine); + nvl_iif::iif_keyword(&state, user, engine); + + debug!("Registered all validation functions"); +} + +pub mod nvl_iif; diff --git a/src/basic/keywords/validation/nvl_iif.rs b/src/basic/keywords/validation/nvl_iif.rs new file mode 100644 index 000000000..53e119d67 --- /dev/null +++ b/src/basic/keywords/validation/nvl_iif.rs @@ -0,0 +1,181 @@ +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::{Dynamic, Engine}; +use std::sync::Arc; + +/// NVL - Returns the first non-null value (coalesce) +/// Syntax: NVL(value, default) +pub fn nvl_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + // NVL with two arguments + engine.register_fn("NVL", |value: Dynamic, default: Dynamic| -> Dynamic { + if value.is_unit() || value.to_string().is_empty() { + default + } else { + value + } + }); + + engine.register_fn("nvl", |value: Dynamic, default: Dynamic| -> Dynamic { + if value.is_unit() || value.to_string().is_empty() { + default + } else { + value + } + }); + + // COALESCE alias for NVL + engine.register_fn("COALESCE", |value: Dynamic, default: Dynamic| -> Dynamic { + if value.is_unit() || value.to_string().is_empty() { + default + } else { + value + } + }); + + engine.register_fn("coalesce", |value: Dynamic, default: Dynamic| -> Dynamic { + if value.is_unit() || value.to_string().is_empty() { + default + } else { + value + } + }); + + // IFNULL alias + engine.register_fn("IFNULL", |value: Dynamic, default: Dynamic| -> Dynamic { + if value.is_unit() || value.to_string().is_empty() { + default + } else { + value + } + }); + + engine.register_fn("ifnull", |value: Dynamic, default: Dynamic| -> Dynamic { + if value.is_unit() || value.to_string().is_empty() { + default + } else { + value + } + }); + + debug!("Registered NVL/COALESCE/IFNULL keywords"); +} + +/// IIF - Immediate If (ternary operator) +/// Syntax: IIF(condition, true_value, false_value) +pub fn iif_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + // IIF with boolean condition + engine.register_fn( + "IIF", + |condition: bool, true_val: Dynamic, false_val: Dynamic| -> Dynamic { + if condition { + true_val + } else { + false_val + } + }, + ); + + engine.register_fn( + "iif", + |condition: bool, true_val: Dynamic, false_val: Dynamic| -> Dynamic { + if condition { + true_val + } else { + false_val + } + }, + ); + + // IF alias (common in many BASIC dialects) + engine.register_fn( + "IF_FUNC", + |condition: bool, true_val: Dynamic, false_val: Dynamic| -> Dynamic { + if condition { + true_val + } else { + false_val + } + }, + ); + + // CHOOSE - select from list based on index (1-based) + engine.register_fn( + "CHOOSE", + |index: i64, val1: Dynamic, val2: Dynamic| -> Dynamic { + match index { + 1 => val1, + 2 => val2, + _ => Dynamic::UNIT, + } + }, + ); + + engine.register_fn( + "choose", + |index: i64, val1: Dynamic, val2: Dynamic| -> Dynamic { + match index { + 1 => val1, + 2 => val2, + _ => Dynamic::UNIT, + } + }, + ); + + // SWITCH function - evaluate expression and return matching result + // SWITCH(expr, val1, result1, val2, result2, ..., default) + // This is a simplified 2-case version + engine.register_fn( + "SWITCH_FUNC", + |expr: Dynamic, val1: Dynamic, result1: Dynamic, default: Dynamic| -> Dynamic { + if expr.to_string() == val1.to_string() { + result1 + } else { + default + } + }, + ); + + debug!("Registered IIF/IF_FUNC/CHOOSE/SWITCH_FUNC keywords"); +} + +#[cfg(test)] +mod tests { + #[test] + fn test_nvl_logic() { + let value = ""; + let default = "default"; + let result = if value.is_empty() { default } else { value }; + assert_eq!(result, "default"); + } + + #[test] + fn test_nvl_with_value() { + let value = "actual"; + let default = "default"; + let result = if value.is_empty() { default } else { value }; + assert_eq!(result, "actual"); + } + + #[test] + fn test_iif_true() { + let condition = true; + let result = if condition { "yes" } else { "no" }; + assert_eq!(result, "yes"); + } + + #[test] + fn test_iif_false() { + let condition = false; + let result = if condition { "yes" } else { "no" }; + assert_eq!(result, "no"); + } + + #[test] + fn test_choose() { + let index = 2; + let values = vec!["first", "second", "third"]; + let result = values.get((index - 1) as usize).unwrap_or(&""); + assert_eq!(*result, "second"); + } +} diff --git a/src/basic/keywords/validation/str_val.rs b/src/basic/keywords/validation/str_val.rs new file mode 100644 index 000000000..f79bac9e9 --- /dev/null +++ b/src/basic/keywords/validation/str_val.rs @@ -0,0 +1,149 @@ +//! Type conversion functions: VAL, STR, CINT, CDBL +//! +//! These functions convert between string and numeric types, +//! following classic BASIC conventions. + +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::{Dynamic, Engine}; +use std::sync::Arc; + +/// VAL - Convert string to number (float) +/// Returns 0.0 if conversion fails +pub fn val_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn("VAL", |s: &str| -> f64 { + s.trim().parse::().unwrap_or(0.0) + }); + + engine.register_fn("val", |s: &str| -> f64 { + s.trim().parse::().unwrap_or(0.0) + }); + + // Also handle Dynamic input + engine.register_fn("VAL", |v: Dynamic| -> f64 { + if v.is_int() { + return v.as_int().unwrap_or(0) as f64; + } + if v.is_float() { + return v.as_float().unwrap_or(0.0); + } + v.to_string().trim().parse::().unwrap_or(0.0) + }); + + debug!("Registered VAL keyword"); +} + +/// STR - Convert number to string +pub fn str_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn("STR", |n: i64| -> String { n.to_string() }); + + engine.register_fn("str", |n: i64| -> String { n.to_string() }); + + engine.register_fn("STR", |n: f64| -> String { + // Remove trailing zeros for cleaner output + let s = format!("{}", n); + s + }); + + engine.register_fn("str", |n: f64| -> String { format!("{}", n) }); + + // Handle Dynamic input + engine.register_fn("STR", |v: Dynamic| -> String { v.to_string() }); + + engine.register_fn("str", |v: Dynamic| -> String { v.to_string() }); + + debug!("Registered STR keyword"); +} + +/// CINT - Convert to integer (rounds to nearest integer) +pub fn cint_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn("CINT", |n: f64| -> i64 { n.round() as i64 }); + + engine.register_fn("cint", |n: f64| -> i64 { n.round() as i64 }); + + engine.register_fn("CINT", |n: i64| -> i64 { n }); + + engine.register_fn("cint", |n: i64| -> i64 { n }); + + engine.register_fn("CINT", |s: &str| -> i64 { + s.trim() + .parse::() + .map(|f| f.round() as i64) + .unwrap_or(0) + }); + + engine.register_fn("cint", |s: &str| -> i64 { + s.trim() + .parse::() + .map(|f| f.round() as i64) + .unwrap_or(0) + }); + + // Handle Dynamic + engine.register_fn("CINT", |v: Dynamic| -> i64 { + if v.is_int() { + return v.as_int().unwrap_or(0); + } + if v.is_float() { + return v.as_float().unwrap_or(0.0).round() as i64; + } + v.to_string() + .trim() + .parse::() + .map(|f| f.round() as i64) + .unwrap_or(0) + }); + + debug!("Registered CINT keyword"); +} + +/// CDBL - Convert to double (floating point) +pub fn cdbl_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn("CDBL", |n: i64| -> f64 { n as f64 }); + + engine.register_fn("cdbl", |n: i64| -> f64 { n as f64 }); + + engine.register_fn("CDBL", |n: f64| -> f64 { n }); + + engine.register_fn("cdbl", |n: f64| -> f64 { n }); + + engine.register_fn("CDBL", |s: &str| -> f64 { + s.trim().parse::().unwrap_or(0.0) + }); + + engine.register_fn("cdbl", |s: &str| -> f64 { + s.trim().parse::().unwrap_or(0.0) + }); + + // Handle Dynamic + engine.register_fn("CDBL", |v: Dynamic| -> f64 { + if v.is_float() { + return v.as_float().unwrap_or(0.0); + } + if v.is_int() { + return v.as_int().unwrap_or(0) as f64; + } + v.to_string().trim().parse::().unwrap_or(0.0) + }); + + debug!("Registered CDBL keyword"); +} + +#[cfg(test)] +mod tests { + #[test] + fn test_val_parsing() { + assert_eq!("123.45".trim().parse::().unwrap_or(0.0), 123.45); + assert_eq!(" 456 ".trim().parse::().unwrap_or(0.0), 456.0); + assert_eq!("abc".trim().parse::().unwrap_or(0.0), 0.0); + } + + #[test] + fn test_cint_rounding() { + assert_eq!(2.4_f64.round() as i64, 2); + assert_eq!(2.5_f64.round() as i64, 3); + assert_eq!(2.6_f64.round() as i64, 3); + assert_eq!((-2.5_f64).round() as i64, -3); + } +} diff --git a/src/basic/keywords/validation/typeof_check.rs b/src/basic/keywords/validation/typeof_check.rs new file mode 100644 index 000000000..ef4423794 --- /dev/null +++ b/src/basic/keywords/validation/typeof_check.rs @@ -0,0 +1,161 @@ +use crate::shared::models::UserSession; +use crate::shared::state::AppState; +use log::debug; +use rhai::{Dynamic, Engine}; +use std::sync::Arc; + +/// TYPEOF - Returns the type of a value as a string +pub fn typeof_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn("TYPEOF", |value: Dynamic| -> String { + get_type_name(&value) + }); + + engine.register_fn("typeof", |value: Dynamic| -> String { + get_type_name(&value) + }); + + engine.register_fn("TYPENAME", |value: Dynamic| -> String { + get_type_name(&value) + }); + + debug!("Registered TYPEOF keyword"); +} + +/// ISARRAY - Check if value is an array +pub fn isarray_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn("ISARRAY", |value: Dynamic| -> bool { + value.is_array() + }); + + engine.register_fn("isarray", |value: Dynamic| -> bool { + value.is_array() + }); + + engine.register_fn("IS_ARRAY", |value: Dynamic| -> bool { + value.is_array() + }); + + debug!("Registered ISARRAY keyword"); +} + +/// ISNUMBER - Check if value is a number (int or float) +pub fn isnumber_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn("ISNUMBER", |value: Dynamic| -> bool { + is_numeric(&value) + }); + + engine.register_fn("isnumber", |value: Dynamic| -> bool { + is_numeric(&value) + }); + + engine.register_fn("IS_NUMBER", |value: Dynamic| -> bool { + is_numeric(&value) + }); + + engine.register_fn("ISNUMERIC", |value: Dynamic| -> bool { + is_numeric(&value) + }); + + debug!("Registered ISNUMBER keyword"); +} + +/// ISSTRING - Check if value is a string +pub fn isstring_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn("ISSTRING", |value: Dynamic| -> bool { + value.is_string() + }); + + engine.register_fn("isstring", |value: Dynamic| -> bool { + value.is_string() + }); + + engine.register_fn("IS_STRING", |value: Dynamic| -> bool { + value.is_string() + }); + + debug!("Registered ISSTRING keyword"); +} + +/// ISBOOL - Check if value is a boolean +pub fn isbool_keyword(_state: &Arc, _user: UserSession, engine: &mut Engine) { + engine.register_fn("ISBOOL", |value: Dynamic| -> bool { + value.is_bool() + }); + + engine.register_fn("isbool", |value: Dynamic| -> bool { + value.is_bool() + }); + + engine.register_fn("IS_BOOL", |value: Dynamic| -> bool { + value.is_bool() + }); + + engine.register_fn("ISBOOLEAN", |value: Dynamic| -> bool { + value.is_bool() + }); + + debug!("Registered ISBOOL keyword"); +} + +/// Helper function to get the type name of a Dynamic value +fn get_type_name(value: &Dynamic) -> String { + if value.is_unit() { + "null".to_string() + } else if value.is_bool() { + "boolean".to_string() + } else if value.is_int() { + "integer".to_string() + } else if value.is_float() { + "float".to_string() + } else if value.is_string() { + "string".to_string() + } else if value.is_array() { + "array".to_string() + } else if value.is_map() { + "object".to_string() + } else if value.is_char() { + "char".to_string() + } else { + value.type_name().to_string() + } +} + +/// Helper function to check if a value is numeric +fn is_numeric(value: &Dynamic) -> bool { + if value.is_int() || value.is_float() { + return true; + } + + // Also check if it's a string that can be parsed as a number + if value.is_string() { + if let Ok(s) = value.clone().into_string() { + return s.trim().parse::().is_ok(); + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_type_name() { + assert_eq!(get_type_name(&Dynamic::UNIT), "null"); + assert_eq!(get_type_name(&Dynamic::from(true)), "boolean"); + assert_eq!(get_type_name(&Dynamic::from(42_i64)), "integer"); + assert_eq!(get_type_name(&Dynamic::from(3.14_f64)), "float"); + assert_eq!(get_type_name(&Dynamic::from("hello")), "string"); + } + + #[test] + fn test_is_numeric() { + assert!(is_numeric(&Dynamic::from(42_i64))); + assert!(is_numeric(&Dynamic::from(3.14_f64))); + assert!(is_numeric(&Dynamic::from("123"))); + assert!(is_numeric(&Dynamic::from("3.14"))); + assert!(!is_numeric(&Dynamic::from("hello"))); + assert!(!is_numeric(&Dynamic::from(true))); + } +} diff --git a/templates/marketing.gbai/marketing.gbdialog/campaigns/lead-nurture-campaign.bas b/templates/marketing.gbai/marketing.gbdialog/campaigns/lead-nurture-campaign.bas index 2776d9b7a..3767b0234 100644 --- a/templates/marketing.gbai/marketing.gbdialog/campaigns/lead-nurture-campaign.bas +++ b/templates/marketing.gbai/marketing.gbdialog/campaigns/lead-nurture-campaign.bas @@ -1,397 +1,235 @@ -REM ============================================================================ -REM Lead Nurturing Campaign with AI Scoring -REM General Bots Marketing Automation Template -REM ============================================================================ -REM This campaign automatically nurtures leads based on their AI-calculated -REM lead score, sending personalized content at optimal intervals. -REM ============================================================================ +' Lead Nurturing Campaign with AI Scoring +' AI-powered lead nurturing with dynamic scoring and personalized messaging -DESCRIPTION "AI-powered lead nurturing campaign with dynamic scoring and personalized messaging" - -REM ============================================================================ -REM Campaign Configuration -REM ============================================================================ +DESCRIPTION "AI-powered lead nurturing campaign with dynamic scoring" +' Campaign Configuration campaign_name = "lead-nurture-2025" -campaign_duration_days = 30 -min_score_threshold = 20 mql_threshold = 70 sql_threshold = 85 -REM Email sending intervals (in days) -email_interval_cold = 7 -email_interval_warm = 3 -email_interval_hot = 1 - -REM ============================================================================ -REM Main Campaign Entry Point -REM ============================================================================ - -REM This is triggered by ON FORM SUBMIT from landing pages +' Form Submit Handler - triggered by landing page submissions ON FORM SUBMIT "landing-page" - REM Extract lead data from form submission lead_email = fields.email lead_name = NVL(fields.name, "there") lead_company = fields.company lead_phone = fields.phone lead_source = metadata.utm_source - TALK "🎯 New lead captured: " + lead_email + TALK "New lead captured: " + lead_email - REM Create lead profile for scoring - lead = NEW OBJECT - lead.email = lead_email - lead.name = lead_name - lead.company = lead_company - lead.source = lead_source - lead.created_at = NOW() + ' Score the lead with AI + WITH lead + .email = lead_email + .name = lead_name + .company = lead_company + .source = lead_source + .created_at = NOW() + END WITH - REM Calculate initial AI lead score score_result = AI SCORE LEAD lead - TALK "📊 Lead Score: " + score_result.score + " (Grade: " + score_result.grade + ")" + TALK "Lead Score: " + score_result.score + " (Grade: " + score_result.grade + ")" - REM Save lead to CRM + ' Save to CRM SAVE "leads", lead_email, lead_name, lead_company, lead_phone, lead_source, score_result.score, score_result.grade, NOW() - REM Determine nurture track based on score + ' Route based on score IF score_result.score >= sql_threshold THEN - REM Hot lead - immediate sales handoff CALL hot_lead_workflow(lead, score_result) ELSE IF score_result.score >= mql_threshold THEN - REM Warm lead - accelerated nurture CALL warm_lead_workflow(lead, score_result) - ELSE IF score_result.score >= min_score_threshold THEN - REM Cold lead - standard nurture - CALL cold_lead_workflow(lead, score_result) ELSE - REM Very cold - add to long-term drip - CALL drip_campaign_workflow(lead, score_result) + CALL cold_lead_workflow(lead, score_result) END IF END ON -REM ============================================================================ -REM Hot Lead Workflow (Score >= 85) -REM ============================================================================ - +' Hot Lead Workflow (Score >= 85) SUB hot_lead_workflow(lead, score_result) - TALK "🔥 HOT LEAD: " + lead.email + " - Initiating sales handoff" + TALK "HOT LEAD: " + lead.email + " - Sales handoff" - REM Send immediate welcome + calendar booking - vars = NEW OBJECT - vars.name = lead.name - vars.score = score_result.score - vars.company = NVL(lead.company, "your company") + WITH vars + .name = lead.name + .score = score_result.score + .company = NVL(lead.company, "your company") + END WITH - REM Send personalized welcome via multiple channels SEND TEMPLATE "hot-lead-welcome", "email", lead.email, vars IF NOT ISEMPTY(lead.phone) THEN SEND TEMPLATE "hot-lead-sms", "sms", lead.phone, vars END IF - REM Create task for sales team CREATE TASK "Contact hot lead: " + lead.email, "sales-team", "high", NOW() - REM Send Slack notification to sales - sales_alert = "🔥 *HOT LEAD ALERT*\n" - sales_alert = sales_alert + "Email: " + lead.email + "\n" - sales_alert = sales_alert + "Score: " + score_result.score + "\n" - sales_alert = sales_alert + "Company: " + NVL(lead.company, "Unknown") + "\n" - sales_alert = sales_alert + "Action: Immediate follow-up required!" + ' Slack notification + WITH alert + .text = "HOT LEAD: " + lead.email + " | Score: " + score_result.score + END WITH + POST "https://hooks.slack.com/services/YOUR_WEBHOOK", alert - POST "https://hooks.slack.com/services/YOUR_WEBHOOK", #{text: sales_alert} + SET SCHEDULE DATEADD(NOW(), 1, "day"), "hot-lead-followup.bas" - REM Schedule follow-up if no response in 24 hours - SET SCHEDULE "0 9 * * *", "hot-lead-followup.bas" - - TALK "✅ Hot lead workflow completed for " + lead.email + TALK "Hot lead workflow completed" END SUB -REM ============================================================================ -REM Warm Lead Workflow (Score 70-84) -REM ============================================================================ - +' Warm Lead Workflow (Score 70-84) SUB warm_lead_workflow(lead, score_result) - TALK "🌡️ WARM LEAD: " + lead.email + " - Starting accelerated nurture" + TALK "WARM LEAD: " + lead.email + " - Accelerated nurture" - vars = NEW OBJECT - vars.name = lead.name - vars.company = NVL(lead.company, "your company") + WITH vars + .name = lead.name + .company = NVL(lead.company, "your company") + END WITH - REM Day 0: Welcome email with case study SEND TEMPLATE "warm-welcome", "email", lead.email, vars - REM Schedule Day 3: Value proposition email - nurture_data = #{ - lead_email: lead.email, - lead_name: lead.name, - template: "warm-value-prop", - step: 2 - } - SET SCHEDULE DATEADD(NOW(), 3, "day"), "send-nurture-email.bas" - - REM Schedule Day 7: Demo invitation + SET SCHEDULE DATEADD(NOW(), 3, "day"), "warm-nurture-2.bas" SET SCHEDULE DATEADD(NOW(), 7, "day"), "warm-demo-invite.bas" - - REM Schedule Day 14: Re-score and evaluate SET SCHEDULE DATEADD(NOW(), 14, "day"), "rescore-lead.bas" - TALK "✅ Warm lead nurture sequence started for " + lead.email + TALK "Warm lead nurture started" END SUB -REM ============================================================================ -REM Cold Lead Workflow (Score 20-69) -REM ============================================================================ - +' Cold Lead Workflow (Score < 70) SUB cold_lead_workflow(lead, score_result) - TALK "❄️ COLD LEAD: " + lead.email + " - Starting standard nurture" + TALK "COLD LEAD: " + lead.email + " - Standard nurture" - vars = NEW OBJECT - vars.name = lead.name - vars.company = NVL(lead.company, "your organization") + WITH vars + .name = lead.name + .company = NVL(lead.company, "your organization") + END WITH - REM Day 0: Welcome email SEND TEMPLATE "cold-welcome", "email", lead.email, vars - REM Day 7: Educational content SET SCHEDULE DATEADD(NOW(), 7, "day"), "cold-education-1.bas" - - REM Day 14: More educational content SET SCHEDULE DATEADD(NOW(), 14, "day"), "cold-education-2.bas" - - REM Day 21: Soft pitch SET SCHEDULE DATEADD(NOW(), 21, "day"), "cold-soft-pitch.bas" - - REM Day 30: Re-score and decide next steps SET SCHEDULE DATEADD(NOW(), 30, "day"), "rescore-lead.bas" - TALK "✅ Cold lead nurture sequence started for " + lead.email + TALK "Cold lead nurture started" END SUB -REM ============================================================================ -REM Long-term Drip Campaign (Score < 20) -REM ============================================================================ - -SUB drip_campaign_workflow(lead, score_result) - TALK "💧 LOW SCORE LEAD: " + lead.email + " - Adding to drip campaign" - - vars = NEW OBJECT - vars.name = lead.name - - REM Simple welcome only - SEND TEMPLATE "drip-welcome", "email", lead.email, vars - - REM Add to monthly newsletter - SAVE "newsletter_subscribers", lead.email, lead.name, NOW() - - REM Schedule monthly check-in - SET SCHEDULE "0 10 1 * *", "monthly-drip-check.bas" - - TALK "✅ Added " + lead.email + " to long-term drip campaign" -END SUB - -REM ============================================================================ -REM Scheduled: Re-score Lead and Adjust Campaign -REM ============================================================================ - +' Re-score Lead (scheduled) SUB rescore_lead() PARAM lead_email AS string - REM Get current lead data lead_data = FIND "leads", "email = '" + lead_email + "'" IF ISEMPTY(lead_data) THEN - TALK "⚠️ Lead not found: " + lead_email + TALK "Lead not found: " + lead_email RETURN END IF - REM Get updated behavior data - lead = NEW OBJECT - lead.email = lead_email - lead.name = lead_data.name - lead.company = lead_data.company + WITH lead + .email = lead_email + .name = lead_data.name + .company = lead_data.company + END WITH - REM Recalculate score with latest behavior new_score = AI SCORE LEAD lead old_score = lead_data.score - score_change = new_score.score - old_score - TALK "📊 Lead Rescore: " + lead_email - TALK " Old Score: " + old_score + " → New Score: " + new_score.score - TALK " Change: " + IIF(score_change >= 0, "+", "") + score_change + TALK "Lead Rescore: " + lead_email + TALK "Old: " + old_score + " -> New: " + new_score.score + " (" + IIF(score_change >= 0, "+", "") + score_change + ")" - REM Update stored score - UPDATE "leads", lead_email, #{score: new_score.score, grade: new_score.grade, updated_at: NOW()} + UPDATE "leads", lead_email, new_score.score, new_score.grade, NOW() - REM Check if lead should be promoted to higher tier IF old_score < mql_threshold AND new_score.score >= mql_threshold THEN - TALK "🎉 Lead promoted to MQL: " + lead_email + TALK "Lead promoted to MQL: " + lead_email CALL warm_lead_workflow(lead, new_score) - - REM Notify marketing team - SEND TEMPLATE "mql-promotion-alert", "email", "marketing@company.com", #{ - lead_email: lead_email, - old_score: old_score, - new_score: new_score.score - } + SEND TEMPLATE "mql-promotion-alert", "email", "marketing@company.com", lead ELSE IF old_score < sql_threshold AND new_score.score >= sql_threshold THEN - TALK "🔥 Lead promoted to SQL: " + lead_email + TALK "Lead promoted to SQL: " + lead_email CALL hot_lead_workflow(lead, new_score) - ELSE IF score_change < -20 THEN - TALK "⚠️ Significant score drop for: " + lead_email - REM Move to re-engagement campaign - CALL reengagement_workflow(lead, new_score) END IF END SUB -REM ============================================================================ -REM Re-engagement Workflow for Declining Leads -REM ============================================================================ - -SUB reengagement_workflow(lead, score_result) - TALK "🔄 Starting re-engagement for: " + lead.email - - vars = NEW OBJECT - vars.name = lead.name - - REM Send re-engagement email - SEND TEMPLATE "reengagement", "email", lead.email, vars - - REM If we have phone, send SMS too - IF NOT ISEMPTY(lead.phone) THEN - SEND TEMPLATE "reengagement-sms", "sms", lead.phone, vars - END IF - - REM Schedule unsubscribe if no engagement in 14 days - SET SCHEDULE DATEADD(NOW(), 14, "day"), "check-reengagement.bas" - - TALK "✅ Re-engagement campaign started for " + lead.email -END SUB - -REM ============================================================================ -REM Utility: Send Nurture Email by Step -REM ============================================================================ - +' Send Nurture Email (utility) SUB send_nurture_email() PARAM lead_email AS string PARAM template_name AS string PARAM step AS integer - REM Get lead data lead_data = FIND "leads", "email = '" + lead_email + "'" IF ISEMPTY(lead_data) THEN - TALK "⚠️ Lead not found, skipping: " + lead_email RETURN END IF - REM Check if lead has unsubscribed unsubscribed = FIND "unsubscribes", "email = '" + lead_email + "'" IF NOT ISEMPTY(unsubscribed) THEN - TALK "⏹️ Lead unsubscribed, stopping nurture: " + lead_email + TALK "Lead unsubscribed: " + lead_email RETURN END IF - REM Check current score - maybe they've become hot current_score = GET LEAD SCORE lead_email IF current_score.score >= sql_threshold THEN - TALK "🔥 Lead is now hot! Switching to hot workflow: " + lead_email - lead = #{email: lead_email, name: lead_data.name, company: lead_data.company} + WITH lead + .email = lead_email + .name = lead_data.name + .company = lead_data.company + END WITH CALL hot_lead_workflow(lead, current_score) RETURN END IF - REM Send the scheduled email - vars = NEW OBJECT - vars.name = lead_data.name - vars.company = NVL(lead_data.company, "your organization") - vars.step = step + WITH vars + .name = lead_data.name + .company = NVL(lead_data.company, "your organization") + .step = step + END WITH result = SEND TEMPLATE template_name, "email", lead_email, vars IF result[0].success THEN - TALK "✉️ Nurture email sent: " + template_name + " to " + lead_email - - REM Track email send + TALK "Nurture email sent: " + template_name + " to " + lead_email SAVE "email_tracking", lead_email, template_name, step, NOW(), "sent" - - REM Update lead score for engagement UPDATE LEAD SCORE lead_email, 2, "Nurture email " + step + " sent" ELSE - TALK "❌ Failed to send nurture email: " + result[0].error + TALK "Failed to send: " + result[0].error END IF END SUB -REM ============================================================================ -REM Campaign Analytics -REM ============================================================================ - +' Campaign Analytics SUB get_campaign_analytics() - TALK "📈 Campaign Analytics for: " + campaign_name - TALK "================================" + TALK "Campaign Analytics: " + campaign_name - REM Total leads total_leads = AGGREGATE "leads", "COUNT", "email" - TALK "Total Leads: " + total_leads - - REM Leads by grade grade_a = AGGREGATE "leads", "COUNT", "email", "grade = 'A'" grade_b = AGGREGATE "leads", "COUNT", "email", "grade = 'B'" grade_c = AGGREGATE "leads", "COUNT", "email", "grade = 'C'" - grade_d = AGGREGATE "leads", "COUNT", "email", "grade = 'D'" - - TALK "Grade Distribution:" - TALK " A (Hot): " + grade_a - TALK " B (Warm): " + grade_b - TALK " C (Neutral): " + grade_c - TALK " D (Cold): " + grade_d - - REM Average score avg_score = AGGREGATE "leads", "AVG", "score" - TALK "Average Lead Score: " + ROUND(avg_score, 1) - - REM Conversion rates mql_count = AGGREGATE "leads", "COUNT", "email", "score >= " + mql_threshold sql_count = AGGREGATE "leads", "COUNT", "email", "score >= " + sql_threshold + emails_sent = AGGREGATE "email_tracking", "COUNT", "id", "status = 'sent'" + + TALK "Total Leads: " + total_leads + TALK "Grade A: " + grade_a + " | B: " + grade_b + " | C: " + grade_c + TALK "Avg Score: " + ROUND(avg_score, 1) IF total_leads > 0 THEN mql_rate = ROUND((mql_count / total_leads) * 100, 1) sql_rate = ROUND((sql_count / total_leads) * 100, 1) - - TALK "Conversion Rates:" - TALK " MQL Rate: " + mql_rate + "%" - TALK " SQL Rate: " + sql_rate + "%" + TALK "MQL Rate: " + mql_rate + "% | SQL Rate: " + sql_rate + "%" END IF - REM Email performance - emails_sent = AGGREGATE "email_tracking", "COUNT", "id", "status = 'sent'" TALK "Emails Sent: " + emails_sent - TALK "================================" + WITH stats + .total_leads = total_leads + .grade_a = grade_a + .grade_b = grade_b + .grade_c = grade_c + .avg_score = avg_score + .mql_count = mql_count + .sql_count = sql_count + END WITH - RETURN #{ - total_leads: total_leads, - grade_a: grade_a, - grade_b: grade_b, - grade_c: grade_c, - grade_d: grade_d, - avg_score: avg_score, - mql_count: mql_count, - sql_count: sql_count, - emails_sent: emails_sent - } + RETURN stats END SUB -REM ============================================================================ -REM Entry point for manual campaign trigger -REM ============================================================================ - -TALK "🚀 Lead Nurturing Campaign Ready: " + campaign_name -TALK "📊 MQL Threshold: " + mql_threshold -TALK "🔥 SQL Threshold: " + sql_threshold -TALK "⏰ Campaign Duration: " + campaign_duration_days + " days" -TALK "" -TALK "Waiting for form submissions..." +TALK "Lead Nurturing Campaign Ready: " + campaign_name +TALK "MQL Threshold: " + mql_threshold + " | SQL Threshold: " + sql_threshold diff --git a/templates/marketing.gbai/marketing.gbdialog/campaigns/welcome-campaign.bas b/templates/marketing.gbai/marketing.gbdialog/campaigns/welcome-campaign.bas index 79482d69a..e1d03e64d 100644 --- a/templates/marketing.gbai/marketing.gbdialog/campaigns/welcome-campaign.bas +++ b/templates/marketing.gbai/marketing.gbdialog/campaigns/welcome-campaign.bas @@ -25,4 +25,72 @@ WITH variables .date = TODAY() END WITH -result = SEND TEMPLATE "welcome-email-1", \ No newline at end of file +result = SEND TEMPLATE "welcome-email-1", "email", lead_email, variables + +IF result[0].success THEN + TALK "Welcome email #1 sent successfully" + SAVE "campaign_logs", NOW(), lead_email, "welcome-1", "sent", "" +ELSE + TALK "Failed to send welcome email #1" + SAVE "campaign_logs", NOW(), lead_email, "welcome-1", "failed", result[0].error +END IF + +' Step 2: Schedule Follow-up Emails +SET SCHEDULE "0 9 * * *" DATEADD(TODAY(), 2, "day"), "send-welcome-2.bas" +SET SCHEDULE "0 9 * * *" DATEADD(TODAY(), 5, "day"), "send-welcome-3.bas" +SET SCHEDULE "0 9 * * *" DATEADD(TODAY(), 8, "day"), "send-welcome-4.bas" +SET SCHEDULE "0 9 * * *" DATEADD(TODAY(), 14, "day"), "send-welcome-5.bas" + +' Step 3: Add to CRM and Score Lead +SAVE "leads", lead_email, lead_name, lead_source, "Welcome Series", "nurturing", NOW() + +' Calculate initial lead score +WITH score_data + .email = lead_email + .name = lead_name + .source = lead_source + .form_submissions = 1 +END WITH + +lead_score = SCORE LEAD score_data + +TALK "Initial lead score: " + lead_score.score + " (Grade: " + lead_score.grade + ")" + +' Step 4: Track Campaign Enrollment +SAVE "campaign_enrollments", "Welcome Series", lead_email, lead_name, NOW(), "active", 1, 5 + +' Step 5: Send to WhatsApp if phone provided +lead_phone = GET BOT MEMORY "lead_phone_" + lead_email + +IF NOT ISEMPTY(lead_phone) THEN + WITH wa_vars + .name = lead_name + END WITH + + wa_result = SEND TEMPLATE "welcome-whatsapp", "whatsapp", lead_phone, wa_vars + + IF wa_result[0].success THEN + TALK "WhatsApp welcome sent" + END IF +END IF + +' Output Summary +TALK "" +TALK "Welcome Campaign Started" +TALK "Lead: " + lead_name + " <" + lead_email + ">" +TALK "Score: " + lead_score.score + " (" + lead_score.grade + ")" +TALK "Status: " + lead_score.status +TALK "Emails Scheduled: 5" +TALK "Campaign Duration: 14 days" + +WITH campaign_status + .campaign = "Welcome Series" + .lead = lead_email + .initial_score = lead_score.score + .grade = lead_score.grade + .emails_scheduled = 5 + .next_email_date = DATEADD(TODAY(), 2, "day") + .status = "active" +END WITH + +RETURN campaign_status