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)
This commit is contained in:
parent
50eae38d36
commit
a41ff7a7d4
28 changed files with 4811 additions and 257 deletions
230
docs/SET_SCHEDULE.md
Normal file
230
docs/SET_SCHEDULE.md
Normal file
|
|
@ -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 |
|
||||||
241
docs/TEMPLATE_VARIABLES.md
Normal file
241
docs/TEMPLATE_VARIABLES.md
Normal file
|
|
@ -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
|
||||||
192
src/basic/keywords/arrays/contains.rs
Normal file
192
src/basic/keywords/arrays/contains.rs
Normal file
|
|
@ -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<AppState>, _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")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
273
src/basic/keywords/arrays/mod.rs
Normal file
273
src/basic/keywords/arrays/mod.rs
Normal file
|
|
@ -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<AppState>, 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::<Vec<_>>()
|
||||||
|
.join(separator)
|
||||||
|
});
|
||||||
|
engine.register_fn("join", |arr: Array, separator: &str| -> String {
|
||||||
|
arr.iter()
|
||||||
|
.map(|item| item.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(separator)
|
||||||
|
});
|
||||||
|
|
||||||
|
// JOIN with default separator (comma)
|
||||||
|
engine.register_fn("JOIN", |arr: Array| -> String {
|
||||||
|
arr.iter()
|
||||||
|
.map(|item| item.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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<Dynamic> = 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<i64> = (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<i32> = nested.into_iter().flatten().collect();
|
||||||
|
assert_eq!(flat, vec![1, 2, 3, 4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/basic/keywords/arrays/push_pop.rs
Normal file
167
src/basic/keywords/arrays/push_pop.rs
Normal file
|
|
@ -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<AppState>, _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<AppState>, _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<AppState>, _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<AppState>, _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/basic/keywords/arrays/slice.rs
Normal file
204
src/basic/keywords/arrays/slice.rs
Normal file
|
|
@ -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<AppState>, _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<i64>) -> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
148
src/basic/keywords/arrays/sort.rs
Normal file
148
src/basic/keywords/arrays/sort.rs
Normal file
|
|
@ -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<AppState>, _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<f64> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/basic/keywords/arrays/unique.rs
Normal file
142
src/basic/keywords/arrays/unique.rs
Normal file
|
|
@ -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<AppState>, _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<String> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/basic/keywords/core_functions.rs
Normal file
136
src/basic/keywords/core_functions.rs
Normal file
|
|
@ -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<AppState>, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/basic/keywords/crm/mod.rs
Normal file
17
src/basic/keywords/crm/mod.rs
Normal file
|
|
@ -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<AppState>, 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");
|
||||||
|
}
|
||||||
519
src/basic/keywords/crm/score_lead.rs
Normal file
519
src/basic/keywords/crm/score_lead.rs
Normal file
|
|
@ -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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
194
src/basic/keywords/errors/mod.rs
Normal file
194
src/basic/keywords/errors/mod.rs
Normal file
|
|
@ -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<AppState>, 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<AppState>, _user: UserSession, engine: &mut Engine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"THROW",
|
||||||
|
|message: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
Err(Box::new(EvalAltResult::ErrorRuntime(
|
||||||
|
message.into(),
|
||||||
|
Position::NONE,
|
||||||
|
)))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
engine.register_fn(
|
||||||
|
"throw",
|
||||||
|
|message: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
Err(Box::new(EvalAltResult::ErrorRuntime(
|
||||||
|
message.into(),
|
||||||
|
Position::NONE,
|
||||||
|
)))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
engine.register_fn(
|
||||||
|
"RAISE",
|
||||||
|
|message: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
Err(Box::new(EvalAltResult::ErrorRuntime(
|
||||||
|
message.into(),
|
||||||
|
Position::NONE,
|
||||||
|
)))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
debug!("Registered THROW keyword");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error_keyword(_state: &Arc<AppState>, _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<AppState>, _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<AppState>, _user: UserSession, engine: &mut Engine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"ASSERT",
|
||||||
|
|condition: bool, message: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
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<bool, Box<EvalAltResult>> {
|
||||||
|
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<AppState>, _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/basic/keywords/errors/throw.rs
Normal file
39
src/basic/keywords/errors/throw.rs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/basic/keywords/lead_scoring.rs
Normal file
99
src/basic/keywords/lead_scoring.rs
Normal file
|
|
@ -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<AppState>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/basic/keywords/messaging/mod.rs
Normal file
16
src/basic/keywords/messaging/mod.rs
Normal file
|
|
@ -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<AppState>, 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");
|
||||||
|
}
|
||||||
576
src/basic/keywords/messaging/send_template.rs
Normal file
576
src/basic/keywords/messaging/send_template.rs
Normal file
|
|
@ -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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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<char> = 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_"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod add_member;
|
pub mod add_member;
|
||||||
pub mod add_suggestion;
|
pub mod add_suggestion;
|
||||||
|
pub mod arrays;
|
||||||
pub mod book;
|
pub mod book;
|
||||||
pub mod bot_memory;
|
pub mod bot_memory;
|
||||||
pub mod clear_kb;
|
pub mod clear_kb;
|
||||||
|
|
@ -8,7 +9,10 @@ pub mod core_functions;
|
||||||
pub mod create_draft;
|
pub mod create_draft;
|
||||||
pub mod create_site;
|
pub mod create_site;
|
||||||
pub mod create_task;
|
pub mod create_task;
|
||||||
|
pub mod crm;
|
||||||
pub mod data_operations;
|
pub mod data_operations;
|
||||||
|
pub mod datetime;
|
||||||
|
pub mod errors;
|
||||||
pub mod file_operations;
|
pub mod file_operations;
|
||||||
pub mod find;
|
pub mod find;
|
||||||
pub mod first;
|
pub mod first;
|
||||||
|
|
@ -22,6 +26,8 @@ pub mod last;
|
||||||
pub mod lead_scoring;
|
pub mod lead_scoring;
|
||||||
pub mod llm_keyword;
|
pub mod llm_keyword;
|
||||||
pub mod llm_macros;
|
pub mod llm_macros;
|
||||||
|
pub mod math;
|
||||||
|
pub mod messaging;
|
||||||
pub mod multimodal;
|
pub mod multimodal;
|
||||||
pub mod on;
|
pub mod on;
|
||||||
pub mod on_form_submit;
|
pub mod on_form_submit;
|
||||||
|
|
@ -37,6 +43,7 @@ pub mod set_context;
|
||||||
pub mod set_schedule;
|
pub mod set_schedule;
|
||||||
pub mod set_user;
|
pub mod set_user;
|
||||||
pub mod sms;
|
pub mod sms;
|
||||||
|
pub mod social;
|
||||||
pub mod social_media;
|
pub mod social_media;
|
||||||
pub mod string_functions;
|
pub mod string_functions;
|
||||||
pub mod switch_case;
|
pub mod switch_case;
|
||||||
|
|
@ -44,6 +51,7 @@ pub mod universal_messaging;
|
||||||
pub mod use_kb;
|
pub mod use_kb;
|
||||||
pub mod use_tool;
|
pub mod use_tool;
|
||||||
pub mod use_website;
|
pub mod use_website;
|
||||||
|
pub mod validation;
|
||||||
pub mod wait;
|
pub mod wait;
|
||||||
pub mod weather;
|
pub mod weather;
|
||||||
pub mod webhook;
|
pub mod webhook;
|
||||||
|
|
|
||||||
504
src/basic/keywords/on_form_submit.rs
Normal file
504
src/basic/keywords/on_form_submit.rs
Normal file
|
|
@ -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<AppState>, 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/basic/keywords/send_template.rs
Normal file
124
src/basic/keywords/send_template.rs
Normal file
|
|
@ -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<AppState>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/basic/keywords/social_media.rs
Normal file
116
src/basic/keywords/social_media.rs
Normal file
|
|
@ -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<AppState>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/basic/keywords/validation/isempty.rs
Normal file
141
src/basic/keywords/validation/isempty.rs
Normal file
|
|
@ -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<AppState>, _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));
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/basic/keywords/validation/isnull.rs
Normal file
41
src/basic/keywords/validation/isnull.rs
Normal file
|
|
@ -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<AppState>, _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());
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/basic/keywords/validation/mod.rs
Normal file
30
src/basic/keywords/validation/mod.rs
Normal file
|
|
@ -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<AppState>, 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;
|
||||||
181
src/basic/keywords/validation/nvl_iif.rs
Normal file
181
src/basic/keywords/validation/nvl_iif.rs
Normal file
|
|
@ -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<AppState>, _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<AppState>, _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");
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/basic/keywords/validation/str_val.rs
Normal file
149
src/basic/keywords/validation/str_val.rs
Normal file
|
|
@ -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<AppState>, _user: UserSession, engine: &mut Engine) {
|
||||||
|
engine.register_fn("VAL", |s: &str| -> f64 {
|
||||||
|
s.trim().parse::<f64>().unwrap_or(0.0)
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.register_fn("val", |s: &str| -> f64 {
|
||||||
|
s.trim().parse::<f64>().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::<f64>().unwrap_or(0.0)
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!("Registered VAL keyword");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// STR - Convert number to string
|
||||||
|
pub fn str_keyword(_state: &Arc<AppState>, _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<AppState>, _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::<f64>()
|
||||||
|
.map(|f| f.round() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.register_fn("cint", |s: &str| -> i64 {
|
||||||
|
s.trim()
|
||||||
|
.parse::<f64>()
|
||||||
|
.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::<f64>()
|
||||||
|
.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<AppState>, _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::<f64>().unwrap_or(0.0)
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.register_fn("cdbl", |s: &str| -> f64 {
|
||||||
|
s.trim().parse::<f64>().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::<f64>().unwrap_or(0.0)
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!("Registered CDBL keyword");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn test_val_parsing() {
|
||||||
|
assert_eq!("123.45".trim().parse::<f64>().unwrap_or(0.0), 123.45);
|
||||||
|
assert_eq!(" 456 ".trim().parse::<f64>().unwrap_or(0.0), 456.0);
|
||||||
|
assert_eq!("abc".trim().parse::<f64>().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);
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/basic/keywords/validation/typeof_check.rs
Normal file
161
src/basic/keywords/validation/typeof_check.rs
Normal file
|
|
@ -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<AppState>, _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<AppState>, _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<AppState>, _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<AppState>, _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<AppState>, _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::<f64>().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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,397 +1,235 @@
|
||||||
REM ============================================================================
|
' Lead Nurturing Campaign with AI Scoring
|
||||||
REM Lead Nurturing Campaign with AI Scoring
|
' AI-powered lead nurturing with dynamic scoring and personalized messaging
|
||||||
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 ============================================================================
|
|
||||||
|
|
||||||
DESCRIPTION "AI-powered lead nurturing campaign with dynamic scoring and personalized messaging"
|
DESCRIPTION "AI-powered lead nurturing campaign with dynamic scoring"
|
||||||
|
|
||||||
REM ============================================================================
|
|
||||||
REM Campaign Configuration
|
|
||||||
REM ============================================================================
|
|
||||||
|
|
||||||
|
' Campaign Configuration
|
||||||
campaign_name = "lead-nurture-2025"
|
campaign_name = "lead-nurture-2025"
|
||||||
campaign_duration_days = 30
|
|
||||||
min_score_threshold = 20
|
|
||||||
mql_threshold = 70
|
mql_threshold = 70
|
||||||
sql_threshold = 85
|
sql_threshold = 85
|
||||||
|
|
||||||
REM Email sending intervals (in days)
|
' Form Submit Handler - triggered by landing page submissions
|
||||||
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
|
|
||||||
ON FORM SUBMIT "landing-page"
|
ON FORM SUBMIT "landing-page"
|
||||||
REM Extract lead data from form submission
|
|
||||||
lead_email = fields.email
|
lead_email = fields.email
|
||||||
lead_name = NVL(fields.name, "there")
|
lead_name = NVL(fields.name, "there")
|
||||||
lead_company = fields.company
|
lead_company = fields.company
|
||||||
lead_phone = fields.phone
|
lead_phone = fields.phone
|
||||||
lead_source = metadata.utm_source
|
lead_source = metadata.utm_source
|
||||||
|
|
||||||
TALK "🎯 New lead captured: " + lead_email
|
TALK "New lead captured: " + lead_email
|
||||||
|
|
||||||
REM Create lead profile for scoring
|
' Score the lead with AI
|
||||||
lead = NEW OBJECT
|
WITH lead
|
||||||
lead.email = lead_email
|
.email = lead_email
|
||||||
lead.name = lead_name
|
.name = lead_name
|
||||||
lead.company = lead_company
|
.company = lead_company
|
||||||
lead.source = lead_source
|
.source = lead_source
|
||||||
lead.created_at = NOW()
|
.created_at = NOW()
|
||||||
|
END WITH
|
||||||
|
|
||||||
REM Calculate initial AI lead score
|
|
||||||
score_result = AI SCORE LEAD lead
|
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()
|
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
|
IF score_result.score >= sql_threshold THEN
|
||||||
REM Hot lead - immediate sales handoff
|
|
||||||
CALL hot_lead_workflow(lead, score_result)
|
CALL hot_lead_workflow(lead, score_result)
|
||||||
ELSE IF score_result.score >= mql_threshold THEN
|
ELSE IF score_result.score >= mql_threshold THEN
|
||||||
REM Warm lead - accelerated nurture
|
|
||||||
CALL warm_lead_workflow(lead, score_result)
|
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
|
ELSE
|
||||||
REM Very cold - add to long-term drip
|
CALL cold_lead_workflow(lead, score_result)
|
||||||
CALL drip_campaign_workflow(lead, score_result)
|
|
||||||
END IF
|
END IF
|
||||||
END ON
|
END ON
|
||||||
|
|
||||||
REM ============================================================================
|
' Hot Lead Workflow (Score >= 85)
|
||||||
REM Hot Lead Workflow (Score >= 85)
|
|
||||||
REM ============================================================================
|
|
||||||
|
|
||||||
SUB hot_lead_workflow(lead, score_result)
|
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
|
WITH vars
|
||||||
vars = NEW OBJECT
|
.name = lead.name
|
||||||
vars.name = lead.name
|
.score = score_result.score
|
||||||
vars.score = score_result.score
|
.company = NVL(lead.company, "your company")
|
||||||
vars.company = NVL(lead.company, "your company")
|
END WITH
|
||||||
|
|
||||||
REM Send personalized welcome via multiple channels
|
|
||||||
SEND TEMPLATE "hot-lead-welcome", "email", lead.email, vars
|
SEND TEMPLATE "hot-lead-welcome", "email", lead.email, vars
|
||||||
|
|
||||||
IF NOT ISEMPTY(lead.phone) THEN
|
IF NOT ISEMPTY(lead.phone) THEN
|
||||||
SEND TEMPLATE "hot-lead-sms", "sms", lead.phone, vars
|
SEND TEMPLATE "hot-lead-sms", "sms", lead.phone, vars
|
||||||
END IF
|
END IF
|
||||||
|
|
||||||
REM Create task for sales team
|
|
||||||
CREATE TASK "Contact hot lead: " + lead.email, "sales-team", "high", NOW()
|
CREATE TASK "Contact hot lead: " + lead.email, "sales-team", "high", NOW()
|
||||||
|
|
||||||
REM Send Slack notification to sales
|
' Slack notification
|
||||||
sales_alert = "🔥 *HOT LEAD ALERT*\n"
|
WITH alert
|
||||||
sales_alert = sales_alert + "Email: " + lead.email + "\n"
|
.text = "HOT LEAD: " + lead.email + " | Score: " + score_result.score
|
||||||
sales_alert = sales_alert + "Score: " + score_result.score + "\n"
|
END WITH
|
||||||
sales_alert = sales_alert + "Company: " + NVL(lead.company, "Unknown") + "\n"
|
POST "https://hooks.slack.com/services/YOUR_WEBHOOK", alert
|
||||||
sales_alert = sales_alert + "Action: Immediate follow-up required!"
|
|
||||||
|
|
||||||
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
|
TALK "Hot lead workflow completed"
|
||||||
SET SCHEDULE "0 9 * * *", "hot-lead-followup.bas"
|
|
||||||
|
|
||||||
TALK "✅ Hot lead workflow completed for " + lead.email
|
|
||||||
END SUB
|
END SUB
|
||||||
|
|
||||||
REM ============================================================================
|
' Warm Lead Workflow (Score 70-84)
|
||||||
REM Warm Lead Workflow (Score 70-84)
|
|
||||||
REM ============================================================================
|
|
||||||
|
|
||||||
SUB warm_lead_workflow(lead, score_result)
|
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
|
WITH vars
|
||||||
vars.name = lead.name
|
.name = lead.name
|
||||||
vars.company = NVL(lead.company, "your company")
|
.company = NVL(lead.company, "your company")
|
||||||
|
END WITH
|
||||||
|
|
||||||
REM Day 0: Welcome email with case study
|
|
||||||
SEND TEMPLATE "warm-welcome", "email", lead.email, vars
|
SEND TEMPLATE "warm-welcome", "email", lead.email, vars
|
||||||
|
|
||||||
REM Schedule Day 3: Value proposition email
|
SET SCHEDULE DATEADD(NOW(), 3, "day"), "warm-nurture-2.bas"
|
||||||
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(), 7, "day"), "warm-demo-invite.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"
|
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
|
END SUB
|
||||||
|
|
||||||
REM ============================================================================
|
' Cold Lead Workflow (Score < 70)
|
||||||
REM Cold Lead Workflow (Score 20-69)
|
|
||||||
REM ============================================================================
|
|
||||||
|
|
||||||
SUB cold_lead_workflow(lead, score_result)
|
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
|
WITH vars
|
||||||
vars.name = lead.name
|
.name = lead.name
|
||||||
vars.company = NVL(lead.company, "your organization")
|
.company = NVL(lead.company, "your organization")
|
||||||
|
END WITH
|
||||||
|
|
||||||
REM Day 0: Welcome email
|
|
||||||
SEND TEMPLATE "cold-welcome", "email", lead.email, vars
|
SEND TEMPLATE "cold-welcome", "email", lead.email, vars
|
||||||
|
|
||||||
REM Day 7: Educational content
|
|
||||||
SET SCHEDULE DATEADD(NOW(), 7, "day"), "cold-education-1.bas"
|
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"
|
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"
|
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"
|
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
|
END SUB
|
||||||
|
|
||||||
REM ============================================================================
|
' Re-score Lead (scheduled)
|
||||||
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 ============================================================================
|
|
||||||
|
|
||||||
SUB rescore_lead()
|
SUB rescore_lead()
|
||||||
PARAM lead_email AS string
|
PARAM lead_email AS string
|
||||||
|
|
||||||
REM Get current lead data
|
|
||||||
lead_data = FIND "leads", "email = '" + lead_email + "'"
|
lead_data = FIND "leads", "email = '" + lead_email + "'"
|
||||||
|
|
||||||
IF ISEMPTY(lead_data) THEN
|
IF ISEMPTY(lead_data) THEN
|
||||||
TALK "⚠️ Lead not found: " + lead_email
|
TALK "Lead not found: " + lead_email
|
||||||
RETURN
|
RETURN
|
||||||
END IF
|
END IF
|
||||||
|
|
||||||
REM Get updated behavior data
|
WITH lead
|
||||||
lead = NEW OBJECT
|
.email = lead_email
|
||||||
lead.email = lead_email
|
.name = lead_data.name
|
||||||
lead.name = lead_data.name
|
.company = lead_data.company
|
||||||
lead.company = lead_data.company
|
END WITH
|
||||||
|
|
||||||
REM Recalculate score with latest behavior
|
|
||||||
new_score = AI SCORE LEAD lead
|
new_score = AI SCORE LEAD lead
|
||||||
old_score = lead_data.score
|
old_score = lead_data.score
|
||||||
|
|
||||||
score_change = new_score.score - old_score
|
score_change = new_score.score - old_score
|
||||||
|
|
||||||
TALK "📊 Lead Rescore: " + lead_email
|
TALK "Lead Rescore: " + lead_email
|
||||||
TALK " Old Score: " + old_score + " → New Score: " + new_score.score
|
TALK "Old: " + old_score + " -> New: " + new_score.score + " (" + IIF(score_change >= 0, "+", "") + score_change + ")"
|
||||||
TALK " Change: " + IIF(score_change >= 0, "+", "") + score_change
|
|
||||||
|
|
||||||
REM Update stored score
|
UPDATE "leads", lead_email, new_score.score, new_score.grade, NOW()
|
||||||
UPDATE "leads", lead_email, #{score: new_score.score, grade: new_score.grade, updated_at: NOW()}
|
|
||||||
|
|
||||||
REM Check if lead should be promoted to higher tier
|
|
||||||
IF old_score < mql_threshold AND new_score.score >= mql_threshold THEN
|
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)
|
CALL warm_lead_workflow(lead, new_score)
|
||||||
|
SEND TEMPLATE "mql-promotion-alert", "email", "marketing@company.com", lead
|
||||||
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
|
|
||||||
}
|
|
||||||
ELSE IF old_score < sql_threshold AND new_score.score >= sql_threshold THEN
|
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)
|
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 IF
|
||||||
END SUB
|
END SUB
|
||||||
|
|
||||||
REM ============================================================================
|
' Send Nurture Email (utility)
|
||||||
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 ============================================================================
|
|
||||||
|
|
||||||
SUB send_nurture_email()
|
SUB send_nurture_email()
|
||||||
PARAM lead_email AS string
|
PARAM lead_email AS string
|
||||||
PARAM template_name AS string
|
PARAM template_name AS string
|
||||||
PARAM step AS integer
|
PARAM step AS integer
|
||||||
|
|
||||||
REM Get lead data
|
|
||||||
lead_data = FIND "leads", "email = '" + lead_email + "'"
|
lead_data = FIND "leads", "email = '" + lead_email + "'"
|
||||||
|
|
||||||
IF ISEMPTY(lead_data) THEN
|
IF ISEMPTY(lead_data) THEN
|
||||||
TALK "⚠️ Lead not found, skipping: " + lead_email
|
|
||||||
RETURN
|
RETURN
|
||||||
END IF
|
END IF
|
||||||
|
|
||||||
REM Check if lead has unsubscribed
|
|
||||||
unsubscribed = FIND "unsubscribes", "email = '" + lead_email + "'"
|
unsubscribed = FIND "unsubscribes", "email = '" + lead_email + "'"
|
||||||
IF NOT ISEMPTY(unsubscribed) THEN
|
IF NOT ISEMPTY(unsubscribed) THEN
|
||||||
TALK "⏹️ Lead unsubscribed, stopping nurture: " + lead_email
|
TALK "Lead unsubscribed: " + lead_email
|
||||||
RETURN
|
RETURN
|
||||||
END IF
|
END IF
|
||||||
|
|
||||||
REM Check current score - maybe they've become hot
|
|
||||||
current_score = GET LEAD SCORE lead_email
|
current_score = GET LEAD SCORE lead_email
|
||||||
|
|
||||||
IF current_score.score >= sql_threshold THEN
|
IF current_score.score >= sql_threshold THEN
|
||||||
TALK "🔥 Lead is now hot! Switching to hot workflow: " + lead_email
|
WITH lead
|
||||||
lead = #{email: lead_email, name: lead_data.name, company: lead_data.company}
|
.email = lead_email
|
||||||
|
.name = lead_data.name
|
||||||
|
.company = lead_data.company
|
||||||
|
END WITH
|
||||||
CALL hot_lead_workflow(lead, current_score)
|
CALL hot_lead_workflow(lead, current_score)
|
||||||
RETURN
|
RETURN
|
||||||
END IF
|
END IF
|
||||||
|
|
||||||
REM Send the scheduled email
|
WITH vars
|
||||||
vars = NEW OBJECT
|
.name = lead_data.name
|
||||||
vars.name = lead_data.name
|
.company = NVL(lead_data.company, "your organization")
|
||||||
vars.company = NVL(lead_data.company, "your organization")
|
.step = step
|
||||||
vars.step = step
|
END WITH
|
||||||
|
|
||||||
result = SEND TEMPLATE template_name, "email", lead_email, vars
|
result = SEND TEMPLATE template_name, "email", lead_email, vars
|
||||||
|
|
||||||
IF result[0].success THEN
|
IF result[0].success THEN
|
||||||
TALK "✉️ Nurture email sent: " + template_name + " to " + lead_email
|
TALK "Nurture email sent: " + template_name + " to " + lead_email
|
||||||
|
|
||||||
REM Track email send
|
|
||||||
SAVE "email_tracking", lead_email, template_name, step, NOW(), "sent"
|
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"
|
UPDATE LEAD SCORE lead_email, 2, "Nurture email " + step + " sent"
|
||||||
ELSE
|
ELSE
|
||||||
TALK "❌ Failed to send nurture email: " + result[0].error
|
TALK "Failed to send: " + result[0].error
|
||||||
END IF
|
END IF
|
||||||
END SUB
|
END SUB
|
||||||
|
|
||||||
REM ============================================================================
|
' Campaign Analytics
|
||||||
REM Campaign Analytics
|
|
||||||
REM ============================================================================
|
|
||||||
|
|
||||||
SUB get_campaign_analytics()
|
SUB get_campaign_analytics()
|
||||||
TALK "📈 Campaign Analytics for: " + campaign_name
|
TALK "Campaign Analytics: " + campaign_name
|
||||||
TALK "================================"
|
|
||||||
|
|
||||||
REM Total leads
|
|
||||||
total_leads = AGGREGATE "leads", "COUNT", "email"
|
total_leads = AGGREGATE "leads", "COUNT", "email"
|
||||||
TALK "Total Leads: " + total_leads
|
|
||||||
|
|
||||||
REM Leads by grade
|
|
||||||
grade_a = AGGREGATE "leads", "COUNT", "email", "grade = 'A'"
|
grade_a = AGGREGATE "leads", "COUNT", "email", "grade = 'A'"
|
||||||
grade_b = AGGREGATE "leads", "COUNT", "email", "grade = 'B'"
|
grade_b = AGGREGATE "leads", "COUNT", "email", "grade = 'B'"
|
||||||
grade_c = AGGREGATE "leads", "COUNT", "email", "grade = 'C'"
|
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"
|
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
|
mql_count = AGGREGATE "leads", "COUNT", "email", "score >= " + mql_threshold
|
||||||
sql_count = AGGREGATE "leads", "COUNT", "email", "score >= " + sql_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
|
IF total_leads > 0 THEN
|
||||||
mql_rate = ROUND((mql_count / total_leads) * 100, 1)
|
mql_rate = ROUND((mql_count / total_leads) * 100, 1)
|
||||||
sql_rate = ROUND((sql_count / total_leads) * 100, 1)
|
sql_rate = ROUND((sql_count / total_leads) * 100, 1)
|
||||||
|
TALK "MQL Rate: " + mql_rate + "% | SQL Rate: " + sql_rate + "%"
|
||||||
TALK "Conversion Rates:"
|
|
||||||
TALK " MQL Rate: " + mql_rate + "%"
|
|
||||||
TALK " SQL Rate: " + sql_rate + "%"
|
|
||||||
END IF
|
END IF
|
||||||
|
|
||||||
REM Email performance
|
|
||||||
emails_sent = AGGREGATE "email_tracking", "COUNT", "id", "status = 'sent'"
|
|
||||||
TALK "Emails Sent: " + emails_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 #{
|
RETURN stats
|
||||||
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
|
|
||||||
}
|
|
||||||
END SUB
|
END SUB
|
||||||
|
|
||||||
REM ============================================================================
|
TALK "Lead Nurturing Campaign Ready: " + campaign_name
|
||||||
REM Entry point for manual campaign trigger
|
TALK "MQL Threshold: " + mql_threshold + " | SQL Threshold: " + sql_threshold
|
||||||
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..."
|
|
||||||
|
|
|
||||||
|
|
@ -25,4 +25,72 @@ WITH variables
|
||||||
.date = TODAY()
|
.date = TODAY()
|
||||||
END WITH
|
END WITH
|
||||||
|
|
||||||
result = SEND TEMPLATE "welcome-email-1",
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue