Looking at this diff, I need to summarize the significant documentation

and code changes:

``` Add natural language scheduling, docs, wizard, and branding

- Add SET SCHEDULE natural language parser supporting patterns like
  "every hour", "at 9am", "weekdays at 8am", "business
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-11-30 12:20:48 -03:00
parent a41ff7a7d4
commit 635f3a7923
17 changed files with 6233 additions and 194 deletions

View file

@ -53,10 +53,11 @@
- [Chapter 06: gbdialog Reference](./chapter-06-gbdialog/README.md)
- [Dialog Basics](./chapter-06-gbdialog/basics.md)
- [Universal Messaging & Multi-Channel](./chapter-06-gbdialog/universal-messaging.md)
- [BASIC vs n8n/Zapier/Make](./chapter-06-gbdialog/basic-vs-automation-tools.md)
- [Template Examples](./chapter-06-gbdialog/templates.md)
- [start.bas](./chapter-06-gbdialog/template-start.md)
- [generate-summary.bas](./chapter-06-gbdialog/template-summary.md)
- [enrollment Tool Example](./chapter-06-gbdialog/template-enrollment.md)
- [start.bas](./chapter-06-gbdialog/templates/start.md)
- [enrollment.bas](./chapter-06-gbdialog/templates/enrollment.md)
- [auth.bas](./chapter-06-gbdialog/templates/auth.md)
- [Consolidated Examples](./chapter-06-gbdialog/examples-consolidated.md)
- [Data Sync Tools](./chapter-06-gbdialog/tools-data-sync.md)
- [Keywords Reference](./chapter-06-gbdialog/keywords.md)

View file

@ -0,0 +1,454 @@
# BASIC vs Everyone: The Complete Domination Guide
> *"Embrace, Extend, Extinguish"* — We learned from the best. Now we're doing it **open source**.
## 🎯 The Mission: Kill Them All
Just like Microsoft killed Lotus 1-2-3 by making Excel better AND cheaper, General Bots BASIC is here to **obliterate** every paid automation tool, AI assistant, and workflow platform.
**They charge you $30/user/month. We charge you $0. Forever.**
---
## 💀 The Kill List
### Automation Platforms (DEAD)
- ~~n8n~~ → BASIC does more
- ~~Zapier~~ → BASIC is free
- ~~Make.com~~ → BASIC has AI native
- ~~Power Automate~~ → BASIC is open source
### AI Assistants (OBSOLETE)
- ~~Microsoft Copilot~~ → We support Claude Opus 4, GPT-4, AND local models
- ~~Google Gemini~~ → We're not locked to one vendor
- ~~ChatGPT Plus~~ → Our bots DO things, not just chat
### Office Suites (DISRUPTED)
- ~~Microsoft 365~~ → We have email, drive, calendar, meet
- ~~Google Workspace~~ → Same features, zero cost
- ~~Zoho~~ → More AI, less complexity
---
## 🏆 The Ultimate Comparison Matrix
| Feature | Zapier | n8n | Make | Power Automate | **Copilot** | **Gemini** | **BASIC** |
|---------|--------|-----|------|----------------|-------------|------------|-----------|
| **PRICE** | $50-800/mo | $24-500/mo | $10-350/mo | $15-40/user | **$30/user** | **$20/user** | **$0 FOREVER** |
| Webhooks | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
| Scheduling | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ `"every hour"` |
| HTTP/REST | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
| GraphQL | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
| SOAP | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ |
| Database Native | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
| **Conversations** | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| **WhatsApp Native** | Plugin | Plugin | Plugin | Plugin | ❌ | ❌ | ✅ **NATIVE** |
| **Telegram Native** | Plugin | Plugin | Plugin | ❌ | ❌ | ❌ | ✅ **NATIVE** |
| **Image Generation** | ❌ | ❌ | ❌ | ❌ | ✅ DALL-E | ✅ Imagen | ✅ **ANY MODEL** |
| **Video Generation** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| **Voice/TTS** | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| **Vision/OCR** | Plugin | Plugin | Plugin | ✅ | ✅ | ✅ | ✅ |
| **Best AI Models** | ❌ | ❌ | ❌ | GPT-4 only | GPT-4 only | Gemini only | ✅ **ALL MODELS** |
| Claude Opus 4 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Local LLMs | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ Llama/Mistral |
| **Self-Hosted** | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ **100% YOURS** |
| **Open Source** | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ **AGPL** |
| **White Label** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Version Control | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ Git native |
| Lead Scoring AI | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| CRM Native | Plugin | Plugin | Plugin | Plugin | ❌ | ❌ | ✅ |
| Email Server | ❌ | ❌ | ❌ | Exchange | Gmail | Gmail | ✅ **BUILT-IN** |
| Video Meetings | ❌ | ❌ | ❌ | Teams | Teams | Meet | ✅ **BUILT-IN** |
| File Storage | ❌ | ❌ | ❌ | OneDrive | OneDrive | Drive | ✅ **BUILT-IN** |
---
## 🔥 Why Copilot & Gemini Are ALREADY DEAD
### Microsoft Copilot: $30/user/month for WHAT?
**What Copilot gives you:**
- Chat with documents *(we do this)*
- Email drafting *(we do this)*
- Meeting summaries *(we do this)*
- Excel formulas *(we do this)*
- Locked to Microsoft ecosystem *(WE DON'T)*
- GPT-4 only *(we support ALL models)*
**What BASIC gives you for FREE:**
```basic
' Everything Copilot does, plus automation
USE KB "company-docs"
answer = LLM "Summarize Q4 results from the uploaded documents"
SEND MAIL "team@company.com", "Q4 Summary", answer
' Schedule it!
SET SCHEDULE "every monday at 9am"
```
**Copilot can't:**
- Send WhatsApp messages
- Trigger webhooks
- Run scheduled automations
- Generate videos
- Score leads
- Build interactive bots
- Work offline with local LLMs
### Google Gemini: Locked in Google's Walled Garden
**Gemini limitations:**
- Only works with Google Workspace
- Only Gemini models (no Claude, no GPT)
- No automation capabilities
- No webhook triggers
- No self-hosting
- Your data trains their models
**BASIC destroys this:**
```basic
' Use ANY model - Claude Opus 4 is the BEST
SET LLM MODEL "claude-opus-4"
analysis = LLM "Deep analysis of customer feedback with nuanced understanding"
' Or use local models for privacy
SET LLM MODEL "llama-3.1-70b"
private_analysis = LLM "Analyze confidential data locally"
' Or use Gemini if you want!
SET LLM MODEL "gemini-pro"
```
---
## 🏴‍☠️ The Microsoft Playbook (We're Using It)
### 1. EMBRACE ✅
We support everything they support:
- All their file formats
- All their APIs
- All their integrations
- All their workflows
### 2. EXTEND ✅
We add what they CAN'T:
- **Conversational AI** - Interactive bots, not just automation
- **Multi-model AI** - Claude Opus 4, GPT-4, Gemini, Llama, Mistral
- **Multimodal** - Image, video, audio generation
- **Self-hosted** - Your data stays yours
- **White label** - Your brand, not ours
- **Zero cost** - Forever free
### 3. EXTINGUISH 🎯
Why would anyone pay when they can get MORE for FREE?
| Their Product | Their Cost | **BASIC Replacement** | **Your Cost** |
|---------------|------------|----------------------|---------------|
| Zapier Business | $800/month | BASIC Scripts | **$0** |
| n8n Cloud | $500/month | BASIC Scripts | **$0** |
| Make Teams | $350/month | BASIC Scripts | **$0** |
| Power Automate | $40/user/month | BASIC Scripts | **$0** |
| Copilot Pro | $30/user/month | LLM Keyword + KB | **$0** |
| Gemini Advanced | $20/user/month | LLM Keyword | **$0** |
| ChatGPT Plus | $20/month | LLM Keyword | **$0** |
| Microsoft 365 | $22/user/month | Full Office Suite | **$0** |
| Google Workspace | $12/user/month | Full Office Suite | **$0** |
| Intercom | $74/user/month | BASIC Bots | **$0** |
| HubSpot | $800/month | CRM + Lead Scoring | **$0** |
| Twilio | Pay per message | WhatsApp Native | **$0** |
**100 users × $30/month Copilot = $3,000/month = $36,000/year**
**100 users × BASIC = $0/month = $0/year**
---
## 🚀 Claude Opus 4: The Best Model, Available HERE
While Copilot is stuck with GPT-4 and Gemini is stuck with... Gemini, **BASIC supports Claude Opus 4** — widely considered the most capable AI model for:
- **Nuanced understanding** - Better at complex instructions
- **Longer context** - 200K tokens vs GPT-4's 128K
- **Better coding** - More accurate code generation
- **Safer outputs** - Constitutional AI training
- **Less hallucination** - More factual responses
```basic
' Use the BEST model available
SET LLM MODEL "claude-opus-4"
' Complex multi-step reasoning
analysis = LLM "
Analyze our Q4 sales data and:
1. Identify top 3 performing regions
2. Find correlation with marketing spend
3. Predict Q1 trends
4. Recommend budget allocation
Be specific with numbers and confidence levels.
"
TALK analysis
```
**Model Freedom:**
```basic
' Switch models based on task
SET LLM MODEL "claude-opus-4" ' Complex analysis
SET LLM MODEL "gpt-4-turbo" ' General tasks
SET LLM MODEL "gemini-pro" ' Google integration
SET LLM MODEL "llama-3.1-70b" ' Private/offline
SET LLM MODEL "mistral-large" ' European compliance
SET LLM MODEL "deepseek-coder" ' Code generation
```
---
## 💪 What We Do That NO ONE Else Can
### 1. Conversational Automation
```basic
' Interactive workflow - impossible in Zapier/n8n
TALK "I'll help you file an expense report. What's the amount?"
HEAR amount
TALK "What category? (travel/meals/supplies)"
HEAR category
TALK "Upload the receipt photo"
HEAR receipt AS FILE
' AI extracts receipt data
receipt_data = SEE receipt
verified_amount = receipt_data.total
IF verified_amount != amount THEN
TALK "Receipt shows $" + verified_amount + ", you entered $" + amount + ". Which is correct?"
HEAR correct_amount
amount = correct_amount
END IF
INSERT "expenses", amount, category, receipt, NOW()
TALK "Expense submitted! Reference: " + LAST_INSERT_ID
```
### 2. Multi-Channel Native
```basic
' Same bot works on ALL channels
TALK "Your order has shipped!" ' Works on WhatsApp, Telegram, Web, SMS
' Channel-specific when needed
IF channel = "whatsapp" THEN
SEND TEMPLATE "shipping_update", phone, tracking_number
ELSE IF channel = "email" THEN
SEND MAIL email, "Shipping Update", tracking_email
END IF
```
### 3. AI That DOES Things
```basic
' Not just chat - actual automation
SET SCHEDULE "every day at 6am"
' Analyze overnight support tickets
tickets = FIND "support_tickets", "created_at > DATEADD('hour', -12, NOW())"
FOR EACH ticket IN tickets
' AI categorizes and prioritizes
analysis = LLM "Analyze this support ticket and return JSON with category, priority, suggested_response: " + ticket.content
UPDATE "support_tickets", ticket.id, analysis.category, analysis.priority
IF analysis.priority = "urgent" THEN
TALK TO on_call_agent, "🚨 Urgent ticket: " + ticket.subject
END IF
NEXT
' Generate daily summary
summary = LLM "Create executive summary of " + LEN(tickets) + " overnight tickets"
SEND MAIL "support-manager@company.com", "Overnight Ticket Summary", summary
```
### 4. Complete Office Suite Replacement
```basic
' Email
SEND MAIL "team@company.com", "Subject", "Body"
emails = GET "mail/inbox"
' Calendar
BOOK "Sales Meeting", "tomorrow at 2pm", "john@company.com, jane@company.com"
' Files
UPLOAD "report.pdf", "shared/reports/"
file = DOWNLOAD "shared/templates/invoice.docx"
GENERATE PDF "invoice_data", "invoice_template.docx"
' Tasks
CREATE TASK "Review proposal", "john", "friday"
' Meetings (video)
meeting_url = CREATE MEETING "Weekly Standup", "monday at 9am"
```
---
## 🎪 The Migration Massacre
### From Zapier (RIP)
```basic
' Zapier: 5 zaps, $50/month
' BASIC: 10 lines, $0/month
ON FORM SUBMIT "contact-form"
' Send to Slack
POST "https://hooks.slack.com/...", { "text": "New lead: " + form.email }
' Add to CRM
INSERT "leads", form.name, form.email, form.company
' Send welcome email
SEND MAIL form.email, "Thanks for reaching out!", welcome_template
' Score the lead with AI
score = AI SCORE LEAD form.email, form.company, form.message
UPDATE "leads", LAST_INSERT_ID, "score", score
END ON
```
### From n8n (Gone)
```basic
' n8n: Complex node setup, self-host headaches
' BASIC: Just write what you want
SET SCHEDULE "every 5 minutes"
' Monitor website
response = GET "https://mysite.com/health"
IF response.status != 200 THEN
TALK TO ops_team, "🔴 Website down! Status: " + response.status
CREATE TASK "Investigate website outage", "devops", "urgent"
END IF
```
### From Power Automate (Destroyed)
```basic
' Power Automate: $40/user/month, Microsoft lock-in
' BASIC: Free, works everywhere
' When email arrives with attachment
ON EMAIL RECEIVED
IF email.has_attachments THEN
FOR EACH attachment IN email.attachments
' Extract data with AI vision
data = SEE attachment
' Save to database
INSERT "documents", email.from, attachment.name, data
' Notify team
TALK TO document_team, "New document from " + email.from
NEXT
END IF
END ON
```
### From Copilot (Obsolete)
```basic
' Copilot: $30/user, limited to Microsoft
' BASIC: $0, unlimited potential
' Everything Copilot does
USE KB "company-knowledge"
answer = LLM "Answer this question using company docs: " + question
' Plus things Copilot CAN'T do
SET SCHEDULE "every monday at 8am"
report = LLM "Generate weekly report from sales data"
SEND MAIL team, "Weekly Report", report
POST "https://slack.com/api/...", { "text": report }
```
---
## 📊 TCO Calculator: The Massacre in Numbers
### Small Business (10 users)
| Solution | Monthly | Annual |
|----------|---------|--------|
| Zapier + Copilot | $300 + $300 = $600 | $7,200 |
| n8n + ChatGPT | $50 + $200 = $250 | $3,000 |
| **BASIC** | **$0** | **$0** |
### Medium Business (100 users)
| Solution | Monthly | Annual |
|----------|---------|--------|
| Zapier Pro + M365 + Copilot | $800 + $2,200 + $3,000 = $6,000 | $72,000 |
| Make + Google + Gemini | $350 + $1,200 + $2,000 = $3,550 | $42,600 |
| **BASIC** | **$0** | **$0** |
### Enterprise (1,000 users)
| Solution | Monthly | Annual |
|----------|---------|--------|
| Enterprise Stack | $50,000+ | $600,000+ |
| **BASIC** | **$0** | **$0** |
**SAVINGS: $600,000/year**
---
## 🏁 The Endgame
### Why They Can't Compete
1. **We're open source** - They can't undercut free
2. **We support ALL models** - They're locked to their own
3. **We're self-hosted** - Your data is yours
4. **We're conversation-first** - They're automation-only
5. **We're multimodal native** - They bolt on features
6. **We have no per-user pricing** - Deploy to millions, pay nothing
### The Lotus 1-2-3 Moment
Remember when Excel killed Lotus 1-2-3?
- Excel was **cheaper**
- Excel was **more integrated**
- Excel **embraced their file format**
- Excel **extended with features**
**BASIC is doing the same thing to the entire automation/AI assistant industry.**
---
## 🚀 Get Started (It's Free, Obviously)
```bash
# One command to rule them all
curl -fsSL https://get.generalbots.com | sh
# Or with Docker
docker run -d generalbots/botserver
```
Then write your first automation:
```basic
SET SCHEDULE "every hour"
TALK "Hello from the future of automation!"
```
**No credit card. No trial period. No user limits. No bullshit.**
---
## Related
- [Keywords Reference](./keywords.md) - Complete keyword documentation
- [SET SCHEDULE](./keyword-set-schedule.md) - Natural language scheduling
- [WEBHOOK](./keyword-webhook.md) - Event-driven automation
- [LLM](./keyword-llm.md) - AI integration with ANY model
- [Templates](./templates.md) - Ready-to-use automation templates
---
*"The best way to predict the future is to create it."*
— Alan Kay
*"The best way to compete with expensive software is to make it free."*
— General Bots

View file

@ -1,24 +1,111 @@
# SET SCHEDULE
Schedule a script or task to run at specified times using cron expressions.
Schedule a script or task to run at specified times using natural language or cron expressions.
## Syntax
```basic
SET SCHEDULE cron_expression
SET SCHEDULE expression
```
## Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `cron_expression` | String | Standard cron expression defining when to run |
| `expression` | String | Natural language schedule or cron expression |
## Description
The `SET SCHEDULE` keyword schedules the current script to run automatically at specified intervals. It uses standard cron syntax for maximum flexibility in scheduling.
The `SET SCHEDULE` keyword schedules the current script to run automatically at specified intervals. It supports **natural language expressions** that are automatically converted to cron format, making scheduling intuitive and readable.
## Cron Expression Format
## Natural Language Patterns
### Time Intervals
```basic
SET SCHEDULE "every minute"
SET SCHEDULE "every 5 minutes"
SET SCHEDULE "every 15 minutes"
SET SCHEDULE "every 30 minutes"
SET SCHEDULE "every hour"
SET SCHEDULE "every 2 hours"
SET SCHEDULE "every 6 hours"
SET SCHEDULE "every day"
SET SCHEDULE "every week"
SET SCHEDULE "every month"
SET SCHEDULE "every year"
```
### Aliases
```basic
SET SCHEDULE "hourly" ' Same as "every hour"
SET SCHEDULE "daily" ' Same as "every day"
SET SCHEDULE "weekly" ' Same as "every week"
SET SCHEDULE "monthly" ' Same as "every month"
SET SCHEDULE "yearly" ' Same as "every year"
```
### Specific Times
```basic
SET SCHEDULE "at 9am"
SET SCHEDULE "at 9:30am"
SET SCHEDULE "at 2pm"
SET SCHEDULE "at 14:00"
SET SCHEDULE "at midnight"
SET SCHEDULE "at noon"
```
### Day-Specific
```basic
SET SCHEDULE "every monday"
SET SCHEDULE "every friday"
SET SCHEDULE "every sunday"
SET SCHEDULE "every monday at 9am"
SET SCHEDULE "every friday at 5pm"
```
### Weekdays & Weekends
```basic
SET SCHEDULE "weekdays" ' Monday-Friday at midnight
SET SCHEDULE "every weekday" ' Same as above
SET SCHEDULE "weekdays at 8am" ' Monday-Friday at 8 AM
SET SCHEDULE "weekends" ' Saturday & Sunday at midnight
SET SCHEDULE "weekends at 10am" ' Saturday & Sunday at 10 AM
```
### Combined Patterns
```basic
SET SCHEDULE "every day at 9am"
SET SCHEDULE "every day at 6:30pm"
SET SCHEDULE "every hour from 9 to 17"
```
### Business Hours
```basic
SET SCHEDULE "business hours" ' Every hour 9-17, Mon-Fri
SET SCHEDULE "every hour during business hours" ' Same as above
SET SCHEDULE "every 30 minutes during business hours" ' Every 30 min, 9-17, Mon-Fri
SET SCHEDULE "every 15 minutes during business hours"
```
### Raw Cron (Advanced)
You can still use standard cron expressions for maximum flexibility:
```basic
SET SCHEDULE "0 * * * *" ' Every hour at minute 0
SET SCHEDULE "*/5 * * * *" ' Every 5 minutes
SET SCHEDULE "0 9-17 * * 1-5" ' Hourly 9AM-5PM on weekdays
SET SCHEDULE "0 0 1 * *" ' First day of each month
```
## Cron Expression Format (Reference)
```
┌───────────── minute (0-59)
@ -30,147 +117,167 @@ The `SET SCHEDULE` keyword schedules the current script to run automatically at
* * * * *
```
## Quick Reference Table
| Natural Language | Cron Equivalent | Description |
|-----------------|-----------------|-------------|
| `every minute` | `* * * * *` | Runs every minute |
| `every 5 minutes` | `*/5 * * * *` | Every 5 minutes |
| `every hour` | `0 * * * *` | Start of every hour |
| `hourly` | `0 * * * *` | Same as every hour |
| `every day` | `0 0 * * *` | Daily at midnight |
| `daily` | `0 0 * * *` | Same as every day |
| `at 9am` | `0 9 * * *` | Daily at 9 AM |
| `at 9:30am` | `30 9 * * *` | Daily at 9:30 AM |
| `at noon` | `0 12 * * *` | Daily at noon |
| `at midnight` | `0 0 * * *` | Daily at midnight |
| `every monday` | `0 0 * * 1` | Monday at midnight |
| `every monday at 9am` | `0 9 * * 1` | Monday at 9 AM |
| `weekdays` | `0 0 * * 1-5` | Mon-Fri at midnight |
| `weekdays at 8am` | `0 8 * * 1-5` | Mon-Fri at 8 AM |
| `weekends` | `0 0 * * 0,6` | Sat-Sun at midnight |
| `every week` | `0 0 * * 0` | Sunday at midnight |
| `weekly` | `0 0 * * 0` | Same as every week |
| `every month` | `0 0 1 * *` | 1st of month |
| `monthly` | `0 0 1 * *` | Same as every month |
| `business hours` | `0 9-17 * * 1-5` | Hourly 9-5 weekdays |
| `every hour from 9 to 17` | `0 9-17 * * *` | Hourly 9 AM - 5 PM |
## Examples
### Every Hour
### Daily Report at 9 AM
```basic
SET SCHEDULE "0 * * * *"
' Runs at the start of every hour
```
SET SCHEDULE "every day at 9am"
### Daily at Specific Time
```basic
SET SCHEDULE "0 9 * * *"
' Runs every day at 9:00 AM
```
### Every 5 Minutes
```basic
SET SCHEDULE "*/5 * * * *"
' Runs every 5 minutes
```
### Weekdays Only
```basic
SET SCHEDULE "0 8 * * 1-5"
' Runs at 8 AM Monday through Friday
```
### Multiple Times Daily
```basic
SET SCHEDULE "0 9,12,17 * * *"
' Runs at 9 AM, 12 PM, and 5 PM
```
### Monthly Reports
```basic
SET SCHEDULE "0 6 1 * *"
' Runs at 6 AM on the first day of each month
```
## Common Patterns
| Pattern | Cron Expression | Description |
|---------|----------------|-------------|
| Every minute | `* * * * *` | Runs every minute |
| Every hour | `0 * * * *` | Start of every hour |
| Every 30 minutes | `*/30 * * * *` | Every 30 minutes |
| Daily at midnight | `0 0 * * *` | Every day at 12:00 AM |
| Weekly on Monday | `0 0 * * 1` | Every Monday at midnight |
| Last day of month | `0 0 28-31 * *` | End of month (approximate) |
| Business hours | `0 9-17 * * 1-5` | Every hour 9 AM-5 PM weekdays |
## Practical Use Cases
### Daily Summary Generation
```basic
SET SCHEDULE "0 6 * * *"
' Fetch and summarize daily data
data = GET "reports/daily.json"
summary = LLM "Summarize key metrics: " + data
SET BOT MEMORY "daily_summary", summary
SEND MAIL "team@company.com", "Daily Report", summary
```
### Hourly Data Refresh
```basic
SET SCHEDULE "0 * * * *"
### Hourly Data Sync
' Update cached data every hour
fresh_data = GET "https://server/data"
```basic
SET SCHEDULE "every hour"
fresh_data = GET "https://api.example.com/data"
SET BOT MEMORY "cached_data", fresh_data
PRINT "Data refreshed at " + NOW()
```
### Weekly Newsletter
```basic
SET SCHEDULE "0 10 * * 1"
### Every 15 Minutes Monitoring
```basic
SET SCHEDULE "every 15 minutes"
status = GET "https://api.example.com/health"
IF status.healthy = false THEN
SEND MAIL "ops@company.com", "Alert: Service Down", status.message
END IF
```
### Weekly Newsletter (Monday 10 AM)
```basic
SET SCHEDULE "every monday at 10am"
subscribers = FIND "subscribers", "active=true"
content = LLM "Generate weekly newsletter with latest updates"
' Send weekly newsletter every Monday at 10 AM
subscribers = FIND "subscribers_custom", "active=true"
FOR EACH email IN subscribers
SEND MAIL email, "Weekly Update", newsletter_content
SEND MAIL email.address, "Weekly Update", content
NEXT
```
### Cancel Schedule
### Business Hours Support Check
```basic
' Schedules are automatically canceled when SET SCHEDULE is removed from .bas.
SET SCHEDULE "every 30 minutes during business hours"
tickets = FIND "support_tickets", "status=open AND priority=high"
IF LEN(tickets) > 5 THEN
TALK TO "support-manager", "High priority ticket queue: " + LEN(tickets) + " tickets waiting"
END IF
```
### Weekend Backup
```basic
SET SCHEDULE "weekends at 3am"
PRINT "Starting weekend backup..."
result = POST "https://backup.service/run", { "type": "full" }
SET BOT MEMORY "last_backup", NOW()
SEND MAIL "admin@company.com", "Backup Complete", result
```
### End of Month Report
```basic
SET SCHEDULE "monthly"
' Runs on 1st of each month at midnight
month_data = AGGREGATE "sales", "SUM(amount)", "month=" + MONTH(DATEADD("month", -1, NOW()))
report = LLM "Generate monthly sales report for: " + month_data
SEND MAIL "finance@company.com", "Monthly Sales Report", report
```
## Best Practices
1. **Start Time Consideration**: Avoid scheduling all tasks at the same time
1. **Use Natural Language**: Prefer readable expressions like `"every day at 9am"` over cron syntax
2. **Stagger Tasks**: Avoid scheduling all tasks at the same time
```basic
' Bad: Everything at midnight
SET SCHEDULE "0 0 * * *"
' Good: Stagger tasks
SET SCHEDULE "0 2 * * *" ' Cleanup at 2 AM
SET SCHEDULE "0 3 * * *" ' Backup at 3 AM
' Good: Different times
SET SCHEDULE "every day at 2am" ' Cleanup
SET SCHEDULE "every day at 3am" ' Backup
SET SCHEDULE "every day at 4am" ' Reports
```
2. **Resource Management**: Consider system load
```basic
' Heavy processing during off-hours
SET SCHEDULE "0 2-4 * * *"
```
3. **Consider Time Zones**: Schedule times are in server's local time
3. **Error Handling**: Include error recovery
4. **Error Handling**: Always include error recovery
```basic
SET SCHEDULE "0 * * * *"
SET SCHEDULE "every hour"
TRY
PROCESS_DATA()
CATCH
LOG "Schedule failed: " + ERROR_MESSAGE
PRINT "Schedule failed: " + ERROR_MESSAGE
SEND MAIL "admin@example.com", "Schedule Error", ERROR_DETAILS
END TRY
```
4. **Idempotency**: Make scheduled tasks safe to re-run
5. **Idempotency**: Make scheduled tasks safe to re-run
```basic
' Check if already processed
last_run = GET BOT MEMORY "last_process_time"
IF TIME_DIFF(NOW(), last_run) > 3600 THEN
IF DATEDIFF("minute", last_run, NOW()) > 55 THEN
PROCESS()
SET BOT MEMORY "last_process_time", NOW()
END IF
```
## Cancel Schedule
Schedules are automatically canceled when `SET SCHEDULE` is removed from the `.bas` file. Simply delete or comment out the line:
```basic
' SET SCHEDULE "every hour" ' Commented out = disabled
```
## Limitations
- Maximum 100 scheduled tasks per bot
- Minimum interval: 1 minute
- Scripts timeout after 5 minutes by default
- Schedules persist until explicitly canceled or bot restarts
- Time zone is server's local time
## Monitoring
Scheduled tasks are logged for monitoring:
Scheduled tasks are logged automatically:
- Execution start/end times
- Success/failure status
- Error messages
- Error messages if any
- Performance metrics
## Related Keywords
@ -186,8 +293,10 @@ Scheduled tasks are logged for monitoring:
Located in `src/basic/keywords/set_schedule.rs`
The implementation:
- Uses cron parser for expression validation
- Uses a fast rule-based natural language parser (no LLM required)
- Falls back to raw cron if input is already in cron format
- Validates expressions before saving
- Integrates with system scheduler
- Persists schedules in database
- Handles concurrent execution
- Provides retry logic for failures
- Provides retry logic for failures

View file

@ -1,24 +1,118 @@
# Template Examples
For template examples and detailed documentation, please see [Chapter 2: Templates](../chapter-02/templates.md).
Templates are pre-built BASIC scripts that demonstrate common use cases and patterns. Each template includes complete code, explanations, and **interactive WhatsApp-style sample dialogs** showing how the bot behaves in real conversations.
## Available Templates
BotServer includes 21 pre-built templates in the `templates/` directory:
### 🚀 [start.bas](./templates/start.md)
**Topic: Basic Greeting & Help Flow**
- **default.gbai** - Basic bot with weather, email, and calculation tools
- **edu.gbai** - Educational bot for course management
- **crm.gbai** - Customer relationship management
- **announcements.gbai** - Broadcast messaging system
- **whatsapp.gbai** - WhatsApp Business integration
- And many more...
The simplest possible bot - learn BASIC fundamentals with a greeting flow that demonstrates `SET`, `TALK`, `HEAR`, and `IF/ELSE`.
Perfect for:
- Learning BASIC syntax
- Quick demos
- Starting point for new bots
---
### 📋 [enrollment.bas](./templates/enrollment.md)
**Topic: User Registration & Data Collection**
A complete data collection workflow that gathers user information step-by-step, validates inputs, confirms details, and saves the data.
Perfect for:
- Customer onboarding
- Event registrations
- Lead capture forms
- Survey collection
---
### 🔐 [auth.bas](./templates/auth.md)
**Topic: Authentication Patterns**
Secure user authentication flows including login, registration, password reset, and session management.
Perfect for:
- User login systems
- Account verification
- Password recovery
- Session handling
---
## Template Structure
Each template documentation includes:
1. **Topic Description** - What the template is for
2. **The Code** - Complete, working BASIC script
3. **Sample Dialogs** - WhatsApp-style conversations showing real interactions
4. **Keywords Used** - Quick reference of BASIC keywords
5. **Customization Ideas** - Ways to extend the template
## Using Templates
Templates provide ready-to-use bot configurations. Each template includes:
- Dialog scripts (`.gbdialog`)
- Knowledge bases (`.gbkb`)
- Configuration (`.gbot`)
- Optional themes (`.gbtheme`)
### Method 1: Copy and Customize
See [Chapter 2: Templates](../chapter-02/templates.md) for complete documentation on using and customizing templates.
Copy the template code into your `.gbdialog` folder and modify it:
```basic
' Copy start.bas and customize
SET user_name = "Guest"
TALK "Hello, " + user_name + "! Welcome to My Company."
HEAR user_input
' ... add your logic
```
### Method 2: Include Templates
Use the `INCLUDE` keyword to use templates as building blocks:
```basic
INCLUDE "templates/auth.bas"
' Now use auth functions
CALL authenticate_user()
```
### Method 3: Use as Reference
Study the templates to learn patterns, then write your own:
```basic
' Learned from enrollment.bas pattern
PARAM name AS string LIKE "John Doe"
DESCRIPTION "User's full name"
TALK "What's your name?"
HEAR name
' ... continue with your logic
```
## More Templates
The `templates/` directory contains 20+ ready-to-use bot configurations:
| Template | Description |
|----------|-------------|
| `default.gbai` | Basic bot with weather, email, and calculation tools |
| `edu.gbai` | Educational bot for course management |
| `crm.gbai` | Customer relationship management |
| `announcements.gbai` | Broadcast messaging system |
| `whatsapp.gbai` | WhatsApp Business integration |
| `store.gbai` | E-commerce bot |
| `healthcare` | Healthcare appointment scheduling |
| `hr` | Human resources assistant |
| `finance` | Financial services bot |
| `marketing.gbai` | Marketing automation |
| `reminder.gbai` | Task and reminder management |
| `backup.gbai` | Automated backup workflows |
| `crawler.gbai` | Web crawling and data extraction |
## Related
- [BASIC vs n8n/Zapier/Make](./basic-vs-automation-tools.md) - Why BASIC beats drag-and-drop tools
- [Keywords Reference](./keywords.md) - Complete keyword documentation
- [Consolidated Examples](./examples-consolidated.md) - More code examples

View file

@ -1,31 +1,433 @@
# auth.bas (Template)
# Authentication Template
This template demonstrates a simple authentication flow using the BASIC dialog language.
The authentication template demonstrates secure user verification flows including login, registration, password validation, and session management.
## Topic: User Authentication & Security
This template is perfect for:
- User login systems
- Account verification
- Password recovery flows
- Session management
- Two-factor authentication
## The Code
```basic
REM Simple authentication flow
REM Authentication Flow with Retry Logic
PARAM username AS string LIKE "john.doe"
DESCRIPTION "Username or email for authentication"
PARAM password AS string LIKE "********"
DESCRIPTION "User's password (masked input)"
SET max_attempts = 3
SET attempts = 0
TALK "Welcome! Please enter your username:"
HEAR username
LABEL auth_loop
HEAR password
IF password = "secret123" THEN
TALK "Authentication successful."
TALK "Enter your password:"
HEAR password AS PASSWORD ' Masked input
' Verify credentials
user = FIND "users", "username='" + username + "'"
IF user = NULL THEN
TALK "Username not found. Would you like to register? (yes/no)"
HEAR register_choice
IF register_choice = "yes" THEN
GOTO registration
ELSE
TALK "Goodbye!"
EXIT
END IF
END IF
IF user.password = HASH(password) THEN
SET BOT MEMORY "authenticated_user", username
SET BOT MEMORY "session_start", NOW()
TALK "Welcome back, " + user.name + "! You are now logged in."
EXIT
ELSE
SET attempts = attempts + 1
IF attempts >= 3 THEN
TALK "Too many attempts. Goodbye."
IF attempts >= max_attempts THEN
TALK "Too many failed attempts. Your account is temporarily locked."
SEND MAIL user.email, "Security Alert", "Multiple failed login attempts detected."
EXIT
ENDIF
TALK "Incorrect password. Try again."
END IF
TALK "Incorrect password. " + (max_attempts - attempts) + " attempts remaining."
GOTO auth_loop
ENDIF
END IF
LABEL registration
TALK "Let's create your account. Enter your email:"
HEAR email
TALK "Create a password (min 8 characters):"
HEAR new_password AS PASSWORD
IF LEN(new_password) < 8 THEN
TALK "Password too short. Please try again."
GOTO registration
END IF
INSERT "users", username, email, HASH(new_password), NOW()
TALK "Account created! You can now log in."
```
**Purpose**
## Sample Dialogs
- Shows how to collect a password with `HEAR`.
- Limits the number of attempts to three.
- Uses `TALK` to give feedback and `EXIT` to end the dialog after too many failures.
These conversations show how the authentication template works in real-world scenarios.
**Keywords used:** `SET`, `HEAR`, `IF`, `ELSE`, `GOTO`, `EXIT`, `TALK`.
### Dialog 1: Successful Login
<div class="wa-chat">
<div class="wa-header">
<div class="wa-header-avatar">🔐</div>
<div class="wa-header-info">
<div class="wa-header-name">Auth Bot</div>
<div class="wa-header-status">online</div>
</div>
</div>
<div class="wa-date"><span>Today</span></div>
<div class="wa-message bot">
<div class="wa-bubble">
<div class="wa-sender">Auth Bot</div>
<p>Welcome! Please enter your username:</p>
<div class="wa-time">09:00</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>maria.santos</p>
<div class="wa-time">09:00 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Enter your password:</p>
<div class="wa-time">09:00</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>••••••••</p>
<div class="wa-time">09:01 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Welcome back, Maria! You are now logged in. ✅</p>
<div class="wa-time">09:01</div>
</div>
</div>
</div>
### Dialog 2: Failed Login with Retry
<div class="wa-chat">
<div class="wa-header">
<div class="wa-header-avatar">🔐</div>
<div class="wa-header-info">
<div class="wa-header-name">Auth Bot</div>
<div class="wa-header-status">online</div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Enter your password:</p>
<div class="wa-time">14:30</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>••••••••</p>
<div class="wa-time">14:30 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Incorrect password. 2 attempts remaining. ⚠️</p>
<div class="wa-time">14:30</div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Enter your password:</p>
<div class="wa-time">14:30</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>••••••••</p>
<div class="wa-time">14:31 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Welcome back, João! You are now logged in. ✅</p>
<div class="wa-time">14:31</div>
</div>
</div>
</div>
### Dialog 3: Account Locked
<div class="wa-chat">
<div class="wa-header">
<div class="wa-header-avatar">🔐</div>
<div class="wa-header-info">
<div class="wa-header-name">Auth Bot</div>
<div class="wa-header-status">online</div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Incorrect password. 1 attempt remaining. ⚠️</p>
<div class="wa-time">16:45</div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Enter your password:</p>
<div class="wa-time">16:45</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>••••••••</p>
<div class="wa-time">16:45 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Too many failed attempts. Your account is temporarily locked. 🔒</p>
<p>A security alert has been sent to your email.</p>
<div class="wa-time">16:45</div>
</div>
</div>
<div class="wa-system"><span>Session ended</span></div>
</div>
### Dialog 4: New User Registration
<div class="wa-chat">
<div class="wa-header">
<div class="wa-header-avatar">🔐</div>
<div class="wa-header-info">
<div class="wa-header-name">Auth Bot</div>
<div class="wa-header-status">online</div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Username not found. Would you like to register? (yes/no)</p>
<div class="wa-time">10:15</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>yes</p>
<div class="wa-time">10:15 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Let's create your account. Enter your email:</p>
<div class="wa-time">10:15</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>carlos@email.com</p>
<div class="wa-time">10:16 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Create a password (min 8 characters):</p>
<div class="wa-time">10:16</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>••••••••••</p>
<div class="wa-time">10:16 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Account created! 🎉 You can now log in.</p>
<div class="wa-time">10:16</div>
</div>
</div>
</div>
## Keywords Used
| Keyword | Purpose |
|---------|---------|
| `PARAM` | Define expected input parameters |
| `SET` | Assign values to variables |
| `TALK` | Send messages to the user |
| `HEAR` | Capture user input |
| `HEAR AS PASSWORD` | Masked password input |
| `FIND` | Query database for user |
| `IF/ELSE` | Conditional logic |
| `GOTO/LABEL` | Flow control for retry loop |
| `HASH` | Secure password hashing |
| `SET BOT MEMORY` | Store session data |
| `SEND MAIL` | Send security alerts |
| `INSERT` | Create new user record |
| `EXIT` | End the dialog |
## How It Works
1. **Username Input**: Collects the username first
2. **User Lookup**: Checks if user exists in database
3. **Password Verification**: Compares hashed password
4. **Retry Logic**: Allows 3 attempts before lockout
5. **Session Creation**: Stores auth state in bot memory
6. **Registration**: Offers new account creation if user not found
## Security Features
### Password Hashing
```basic
' Never store plain text passwords!
hashed = HASH(password)
INSERT "users", username, email, hashed
```
### Rate Limiting
```basic
IF attempts >= max_attempts THEN
SET BOT MEMORY "locked_" + username, NOW()
TALK "Account locked for 15 minutes."
END IF
```
### Two-Factor Authentication
```basic
' Send OTP after password verification
otp = RANDOM(100000, 999999)
SET BOT MEMORY "otp_" + username, otp
SEND MAIL email, "Your verification code", "Code: " + otp
TALK "Enter the 6-digit code sent to your email:"
HEAR user_otp
IF user_otp = GET BOT MEMORY "otp_" + username THEN
TALK "Two-factor authentication successful!"
ELSE
TALK "Invalid code."
END IF
```
## Customization Ideas
### Add "Forgot Password"
```basic
TALK "Forgot your password? (yes/no)"
HEAR forgot
IF forgot = "yes" THEN
reset_token = RANDOM_STRING(32)
SET BOT MEMORY "reset_" + username, reset_token
SEND MAIL user.email, "Password Reset", "Click here: /reset/" + reset_token
TALK "Password reset link sent to your email."
END IF
```
### Session Timeout
```basic
session_start = GET BOT MEMORY "session_start"
IF DATEDIFF("minute", session_start, NOW()) > 30 THEN
TALK "Session expired. Please log in again."
SET BOT MEMORY "authenticated_user", ""
END IF
```
### Social Login
```basic
TALK "Login with: 1) Password 2) Google 3) GitHub"
HEAR login_method
SWITCH login_method
CASE "2"
' Redirect to OAuth
url = GET "auth/google/redirect"
TALK "Click to login: " + url
CASE "3"
url = GET "auth/github/redirect"
TALK "Click to login: " + url
DEFAULT
' Standard password flow
END SWITCH
```
## Related Templates
- [start.bas](./start.md) - Basic greeting flow
- [enrollment.bas](./enrollment.md) - Data collection patterns
---
<style>
/* Inline WhatsApp Chat Styles for this page */
.wa-chat{background-color:#e5ddd5;border-radius:8px;padding:20px 15px;margin:20px 0;max-width:600px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px}
.wa-chat::after{content:'';display:table;clear:both}
.wa-message{clear:both;margin-bottom:10px;max-width:85%;position:relative}
.wa-message.user{float:right}
.wa-message.user .wa-bubble{background-color:#dcf8c6;border-radius:8px 0 8px 8px;margin-left:40px}
.wa-message.bot{float:left}
.wa-message.bot .wa-bubble{background-color:#fff;border-radius:0 8px 8px 8px;margin-right:40px}
.wa-bubble{padding:8px 12px;box-shadow:0 1px .5px rgba(0,0,0,.13)}
.wa-bubble p{margin:0 0 4px 0;line-height:1.4;color:#303030}
.wa-bubble p:last-child{margin-bottom:0}
.wa-time{font-size:11px;color:#8696a0;text-align:right;margin-top:4px}
.wa-message.user .wa-time{color:#61a05e}
.wa-sender{font-size:12px;font-weight:600;color:#06cf9c;margin-bottom:2px}
.wa-status.read::after{content:'✓✓';color:#53bdeb;margin-left:4px}
.wa-system{text-align:center;margin:15px 0;clear:both}
.wa-system span{background-color:#e1f2fb;color:#54656f;padding:5px 12px;border-radius:8px;font-size:12px}
.wa-date{text-align:center;margin:15px 0;clear:both}
.wa-date span{background-color:#fff;color:#54656f;padding:5px 12px;border-radius:8px;font-size:12px;box-shadow:0 1px .5px rgba(0,0,0,.13)}
.wa-header{background-color:#075e54;color:#fff;padding:10px 15px;margin:-20px -15px 15px -15px;border-radius:8px 8px 0 0;display:flex;align-items:center;gap:10px}
.wa-header-avatar{width:40px;height:40px;background-color:#25d366;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px}
.wa-header-info{flex:1}
.wa-header-name{font-weight:600;font-size:16px}
.wa-header-status{font-size:12px;opacity:.8}
</style>

View file

@ -1,12 +1,21 @@
# enrollment.bas (Template)
# Enrollment Template
A comprehensive enrollment dialog that gathers user information, confirms it, and saves it to a CSV file.
The enrollment template demonstrates how to build a complete data collection workflow that gathers user information step-by-step, validates inputs, confirms details, and saves the data.
## Topic: User Registration & Data Collection
This template is perfect for:
- Customer onboarding flows
- Event registrations
- Lead capture forms
- Survey collection
- Application submissions
## The Code
```basic
REM Enrollment Tool Example
## Complete Enrollment Script
PARAM name AS string LIKE "Abreu Silva"
DESCRIPTION "Required full name of the individual."
@ -22,9 +31,9 @@ DESCRIPTION "Required Personal ID number of the individual (only numbers)."
PARAM address AS string LIKE "Rua das Flores, 123 - SP"
DESCRIPTION "Required full address of the individual."
DESCRIPTION "This is the enrollment process, called when the user wants to enrol. Once all information is collected, confirm the details and inform them that their enrollment request has been successfully submitted. Provide a polite and professional tone throughout the interaction."
DESCRIPTION "This is the enrollment process, called when the user wants to enrol."
REM Enrollment Process
REM Start enrollment
TALK "Welcome to the enrollment process! Let's get you registered."
TALK "First, what is your full name?"
@ -54,21 +63,338 @@ TALK "Are these details correct? (yes/no)"
HEAR confirmation
IF confirmation = "yes" THEN
REM Save to CSV file
SAVE "enrollments.csv", name, birthday, email, personalid, address
TALK "Thank you! Your enrollment has been successfully submitted. You will receive a confirmation email shortly."
TALK "Thank you! Your enrollment has been successfully submitted."
ELSE
TALK "Let's start over with the correct information."
REM In a real implementation, you might loop back or use a different approach
END IF
```
**Purpose**
## Sample Dialogs
- Shows how to define parameters with `PARAM` and `DESCRIPTION`.
- Demonstrates a multistep data collection flow using `HEAR` and `TALK`.
- Confirms data before persisting it via `SAVE`.
These conversations show how the enrollment template works in real-world scenarios.
**Keywords used:** `PARAM`, `DESCRIPTION`, `HEAR`, `TALK`, `IF`, `ELSE`, `SAVE`.
### Dialog 1: Successful Enrollment
<div class="wa-chat">
<div class="wa-header">
<div class="wa-header-avatar">🤖</div>
<div class="wa-header-info">
<div class="wa-header-name">Registration Bot</div>
<div class="wa-header-status">online</div>
</div>
</div>
<div class="wa-date"><span>Today</span></div>
<div class="wa-message user">
<div class="wa-bubble">
<p>Hi, I want to register</p>
<div class="wa-time">10:30 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<div class="wa-sender">Registration Bot</div>
<p>Welcome to the enrollment process! Let's get you registered. 📋</p>
<div class="wa-time">10:30</div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>First, what is your full name?</p>
<div class="wa-time">10:30</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>Maria Santos Silva</p>
<div class="wa-time">10:31 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Thank you. What is your birth date? (DD/MM/YYYY)</p>
<div class="wa-time">10:31</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>15/03/1990</p>
<div class="wa-time">10:31 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>What is your email address?</p>
<div class="wa-time">10:31</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>maria.santos@email.com</p>
<div class="wa-time">10:32 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Please provide your Personal ID number (numbers only):</p>
<div class="wa-time">10:32</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>12345678901</p>
<div class="wa-time">10:32 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Finally, what is your full address?</p>
<div class="wa-time">10:32</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>Rua das Palmeiras, 456 - São Paulo, SP</p>
<div class="wa-time">10:33 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Please confirm your details:</p>
<p><strong>Name:</strong> Maria Santos Silva</p>
<p><strong>Birth Date:</strong> 15/03/1990</p>
<p><strong>Email:</strong> maria.santos@email.com</p>
<p><strong>Personal ID:</strong> 12345678901</p>
<p><strong>Address:</strong> Rua das Palmeiras, 456 - São Paulo, SP</p>
<div class="wa-time">10:33</div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Are these details correct? (yes/no)</p>
<div class="wa-time">10:33</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>yes</p>
<div class="wa-time">10:33 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Thank you! Your enrollment has been successfully submitted. ✅</p>
<p>You will receive a confirmation email shortly.</p>
<div class="wa-time">10:33</div>
</div>
</div>
</div>
### Dialog 2: User Corrects Information
<div class="wa-chat">
<div class="wa-header">
<div class="wa-header-avatar">🤖</div>
<div class="wa-header-info">
<div class="wa-header-name">Registration Bot</div>
<div class="wa-header-status">online</div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Please confirm your details:</p>
<p><strong>Name:</strong> João Pedro</p>
<p><strong>Birth Date:</strong> 20/05/1985</p>
<p><strong>Email:</strong> joao@email.com</p>
<p><strong>Personal ID:</strong> 98765432100</p>
<p><strong>Address:</strong> Av. Brasil, 100 - RJ</p>
<div class="wa-time">14:22</div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Are these details correct? (yes/no)</p>
<div class="wa-time">14:22</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>no</p>
<div class="wa-time">14:23 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Let's start over with the correct information.</p>
<div class="wa-time">14:23</div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>First, what is your full name?</p>
<div class="wa-time">14:23</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>João Pedro Oliveira</p>
<div class="wa-time">14:24 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-system"><span>User continues with corrected information...</span></div>
</div>
### Dialog 3: LLM-Assisted Natural Input
When using the LLM, users can provide information naturally:
<div class="wa-chat">
<div class="wa-header">
<div class="wa-header-avatar">🤖</div>
<div class="wa-header-info">
<div class="wa-header-name">Smart Registration</div>
<div class="wa-header-status">online</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>I'd like to sign up. My name is Carlos Ferreira, born on March 5th 1992, email carlos.f@gmail.com, ID 11122233344, and I live at Rua do Sol, 789 in Brasília</p>
<div class="wa-time">16:45 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<div class="wa-sender">Smart Registration</div>
<p>I've captured your information! Let me confirm:</p>
<p><strong>Name:</strong> Carlos Ferreira</p>
<p><strong>Birth Date:</strong> 05/03/1992</p>
<p><strong>Email:</strong> carlos.f@gmail.com</p>
<p><strong>Personal ID:</strong> 11122233344</p>
<p><strong>Address:</strong> Rua do Sol, 789 - Brasília</p>
<div class="wa-time">16:45</div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Is everything correct? 👆</p>
<div class="wa-time">16:45</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>Perfect! Yes</p>
<div class="wa-time">16:46 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>✅ Registration complete! Welcome aboard, Carlos!</p>
<div class="wa-time">16:46</div>
</div>
</div>
</div>
## Keywords Used
| Keyword | Purpose |
|---------|---------|
| `PARAM` | Define expected input parameters with types and examples |
| `DESCRIPTION` | Provide context for LLM understanding |
| `TALK` | Send messages to the user |
| `HEAR` | Wait for and capture user input |
| `IF/ELSE` | Conditional logic for confirmation |
| `SAVE` | Persist data to CSV file |
## How It Works
1. **Parameter Definition**: The `PARAM` declarations tell the LLM what information to collect
2. **Step-by-Step Collection**: Each `HEAR` captures one piece of data
3. **Confirmation Loop**: User reviews all data before submission
4. **Data Persistence**: `SAVE` stores the validated data
## Customization Ideas
### Add Validation
```basic
HEAR email
IF NOT INSTR(email, "@") THEN
TALK "Please enter a valid email address"
HEAR email
END IF
```
### Add to Database Instead of CSV
```basic
INSERT "users", name, birthday, email, personalid, address
```
### Send Confirmation Email
```basic
SEND MAIL email, "Welcome!", "Your registration is complete, " + name
```
## Related Templates
- [start.bas](./start.md) - Basic greeting flow
- [auth.bas](./auth.md) - Authentication patterns
---
<style>
/* Inline WhatsApp Chat Styles for this page */
.wa-chat{background-color:#e5ddd5;border-radius:8px;padding:20px 15px;margin:20px 0;max-width:600px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px}
.wa-chat::after{content:'';display:table;clear:both}
.wa-message{clear:both;margin-bottom:10px;max-width:85%;position:relative}
.wa-message.user{float:right}
.wa-message.user .wa-bubble{background-color:#dcf8c6;border-radius:8px 0 8px 8px;margin-left:40px}
.wa-message.bot{float:left}
.wa-message.bot .wa-bubble{background-color:#fff;border-radius:0 8px 8px 8px;margin-right:40px}
.wa-bubble{padding:8px 12px;box-shadow:0 1px .5px rgba(0,0,0,.13)}
.wa-bubble p{margin:0 0 4px 0;line-height:1.4;color:#303030}
.wa-bubble p:last-child{margin-bottom:0}
.wa-time{font-size:11px;color:#8696a0;text-align:right;margin-top:4px}
.wa-message.user .wa-time{color:#61a05e}
.wa-sender{font-size:12px;font-weight:600;color:#06cf9c;margin-bottom:2px}
.wa-status.read::after{content:'✓✓';color:#53bdeb;margin-left:4px}
.wa-system{text-align:center;margin:15px 0;clear:both}
.wa-system span{background-color:#e1f2fb;color:#54656f;padding:5px 12px;border-radius:8px;font-size:12px}
.wa-date{text-align:center;margin:15px 0;clear:both}
.wa-date span{background-color:#fff;color:#54656f;padding:5px 12px;border-radius:8px;font-size:12px;box-shadow:0 1px .5px rgba(0,0,0,.13)}
.wa-header{background-color:#075e54;color:#fff;padding:10px 15px;margin:-20px -15px 15px -15px;border-radius:8px 8px 0 0;display:flex;align-items:center;gap:10px}
.wa-header-avatar{width:40px;height:40px;background-color:#25d366;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px}
.wa-header-info{flex:1}
.wa-header-name{font-weight:600;font-size:16px}
.wa-header-status{font-size:12px;opacity:.8}
</style>

View file

@ -1,25 +1,274 @@
# start.bas (Template)
# Start Template
A minimal greeting and help flow to get users started.
The start template is the simplest possible bot - a greeting flow that demonstrates the core interaction pattern of BASIC: greeting users and responding to their input.
## Topic: Basic Greeting & Help Flow
This template is perfect for:
- Learning BASIC fundamentals
- Simple FAQ bots
- Quick demos
- Starting point for more complex bots
## The Code
```basic
REM Basic greeting and help flow
SET user_name = "Guest"
TALK "Hello, " + user_name + "! How can I help you today?"
HEAR user_input
IF user_input = "help" THEN
TALK "Sure, I can assist with account info, orders, or support."
ELSE
TALK "Sorry, I didn't understand."
ENDIF
TALK "Sorry, I didn't understand. Type 'help' for options."
END IF
```
**Purpose**
## Sample Dialogs
- Shows how to set a variable with `SET`.
- Uses `TALK` to send a message and `HEAR` to receive user input.
- Demonstrates simple branching with `IF/ELSE`.
These conversations show how the start template works in real-world scenarios.
**Keywords used:** `SET`, `TALK`, `HEAR`, `IF`, `ELSE`.
### Dialog 1: User Asks for Help
<div class="wa-chat">
<div class="wa-header">
<div class="wa-header-avatar">🤖</div>
<div class="wa-header-info">
<div class="wa-header-name">Helper Bot</div>
<div class="wa-header-status">online</div>
</div>
</div>
<div class="wa-date"><span>Today</span></div>
<div class="wa-message bot">
<div class="wa-bubble">
<div class="wa-sender">Helper Bot</div>
<p>Hello, Guest! How can I help you today? 👋</p>
<div class="wa-time">09:15</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>help</p>
<div class="wa-time">09:15 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Sure, I can assist with account info, orders, or support. 📋</p>
<div class="wa-time">09:15</div>
</div>
</div>
</div>
### Dialog 2: Unknown Input
<div class="wa-chat">
<div class="wa-header">
<div class="wa-header-avatar">🤖</div>
<div class="wa-header-info">
<div class="wa-header-name">Helper Bot</div>
<div class="wa-header-status">online</div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<div class="wa-sender">Helper Bot</div>
<p>Hello, Guest! How can I help you today? 👋</p>
<div class="wa-time">11:30</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>what's the weather?</p>
<div class="wa-time">11:30 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Sorry, I didn't understand. Type 'help' for options.</p>
<div class="wa-time">11:30</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>help</p>
<div class="wa-time">11:31 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Sure, I can assist with account info, orders, or support. 📋</p>
<div class="wa-time">11:31</div>
</div>
</div>
</div>
### Dialog 3: Personalized Greeting (Enhanced Version)
When you add user detection, the experience improves:
<div class="wa-chat">
<div class="wa-header">
<div class="wa-header-avatar">🤖</div>
<div class="wa-header-info">
<div class="wa-header-name">Smart Helper</div>
<div class="wa-header-status">online</div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<div class="wa-sender">Smart Helper</div>
<p>Hello, Maria! 👋 How can I help you today?</p>
<div class="wa-time">14:20</div>
</div>
</div>
<div class="wa-message user">
<div class="wa-bubble">
<p>I need help with my order</p>
<div class="wa-time">14:20 <span class="wa-status read"></span></div>
</div>
</div>
<div class="wa-message bot">
<div class="wa-bubble">
<p>Of course, Maria! I found your recent order #12345.</p>
<p>📦 <strong>Status:</strong> Shipped</p>
<p>🚚 <strong>Delivery:</strong> Tomorrow by 6pm</p>
<p>Is there anything specific about this order?</p>
<div class="wa-time">14:20</div>
</div>
</div>
</div>
## Keywords Used
| Keyword | Purpose |
|---------|---------|
| `SET` | Assign a value to a variable |
| `TALK` | Send a message to the user |
| `HEAR` | Wait for and capture user input |
| `IF/ELSE` | Conditional branching based on input |
## How It Works
1. **Variable Setup**: `SET` creates a variable to hold the user's name
2. **Greeting**: `TALK` sends the welcome message
3. **Input Capture**: `HEAR` waits for user response
4. **Response Logic**: `IF/ELSE` determines what to say back
## Enhanced Version
Here's the same template enhanced with LLM for natural understanding:
```basic
REM Smart greeting flow with LLM
SET user_name = "Guest"
TALK "Hello, " + user_name + "! How can I help you today?"
HEAR user_input
' Let LLM understand intent
intent = LLM "Classify this user message into one category: help, account, orders, support, other. Message: " + user_input
SWITCH intent
CASE "help"
TALK "I can assist with account info, orders, or support."
CASE "account"
TALK "Let me pull up your account information..."
CASE "orders"
TALK "I'll check on your recent orders..."
CASE "support"
TALK "Connecting you with our support team..."
DEFAULT
response = LLM "Respond helpfully to: " + user_input
TALK response
END SWITCH
```
## Customization Ideas
### Add User Detection
```basic
' Get user info if available
user_name = GET BOT MEMORY "user_" + user_id + "_name"
IF user_name = "" THEN
TALK "Hi there! What's your name?"
HEAR user_name
SET BOT MEMORY "user_" + user_id + "_name", user_name
END IF
TALK "Welcome back, " + user_name + "!"
```
### Add Quick Reply Buttons
```basic
ADD SUGGESTION "Account Info"
ADD SUGGESTION "My Orders"
ADD SUGGESTION "Get Support"
TALK "What would you like help with?"
HEAR choice
```
### Add Time-Based Greeting
```basic
hour = HOUR(NOW())
IF hour < 12 THEN
greeting = "Good morning"
ELSE IF hour < 18 THEN
greeting = "Good afternoon"
ELSE
greeting = "Good evening"
END IF
TALK greeting + ", " + user_name + "!"
```
## Related Templates
- [enrollment.bas](./enrollment.md) - Multi-step data collection
- [auth.bas](./auth.md) - User authentication patterns
---
<style>
/* Inline WhatsApp Chat Styles for this page */
.wa-chat{background-color:#e5ddd5;border-radius:8px;padding:20px 15px;margin:20px 0;max-width:600px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px}
.wa-chat::after{content:'';display:table;clear:both}
.wa-message{clear:both;margin-bottom:10px;max-width:85%;position:relative}
.wa-message.user{float:right}
.wa-message.user .wa-bubble{background-color:#dcf8c6;border-radius:8px 0 8px 8px;margin-left:40px}
.wa-message.bot{float:left}
.wa-message.bot .wa-bubble{background-color:#fff;border-radius:0 8px 8px 8px;margin-right:40px}
.wa-bubble{padding:8px 12px;box-shadow:0 1px .5px rgba(0,0,0,.13)}
.wa-bubble p{margin:0 0 4px 0;line-height:1.4;color:#303030}
.wa-bubble p:last-child{margin-bottom:0}
.wa-time{font-size:11px;color:#8696a0;text-align:right;margin-top:4px}
.wa-message.user .wa-time{color:#61a05e}
.wa-sender{font-size:12px;font-weight:600;color:#06cf9c;margin-bottom:2px}
.wa-status.read::after{content:'✓✓';color:#53bdeb;margin-left:4px}
.wa-system{text-align:center;margin:15px 0;clear:both}
.wa-system span{background-color:#e1f2fb;color:#54656f;padding:5px 12px;border-radius:8px;font-size:12px}
.wa-date{text-align:center;margin:15px 0;clear:both}
.wa-date span{background-color:#fff;color:#54656f;padding:5px 12px;border-radius:8px;font-size:12px;box-shadow:0 1px .5px rgba(0,0,0,.13)}
.wa-header{background-color:#075e54;color:#fff;padding:10px 15px;margin:-20px -15px 15px -15px;border-radius:8px 8px 0 0;display:flex;align-items:center;gap:10px}
.wa-header-avatar{width:40px;height:40px;background-color:#25d366;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px}
.wa-header-info{flex:1}
.wa-header-name{font-weight:600;font-size:16px}
.wa-header-status{font-size:12px;opacity:.8}
</style>

View file

@ -0,0 +1,306 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="900" viewBox="0 0 1200 900" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradients -->
<linearGradient id="headerGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#075e54"/>
<stop offset="100%" style="stop-color:#25d366"/>
</linearGradient>
<linearGradient id="userGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#667eea"/>
<stop offset="100%" style="stop-color:#764ba2"/>
</linearGradient>
<linearGradient id="botGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#11998e"/>
<stop offset="100%" style="stop-color:#38ef7d"/>
</linearGradient>
<linearGradient id="storageGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#f093fb"/>
<stop offset="100%" style="stop-color:#f5576c"/>
</linearGradient>
<linearGradient id="llmGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4facfe"/>
<stop offset="100%" style="stop-color:#00f2fe"/>
</linearGradient>
<linearGradient id="dbGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#fa709a"/>
<stop offset="100%" style="stop-color:#fee140"/>
</linearGradient>
<!-- Arrow marker -->
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#666"/>
</marker>
<marker id="arrowheadGreen" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#25d366"/>
</marker>
<marker id="arrowheadBlue" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#4facfe"/>
</marker>
<marker id="arrowheadPink" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#f5576c"/>
</marker>
<!-- Drop shadow -->
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.2"/>
</filter>
</defs>
<!-- Background -->
<rect width="1200" height="900" fill="#f8fafc"/>
<!-- Header -->
<rect x="0" y="0" width="1200" height="60" fill="url(#headerGradient)"/>
<text x="600" y="38" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="white" text-anchor="middle">
Data Traceability Diagram - General Bots Architecture
</text>
<!-- Legend -->
<g transform="translate(900, 80)">
<rect x="0" y="0" width="280" height="140" rx="8" fill="white" stroke="#e2e8f0" filter="url(#shadow)"/>
<text x="140" y="25" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#1a1a1a" text-anchor="middle">Legend</text>
<line x1="20" y1="50" x2="60" y2="50" stroke="#25d366" stroke-width="2" marker-end="url(#arrowheadGreen)"/>
<text x="75" y="54" font-family="Arial, sans-serif" font-size="12" fill="#666">User Input Flow</text>
<line x1="20" y1="75" x2="60" y2="75" stroke="#4facfe" stroke-width="2" marker-end="url(#arrowheadBlue)"/>
<text x="75" y="79" font-family="Arial, sans-serif" font-size="12" fill="#666">LLM Processing</text>
<line x1="20" y1="100" x2="60" y2="100" stroke="#f5576c" stroke-width="2" marker-end="url(#arrowheadPink)"/>
<text x="75" y="104" font-family="Arial, sans-serif" font-size="12" fill="#666">Storage Operations</text>
<line x1="20" y1="125" x2="60" y2="125" stroke="#666" stroke-width="2" stroke-dasharray="5,5" marker-end="url(#arrowhead)"/>
<text x="75" y="129" font-family="Arial, sans-serif" font-size="12" fill="#666">Internal Flow</text>
</g>
<!-- User/Channels Section -->
<g transform="translate(50, 100)">
<rect x="0" y="0" width="200" height="280" rx="12" fill="url(#userGradient)" filter="url(#shadow)"/>
<text x="100" y="30" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="white" text-anchor="middle">CHANNELS</text>
<!-- WhatsApp -->
<rect x="20" y="50" width="160" height="40" rx="6" fill="white" opacity="0.9"/>
<text x="100" y="75" font-family="Arial, sans-serif" font-size="13" fill="#333" text-anchor="middle">📱 WhatsApp</text>
<!-- Telegram -->
<rect x="20" y="100" width="160" height="40" rx="6" fill="white" opacity="0.9"/>
<text x="100" y="125" font-family="Arial, sans-serif" font-size="13" fill="#333" text-anchor="middle">✈️ Telegram</text>
<!-- Web UI -->
<rect x="20" y="150" width="160" height="40" rx="6" fill="white" opacity="0.9"/>
<text x="100" y="175" font-family="Arial, sans-serif" font-size="13" fill="#333" text-anchor="middle">🌐 Web UI / gbui</text>
<!-- API -->
<rect x="20" y="200" width="160" height="40" rx="6" fill="white" opacity="0.9"/>
<text x="100" y="225" font-family="Arial, sans-serif" font-size="13" fill="#333" text-anchor="middle">🔌 REST API</text>
<!-- Webhook -->
<rect x="20" y="250" width="160" height="20" rx="4" fill="white" opacity="0.7"/>
<text x="100" y="264" font-family="Arial, sans-serif" font-size="11" fill="#333" text-anchor="middle">🪝 Webhooks</text>
</g>
<!-- Bot Core -->
<g transform="translate(350, 120)">
<rect x="0" y="0" width="240" height="360" rx="12" fill="url(#botGradient)" filter="url(#shadow)"/>
<text x="120" y="30" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="white" text-anchor="middle">BOTSERVER CORE</text>
<!-- BASIC Interpreter -->
<rect x="20" y="50" width="200" height="50" rx="6" fill="white" opacity="0.95"/>
<text x="120" y="72" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="#333" text-anchor="middle">BASIC Interpreter</text>
<text x="120" y="88" font-family="Arial, sans-serif" font-size="10" fill="#666" text-anchor="middle">.bas scripts → Rhai engine</text>
<!-- Message Router -->
<rect x="20" y="110" width="200" height="50" rx="6" fill="white" opacity="0.95"/>
<text x="120" y="132" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="#333" text-anchor="middle">Message Router</text>
<text x="120" y="148" font-family="Arial, sans-serif" font-size="10" fill="#666" text-anchor="middle">TALK / HEAR / SEND</text>
<!-- Scheduler -->
<rect x="20" y="170" width="200" height="50" rx="6" fill="white" opacity="0.95"/>
<text x="120" y="192" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="#333" text-anchor="middle">Scheduler</text>
<text x="120" y="208" font-family="Arial, sans-serif" font-size="10" fill="#666" text-anchor="middle">SET SCHEDULE "every hour"</text>
<!-- Keywords Engine -->
<rect x="20" y="230" width="200" height="50" rx="6" fill="white" opacity="0.95"/>
<text x="120" y="252" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="#333" text-anchor="middle">Keywords Engine</text>
<text x="120" y="268" font-family="Arial, sans-serif" font-size="10" fill="#666" text-anchor="middle">GET/POST/FIND/SAVE/LLM</text>
<!-- Session Manager -->
<rect x="20" y="290" width="200" height="50" rx="6" fill="white" opacity="0.95"/>
<text x="120" y="312" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="#333" text-anchor="middle">Session Manager</text>
<text x="120" y="328" font-family="Arial, sans-serif" font-size="10" fill="#666" text-anchor="middle">user_id → bot_id → org_id</text>
</g>
<!-- LLM Section -->
<g transform="translate(680, 100)">
<rect x="0" y="0" width="200" height="200" rx="12" fill="url(#llmGradient)" filter="url(#shadow)"/>
<text x="100" y="30" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="white" text-anchor="middle">LLM PROVIDERS</text>
<rect x="20" y="50" width="160" height="30" rx="4" fill="white" opacity="0.9"/>
<text x="100" y="70" font-family="Arial, sans-serif" font-size="11" fill="#333" text-anchor="middle">🧠 Claude Opus 4</text>
<rect x="20" y="90" width="160" height="30" rx="4" fill="white" opacity="0.9"/>
<text x="100" y="110" font-family="Arial, sans-serif" font-size="11" fill="#333" text-anchor="middle">🤖 GPT-4 Turbo</text>
<rect x="20" y="130" width="160" height="30" rx="4" fill="white" opacity="0.9"/>
<text x="100" y="150" font-family="Arial, sans-serif" font-size="11" fill="#333" text-anchor="middle">💎 Gemini Pro</text>
<rect x="20" y="170" width="160" height="20" rx="4" fill="white" opacity="0.7"/>
<text x="100" y="184" font-family="Arial, sans-serif" font-size="10" fill="#333" text-anchor="middle">🦙 Local: Llama/Mistral</text>
</g>
<!-- Storage Section -->
<g transform="translate(680, 320)">
<rect x="0" y="0" width="200" height="200" rx="12" fill="url(#storageGradient)" filter="url(#shadow)"/>
<text x="100" y="30" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="white" text-anchor="middle">STORAGE (MinIO)</text>
<rect x="20" y="50" width="160" height="35" rx="4" fill="white" opacity="0.9"/>
<text x="100" y="65" font-family="Arial, sans-serif" font-size="11" font-weight="bold" fill="#333" text-anchor="middle">📁 /{org}/{botname}/</text>
<text x="100" y="78" font-family="Arial, sans-serif" font-size="9" fill="#666" text-anchor="middle">Bucket per bot</text>
<rect x="20" y="95" width="75" height="30" rx="4" fill="white" opacity="0.85"/>
<text x="57" y="114" font-family="Arial, sans-serif" font-size="10" fill="#333" text-anchor="middle">.gbdialog</text>
<rect x="105" y="95" width="75" height="30" rx="4" fill="white" opacity="0.85"/>
<text x="142" y="114" font-family="Arial, sans-serif" font-size="10" fill="#333" text-anchor="middle">.gbkb</text>
<rect x="20" y="135" width="75" height="30" rx="4" fill="white" opacity="0.85"/>
<text x="57" y="154" font-family="Arial, sans-serif" font-size="10" fill="#333" text-anchor="middle">.gbot</text>
<rect x="105" y="135" width="75" height="30" rx="4" fill="white" opacity="0.85"/>
<text x="142" y="154" font-family="Arial, sans-serif" font-size="10" fill="#333" text-anchor="middle">.gbtheme</text>
<rect x="20" y="175" width="160" height="18" rx="3" fill="white" opacity="0.7"/>
<text x="100" y="188" font-family="Arial, sans-serif" font-size="9" fill="#666" text-anchor="middle">uploads/ | exports/ | cache/</text>
</g>
<!-- Database Section -->
<g transform="translate(950, 180)">
<rect x="0" y="0" width="200" height="280" rx="12" fill="url(#dbGradient)" filter="url(#shadow)"/>
<text x="100" y="30" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="white" text-anchor="middle">DATABASE</text>
<rect x="20" y="50" width="160" height="35" rx="4" fill="white" opacity="0.9"/>
<text x="100" y="65" font-family="Arial, sans-serif" font-size="11" font-weight="bold" fill="#333" text-anchor="middle">🐘 PostgreSQL</text>
<text x="100" y="78" font-family="Arial, sans-serif" font-size="9" fill="#666" text-anchor="middle">Main database</text>
<rect x="20" y="95" width="160" height="25" rx="3" fill="white" opacity="0.85"/>
<text x="100" y="112" font-family="Arial, sans-serif" font-size="10" fill="#333" text-anchor="middle">organizations</text>
<rect x="20" y="125" width="160" height="25" rx="3" fill="white" opacity="0.85"/>
<text x="100" y="142" font-family="Arial, sans-serif" font-size="10" fill="#333" text-anchor="middle">bots</text>
<rect x="20" y="155" width="160" height="25" rx="3" fill="white" opacity="0.85"/>
<text x="100" y="172" font-family="Arial, sans-serif" font-size="10" fill="#333" text-anchor="middle">users / sessions</text>
<rect x="20" y="185" width="160" height="25" rx="3" fill="white" opacity="0.85"/>
<text x="100" y="202" font-family="Arial, sans-serif" font-size="10" fill="#333" text-anchor="middle">conversations</text>
<rect x="20" y="215" width="160" height="25" rx="3" fill="white" opacity="0.85"/>
<text x="100" y="232" font-family="Arial, sans-serif" font-size="10" fill="#333" text-anchor="middle">system_automations</text>
<rect x="20" y="250" width="160" height="20" rx="3" fill="white" opacity="0.7"/>
<text x="100" y="264" font-family="Arial, sans-serif" font-size="9" fill="#666" text-anchor="middle">🔴 Redis (cache/queue)</text>
</g>
<!-- Data Flow Arrows -->
<!-- Channels to Bot Core -->
<path d="M 250 240 L 350 300" stroke="#25d366" stroke-width="2" fill="none" marker-end="url(#arrowheadGreen)"/>
<path d="M 250 220 L 350 280" stroke="#25d366" stroke-width="2" fill="none" marker-end="url(#arrowheadGreen)"/>
<!-- Bot Core to LLM -->
<path d="M 590 200 L 680 200" stroke="#4facfe" stroke-width="2" fill="none" marker-end="url(#arrowheadBlue)"/>
<text x="635" y="190" font-family="Arial, sans-serif" font-size="9" fill="#4facfe">LLM</text>
<!-- LLM back to Bot Core -->
<path d="M 680 230 L 590 270" stroke="#4facfe" stroke-width="2" fill="none" marker-end="url(#arrowheadBlue)"/>
<!-- Bot Core to Storage -->
<path d="M 590 380 L 680 400" stroke="#f5576c" stroke-width="2" fill="none" marker-end="url(#arrowheadPink)"/>
<text x="620" y="375" font-family="Arial, sans-serif" font-size="9" fill="#f5576c">FILES</text>
<!-- Bot Core to Database -->
<path d="M 590 340 Q 750 340 950 340" stroke="#666" stroke-width="2" stroke-dasharray="5,5" fill="none" marker-end="url(#arrowhead)"/>
<text x="770" y="330" font-family="Arial, sans-serif" font-size="9" fill="#666">FIND/SAVE</text>
<!-- Storage to Database (metadata) -->
<path d="M 880 420 L 950 380" stroke="#666" stroke-width="1.5" stroke-dasharray="3,3" fill="none" marker-end="url(#arrowhead)"/>
<!-- Key Data Flows Section -->
<g transform="translate(50, 550)">
<rect x="0" y="0" width="1100" height="300" rx="12" fill="white" stroke="#e2e8f0" filter="url(#shadow)"/>
<text x="550" y="30" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="#1a1a1a" text-anchor="middle">Key Data Flows</text>
<!-- Flow 1: User Message -->
<g transform="translate(30, 50)">
<rect x="0" y="0" width="320" height="100" rx="8" fill="#e8f5e9" stroke="#4caf50"/>
<text x="160" y="25" font-family="Arial, sans-serif" font-size="13" font-weight="bold" fill="#2e7d32" text-anchor="middle">1. User Message Flow</text>
<text x="10" y="50" font-family="monospace" font-size="10" fill="#333">Channel → Router → Session</text>
<text x="10" y="65" font-family="monospace" font-size="10" fill="#333">→ BASIC Script → LLM (if needed)</text>
<text x="10" y="80" font-family="monospace" font-size="10" fill="#333">→ Response → Channel</text>
<text x="10" y="95" font-family="Arial, sans-serif" font-size="9" fill="#666">Key: {org}_{bot}_{user}_{session}</text>
</g>
<!-- Flow 2: Scheduled Task -->
<g transform="translate(380, 50)">
<rect x="0" y="0" width="320" height="100" rx="8" fill="#e3f2fd" stroke="#2196f3"/>
<text x="160" y="25" font-family="Arial, sans-serif" font-size="13" font-weight="bold" fill="#1565c0" text-anchor="middle">2. Scheduled Task Flow</text>
<text x="10" y="50" font-family="monospace" font-size="10" fill="#333">Cron Trigger → Load Script</text>
<text x="10" y="65" font-family="monospace" font-size="10" fill="#333">→ Execute Keywords → External APIs</text>
<text x="10" y="80" font-family="monospace" font-size="10" fill="#333">→ Save Results → Log</text>
<text x="10" y="95" font-family="Arial, sans-serif" font-size="9" fill="#666">Key: {org}_{bot}_schedule_{name}</text>
</g>
<!-- Flow 3: File Operations -->
<g transform="translate(730, 50)">
<rect x="0" y="0" width="320" height="100" rx="8" fill="#fce4ec" stroke="#e91e63"/>
<text x="160" y="25" font-family="Arial, sans-serif" font-size="13" font-weight="bold" fill="#c2185b" text-anchor="middle">3. File Operations Flow</text>
<text x="10" y="50" font-family="monospace" font-size="10" fill="#333">UPLOAD/DOWNLOAD → MinIO</text>
<text x="10" y="65" font-family="monospace" font-size="10" fill="#333">Bucket: /{org}/{botname}/</text>
<text x="10" y="80" font-family="monospace" font-size="10" fill="#333">→ Metadata → PostgreSQL</text>
<text x="10" y="95" font-family="Arial, sans-serif" font-size="9" fill="#666">Path: s3://{org}/{bot}/{path}</text>
</g>
<!-- Flow 4: Knowledge Base -->
<g transform="translate(30, 165)">
<rect x="0" y="0" width="320" height="100" rx="8" fill="#fff3e0" stroke="#ff9800"/>
<text x="160" y="25" font-family="Arial, sans-serif" font-size="13" font-weight="bold" fill="#e65100" text-anchor="middle">4. Knowledge Base Flow</text>
<text x="10" y="50" font-family="monospace" font-size="10" fill="#333">USE KB "docs" → Load .gbkb</text>
<text x="10" y="65" font-family="monospace" font-size="10" fill="#333">→ Embed → Vector DB (pgvector)</text>
<text x="10" y="80" font-family="monospace" font-size="10" fill="#333">→ Semantic Search → LLM Context</text>
<text x="10" y="95" font-family="Arial, sans-serif" font-size="9" fill="#666">Key: {org}_{bot}_kb_{name}</text>
</g>
<!-- Flow 5: Webhook -->
<g transform="translate(380, 165)">
<rect x="0" y="0" width="320" height="100" rx="8" fill="#f3e5f5" stroke="#9c27b0"/>
<text x="160" y="25" font-family="Arial, sans-serif" font-size="13" font-weight="bold" fill="#7b1fa2" text-anchor="middle">5. Webhook Flow</text>
<text x="10" y="50" font-family="monospace" font-size="10" fill="#333">External POST → /webhook/{id}</text>
<text x="10" y="65" font-family="monospace" font-size="10" fill="#333">→ Validate → Trigger Script</text>
<text x="10" y="80" font-family="monospace" font-size="10" fill="#333">→ Process → Response/Notify</text>
<text x="10" y="95" font-family="Arial, sans-serif" font-size="9" fill="#666">Key: {org}_{bot}_webhook_{path}</text>
</g>
<!-- Flow 6: Bot Memory -->
<g transform="translate(730, 165)">
<rect x="0" y="0" width="320" height="100" rx="8" fill="#e0f2f1" stroke="#009688"/>
<text x="160" y="25" font-family="Arial, sans-serif" font-size="13" font-weight="bold" fill="#00695c" text-anchor="middle">6. Bot Memory Flow</text>
<text x="10" y="50" font-family="monospace" font-size="10" fill="#333">SET/GET BOT MEMORY → Redis</text>
<text x="10" y="65" font-family="monospace" font-size="10" fill="#333">→ Persist → PostgreSQL (backup)</text>
<text x="10" y="80" font-family="monospace" font-size="10" fill="#333">→ Scope: global | user | session</text>
<text x="10" y="95" font-family="Arial, sans-serif" font-size="9" fill="#666">Key: {org}_{bot}_mem_{scope}_{key}</text>
</g>
</g>
<!-- Footer -->
<text x="600" y="880" font-family="Arial, sans-serif" font-size="11" fill="#666" text-anchor="middle">
General Bots Data Traceability - All keys follow pattern: {org}_{botname}_{resource}_{identifier}
</text>
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

447
docs/src/whatsapp-chat.css Normal file
View file

@ -0,0 +1,447 @@
/* WhatsApp-style Chat CSS for Template Documentation
* Use this to display sample dialogs that demonstrate how templates work
*/
/* Chat Container */
.wa-chat {
background-color: #e5ddd5;
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M54.627 0l.83.828-1.415 1.415L51.8 0h2.827zM5.373 0l-.83.828L5.96 2.243 8.2 0H5.374zM48.97 0l3.657 3.657-1.414 1.414L46.143 0h2.828zM11.03 0L7.372 3.657 8.787 5.07 13.857 0H11.03zm32.284 0L49.8 6.485 48.384 7.9l-7.9-7.9h2.83zM16.686 0L10.2 6.485 11.616 7.9l7.9-7.9h-2.83zM22.343 0L13.857 8.485 15.272 9.9l9.9-9.9h-2.83zM32 0l-3.486 3.485-1.414-1.414L30.172 0H32zM0 5.373l.828-.83 1.415 1.415L0 8.2V5.374zm0 5.656l.828-.829 5.657 5.657-1.414 1.414L0 11.03v2.828zm0 5.656l.828-.828 8.485 8.485-1.414 1.414L0 16.686v2.83zm0 5.657l.828-.828 11.314 11.314-1.414 1.414L0 22.343v2.83zM0 32l.828-.828 14.142 14.142-1.414 1.414L0 32.172V32z' fill='%23c5c5c5' fill-opacity='0.1' fill-rule='evenodd'/%3E%3C/svg%3E");
border-radius: 8px;
padding: 20px 15px;
margin: 20px 0;
max-width: 600px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
}
/* Dark theme support */
.coal .wa-chat,
.navy .wa-chat,
.ayu .wa-chat,
.dark .wa-chat {
background-color: #0b141a;
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M54.627 0l.83.828-1.415 1.415L51.8 0h2.827zM5.373 0l-.83.828L5.96 2.243 8.2 0H5.374zM48.97 0l3.657 3.657-1.414 1.414L46.143 0h2.828zM11.03 0L7.372 3.657 8.787 5.07 13.857 0H11.03zm32.284 0L49.8 6.485 48.384 7.9l-7.9-7.9h2.83zM16.686 0L10.2 6.485 11.616 7.9l7.9-7.9h-2.83zM22.343 0L13.857 8.485 15.272 9.9l9.9-9.9h-2.83zM32 0l-3.486 3.485-1.414-1.414L30.172 0H32zM0 5.373l.828-.83 1.415 1.415L0 8.2V5.374zm0 5.656l.828-.829 5.657 5.657-1.414 1.414L0 11.03v2.828zm0 5.656l.828-.828 8.485 8.485-1.414 1.414L0 16.686v2.83zm0 5.657l.828-.828 11.314 11.314-1.414 1.414L0 22.343v2.83zM0 32l.828-.828 14.142 14.142-1.414 1.414L0 32.172V32z' fill='%23ffffff' fill-opacity='0.05' fill-rule='evenodd'/%3E%3C/svg%3E");
}
/* Message Bubbles Base */
.wa-message {
clear: both;
margin-bottom: 10px;
max-width: 85%;
position: relative;
}
/* User Messages (Right side, light gray) */
.wa-message.user {
float: right;
}
.wa-message.user .wa-bubble {
background-color: #dcf8c6;
border-radius: 8px 0 8px 8px;
margin-left: 40px;
position: relative;
}
.wa-message.user .wa-bubble::after {
content: '';
position: absolute;
right: -8px;
top: 0;
border: 8px solid transparent;
border-left-color: #dcf8c6;
border-top-color: #dcf8c6;
}
/* Dark theme user messages */
.coal .wa-message.user .wa-bubble,
.navy .wa-message.user .wa-bubble,
.ayu .wa-message.user .wa-bubble,
.dark .wa-message.user .wa-bubble {
background-color: #005c4b;
}
.coal .wa-message.user .wa-bubble::after,
.navy .wa-message.user .wa-bubble::after,
.ayu .wa-message.user .wa-bubble::after,
.dark .wa-message.user .wa-bubble::after {
border-left-color: #005c4b;
border-top-color: #005c4b;
}
/* Bot Messages (Left side, white) */
.wa-message.bot {
float: left;
}
.wa-message.bot .wa-bubble {
background-color: #ffffff;
border-radius: 0 8px 8px 8px;
margin-right: 40px;
position: relative;
}
.wa-message.bot .wa-bubble::before {
content: '';
position: absolute;
left: -8px;
top: 0;
border: 8px solid transparent;
border-right-color: #ffffff;
border-top-color: #ffffff;
}
/* Dark theme bot messages */
.coal .wa-message.bot .wa-bubble,
.navy .wa-message.bot .wa-bubble,
.ayu .wa-message.bot .wa-bubble,
.dark .wa-message.bot .wa-bubble {
background-color: #202c33;
}
.coal .wa-message.bot .wa-bubble::before,
.navy .wa-message.bot .wa-bubble::before,
.ayu .wa-message.bot .wa-bubble::before,
.dark .wa-message.bot .wa-bubble::before {
border-right-color: #202c33;
border-top-color: #202c33;
}
/* Bubble Content */
.wa-bubble {
padding: 8px 12px;
box-shadow: 0 1px 0.5px rgba(0, 0, 0, 0.13);
}
.wa-bubble p {
margin: 0;
line-height: 1.4;
color: #303030;
word-wrap: break-word;
}
/* Dark theme text */
.coal .wa-bubble p,
.navy .wa-bubble p,
.ayu .wa-bubble p,
.dark .wa-bubble p {
color: #e9edef;
}
/* Timestamp */
.wa-time {
font-size: 11px;
color: #8696a0;
text-align: right;
margin-top: 4px;
}
.wa-message.user .wa-time {
color: #61a05e;
}
.coal .wa-message.user .wa-time,
.navy .wa-message.user .wa-time,
.ayu .wa-message.user .wa-time,
.dark .wa-message.user .wa-time {
color: #8eb589;
}
/* Sender Name (for bot) */
.wa-sender {
font-size: 12px;
font-weight: 600;
color: #06cf9c;
margin-bottom: 2px;
}
/* Checkmarks for sent/delivered/read */
.wa-status {
display: inline-block;
margin-left: 4px;
font-size: 12px;
}
.wa-status.sent::after {
content: '✓';
color: #8696a0;
}
.wa-status.delivered::after {
content: '✓✓';
color: #8696a0;
}
.wa-status.read::after {
content: '✓✓';
color: #53bdeb;
}
/* System Messages */
.wa-system {
text-align: center;
margin: 15px 0;
clear: both;
}
.wa-system span {
background-color: #e1f2fb;
color: #54656f;
padding: 5px 12px;
border-radius: 8px;
font-size: 12px;
box-shadow: 0 1px 0.5px rgba(0, 0, 0, 0.13);
}
.coal .wa-system span,
.navy .wa-system span,
.ayu .wa-system span,
.dark .wa-system span {
background-color: #182229;
color: #8696a0;
}
/* Date Separator */
.wa-date {
text-align: center;
margin: 15px 0;
clear: both;
}
.wa-date span {
background-color: #ffffff;
color: #54656f;
padding: 5px 12px;
border-radius: 8px;
font-size: 12px;
box-shadow: 0 1px 0.5px rgba(0, 0, 0, 0.13);
}
.coal .wa-date span,
.navy .wa-date span,
.ayu .wa-date span,
.dark .wa-date span {
background-color: #182229;
color: #8696a0;
}
/* Typing Indicator */
.wa-typing {
float: left;
clear: both;
}
.wa-typing .wa-bubble {
background-color: #ffffff;
border-radius: 0 8px 8px 8px;
padding: 12px 16px;
}
.coal .wa-typing .wa-bubble,
.navy .wa-typing .wa-bubble,
.ayu .wa-typing .wa-bubble,
.dark .wa-typing .wa-bubble {
background-color: #202c33;
}
.wa-typing-dots {
display: flex;
gap: 4px;
}
.wa-typing-dots span {
width: 8px;
height: 8px;
background-color: #8696a0;
border-radius: 50%;
animation: wa-typing 1.4s infinite ease-in-out;
}
.wa-typing-dots span:nth-child(1) { animation-delay: 0s; }
.wa-typing-dots span:nth-child(2) { animation-delay: 0.2s; }
.wa-typing-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes wa-typing {
0%, 60%, 100% { transform: translateY(0); opacity: 0.6; }
30% { transform: translateY(-4px); opacity: 1; }
}
/* Clear float */
.wa-chat::after {
content: '';
display: table;
clear: both;
}
/* Chat Header (optional) */
.wa-header {
background-color: #075e54;
color: white;
padding: 10px 15px;
margin: -20px -15px 15px -15px;
border-radius: 8px 8px 0 0;
display: flex;
align-items: center;
gap: 10px;
}
.coal .wa-header,
.navy .wa-header,
.ayu .wa-header,
.dark .wa-header {
background-color: #202c33;
}
.wa-header-avatar {
width: 40px;
height: 40px;
background-color: #25d366;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.wa-header-info {
flex: 1;
}
.wa-header-name {
font-weight: 600;
font-size: 16px;
}
.wa-header-status {
font-size: 12px;
opacity: 0.8;
}
/* Input Area (for visual reference) */
.wa-input {
background-color: #f0f2f5;
margin: 15px -15px -20px -15px;
padding: 10px 15px;
border-radius: 0 0 8px 8px;
display: flex;
align-items: center;
gap: 10px;
}
.coal .wa-input,
.navy .wa-input,
.ayu .wa-input,
.dark .wa-input {
background-color: #202c33;
}
.wa-input-field {
flex: 1;
background-color: #ffffff;
border: none;
border-radius: 20px;
padding: 10px 15px;
font-size: 14px;
color: #3b4a54;
}
.coal .wa-input-field,
.navy .wa-input-field,
.ayu .wa-input-field,
.dark .wa-input-field {
background-color: #2a3942;
color: #e9edef;
}
/* Code in messages */
.wa-bubble code {
background-color: rgba(0, 0, 0, 0.08);
padding: 2px 4px;
border-radius: 3px;
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
font-size: 13px;
}
.coal .wa-bubble code,
.navy .wa-bubble code,
.ayu .wa-bubble code,
.dark .wa-bubble code {
background-color: rgba(255, 255, 255, 0.1);
}
/* Links in messages */
.wa-bubble a {
color: #027eb5;
text-decoration: none;
}
.wa-bubble a:hover {
text-decoration: underline;
}
.coal .wa-bubble a,
.navy .wa-bubble a,
.ayu .wa-bubble a,
.dark .wa-bubble a {
color: #53bdeb;
}
/* Emoji sizing */
.wa-bubble .emoji {
font-size: 20px;
}
/* Media messages placeholder */
.wa-media {
background-color: rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 40px 60px;
text-align: center;
color: #8696a0;
font-size: 12px;
margin-bottom: 4px;
}
.wa-media::before {
content: '📷';
display: block;
font-size: 24px;
margin-bottom: 4px;
}
/* Reply/Quote */
.wa-reply {
background-color: rgba(0, 0, 0, 0.05);
border-left: 4px solid #06cf9c;
padding: 6px 10px;
margin-bottom: 6px;
border-radius: 4px;
font-size: 13px;
}
.wa-reply-name {
color: #06cf9c;
font-weight: 600;
font-size: 12px;
}
.wa-reply-text {
color: #667781;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.coal .wa-reply,
.navy .wa-reply,
.ayu .wa-reply,
.dark .wa-reply {
background-color: rgba(255, 255, 255, 0.05);
}
.coal .wa-reply-text,
.navy .wa-reply-text,
.ayu .wa-reply-text,
.dark .wa-reply-text {
color: #8696a0;
}

View file

@ -1,81 +1,338 @@
use crate::shared::models::UserSession;
use crate::shared::state::AppState;
use chrono::{Local, Utc};
use chrono::{Datelike, Local, Timelike, Utc};
use log::debug;
use rhai::Engine;
use rhai::{Dynamic, Engine, Map};
use std::sync::Arc;
/// Creates a datetime map object with property access
/// Usage in BASIC:
/// dt = NOW()
/// dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
/// dt.weekday, dt.timestamp, dt.formatted
fn create_datetime_map(local: chrono::DateTime<Local>) -> Map {
let mut map = Map::new();
// Date components
map.insert("year".into(), Dynamic::from(local.year() as i64));
map.insert("month".into(), Dynamic::from(local.month() as i64));
map.insert("day".into(), Dynamic::from(local.day() as i64));
// Time components
map.insert("hour".into(), Dynamic::from(local.hour() as i64));
map.insert("minute".into(), Dynamic::from(local.minute() as i64));
map.insert("second".into(), Dynamic::from(local.second() as i64));
// Weekday (1=Sunday, 7=Saturday)
map.insert(
"weekday".into(),
Dynamic::from(local.weekday().num_days_from_sunday() as i64 + 1),
);
// Weekday name
let weekday_name = match local.weekday().num_days_from_sunday() {
0 => "Sunday",
1 => "Monday",
2 => "Tuesday",
3 => "Wednesday",
4 => "Thursday",
5 => "Friday",
6 => "Saturday",
_ => "Unknown",
};
map.insert(
"weekday_name".into(),
Dynamic::from(weekday_name.to_string()),
);
// Month name
let month_name = match local.month() {
1 => "January",
2 => "February",
3 => "March",
4 => "April",
5 => "May",
6 => "June",
7 => "July",
8 => "August",
9 => "September",
10 => "October",
11 => "November",
12 => "December",
_ => "Unknown",
};
map.insert("month_name".into(), Dynamic::from(month_name.to_string()));
// Timestamp (Unix epoch)
map.insert("timestamp".into(), Dynamic::from(local.timestamp()));
// Pre-formatted strings
map.insert(
"formatted".into(),
Dynamic::from(local.format("%Y-%m-%d %H:%M:%S").to_string()),
);
map.insert(
"date".into(),
Dynamic::from(local.format("%Y-%m-%d").to_string()),
);
map.insert(
"time".into(),
Dynamic::from(local.format("%H:%M:%S").to_string()),
);
map.insert(
"iso".into(),
Dynamic::from(local.format("%Y-%m-%dT%H:%M:%S%z").to_string()),
);
// Quarter
let quarter = ((local.month() - 1) / 3) + 1;
map.insert("quarter".into(), Dynamic::from(quarter as i64));
// Day of year
map.insert("day_of_year".into(), Dynamic::from(local.ordinal() as i64));
// Is weekend
let is_weekend =
local.weekday().num_days_from_sunday() == 0 || local.weekday().num_days_from_sunday() == 6;
map.insert("is_weekend".into(), Dynamic::from(is_weekend));
// AM/PM
let is_pm = local.hour() >= 12;
map.insert("is_pm".into(), Dynamic::from(is_pm));
map.insert(
"ampm".into(),
Dynamic::from(if is_pm { "PM" } else { "AM" }.to_string()),
);
// Hour in 12-hour format
let hour12 = if local.hour() == 0 {
12
} else if local.hour() > 12 {
local.hour() - 12
} else {
local.hour()
};
map.insert("hour12".into(), Dynamic::from(hour12 as i64));
map
}
pub fn now_keyword(_state: &Arc<AppState>, _user: UserSession, engine: &mut Engine) {
engine.register_fn("NOW", || -> String {
// NOW() returns a datetime object with properties
// Usage: dt = NOW()
// dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
engine.register_fn("NOW", || -> Map { create_datetime_map(Local::now()) });
engine.register_fn("now", || -> Map { create_datetime_map(Local::now()) });
// NOW_UTC returns UTC datetime object
engine.register_fn("NOW_UTC", || -> Map {
let utc = Utc::now();
let local = utc.with_timezone(&Local);
create_datetime_map(local)
});
// NOW_STR for backward compatibility - returns plain string
engine.register_fn("NOW_STR", || -> String {
Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
});
engine.register_fn("now", || -> String {
engine.register_fn("now_str", || -> String {
Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
});
engine.register_fn("NOW_UTC", || -> String {
Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()
});
debug!("Registered NOW keyword");
debug!("Registered NOW keyword with .property access");
}
pub fn today_keyword(_state: &Arc<AppState>, _user: UserSession, engine: &mut Engine) {
engine.register_fn("TODAY", || -> String {
// TODAY() returns a date object with properties
// Usage: d = TODAY()
// d.year, d.month, d.day, d.weekday
engine.register_fn("TODAY", || -> Map {
let now = Local::now();
let mut map = Map::new();
map.insert("year".into(), Dynamic::from(now.year() as i64));
map.insert("month".into(), Dynamic::from(now.month() as i64));
map.insert("day".into(), Dynamic::from(now.day() as i64));
map.insert(
"weekday".into(),
Dynamic::from(now.weekday().num_days_from_sunday() as i64 + 1),
);
map.insert(
"formatted".into(),
Dynamic::from(now.format("%Y-%m-%d").to_string()),
);
map.insert("day_of_year".into(), Dynamic::from(now.ordinal() as i64));
let is_weekend =
now.weekday().num_days_from_sunday() == 0 || now.weekday().num_days_from_sunday() == 6;
map.insert("is_weekend".into(), Dynamic::from(is_weekend));
let quarter = ((now.month() - 1) / 3) + 1;
map.insert("quarter".into(), Dynamic::from(quarter as i64));
map
});
engine.register_fn("today", || -> Map {
let now = Local::now();
let mut map = Map::new();
map.insert("year".into(), Dynamic::from(now.year() as i64));
map.insert("month".into(), Dynamic::from(now.month() as i64));
map.insert("day".into(), Dynamic::from(now.day() as i64));
map.insert(
"weekday".into(),
Dynamic::from(now.weekday().num_days_from_sunday() as i64 + 1),
);
map.insert(
"formatted".into(),
Dynamic::from(now.format("%Y-%m-%d").to_string()),
);
map
});
// TODAY_STR for backward compatibility
engine.register_fn("TODAY_STR", || -> String {
Local::now().format("%Y-%m-%d").to_string()
});
engine.register_fn("today", || -> String {
engine.register_fn("today_str", || -> String {
Local::now().format("%Y-%m-%d").to_string()
});
debug!("Registered TODAY keyword");
debug!("Registered TODAY keyword with .property access");
}
pub fn time_keyword(_state: &Arc<AppState>, _user: UserSession, engine: &mut Engine) {
engine.register_fn("TIME", || -> String {
Local::now().format("%H:%M:%S").to_string()
});
engine.register_fn("time", || -> String {
// TIME() returns a time object with properties
// Usage: t = TIME()
// t.hour, t.minute, t.second
engine.register_fn("TIME", || -> Map {
let now = Local::now();
let mut map = Map::new();
map.insert("hour".into(), Dynamic::from(now.hour() as i64));
map.insert("minute".into(), Dynamic::from(now.minute() as i64));
map.insert("second".into(), Dynamic::from(now.second() as i64));
map.insert(
"formatted".into(),
Dynamic::from(now.format("%H:%M:%S").to_string()),
);
let is_pm = now.hour() >= 12;
map.insert("is_pm".into(), Dynamic::from(is_pm));
map.insert(
"ampm".into(),
Dynamic::from(if is_pm { "PM" } else { "AM" }.to_string()),
);
let hour12 = if now.hour() == 0 {
12
} else if now.hour() > 12 {
now.hour() - 12
} else {
now.hour()
};
map.insert("hour12".into(), Dynamic::from(hour12 as i64));
map
});
engine.register_fn("time", || -> Map {
let now = Local::now();
let mut map = Map::new();
map.insert("hour".into(), Dynamic::from(now.hour() as i64));
map.insert("minute".into(), Dynamic::from(now.minute() as i64));
map.insert("second".into(), Dynamic::from(now.second() as i64));
map.insert(
"formatted".into(),
Dynamic::from(now.format("%H:%M:%S").to_string()),
);
map
});
// TIME_STR for backward compatibility
engine.register_fn("TIME_STR", || -> String {
Local::now().format("%H:%M:%S").to_string()
});
// TIMESTAMP returns Unix epoch directly (not an object)
engine.register_fn("TIMESTAMP", || -> i64 { Utc::now().timestamp() });
engine.register_fn("timestamp", || -> i64 { Utc::now().timestamp() });
debug!("Registered TIME keyword");
debug!("Registered TIME keyword with .property access");
}
pub fn timestamp_keyword(_state: &Arc<AppState>, _user: UserSession, engine: &mut Engine) {
// Direct timestamp access - returns integer, not object
engine.register_fn("UNIX_TIMESTAMP", || -> i64 { Utc::now().timestamp() });
engine.register_fn("TIMESTAMP_MS", || -> i64 { Utc::now().timestamp_millis() });
debug!("Registered TIMESTAMP keyword");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_now_format() {
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
assert!(now.len() == 19);
assert!(now.contains('-'));
assert!(now.contains(':'));
fn test_create_datetime_map() {
let now = Local::now();
let map = create_datetime_map(now);
assert!(map.contains_key("year"));
assert!(map.contains_key("month"));
assert!(map.contains_key("day"));
assert!(map.contains_key("hour"));
assert!(map.contains_key("minute"));
assert!(map.contains_key("second"));
assert!(map.contains_key("weekday"));
assert!(map.contains_key("timestamp"));
assert!(map.contains_key("formatted"));
assert!(map.contains_key("is_weekend"));
assert!(map.contains_key("quarter"));
}
#[test]
fn test_today_format() {
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
assert!(today.len() == 10);
assert!(today.contains('-'));
fn test_year_extraction() {
let now = Local::now();
let map = create_datetime_map(now);
let year = map.get("year").unwrap().as_int().unwrap();
assert!(year >= 2024);
}
#[test]
fn test_time_format() {
let time = chrono::Local::now().format("%H:%M:%S").to_string();
assert!(time.len() == 8);
assert!(time.contains(':'));
fn test_month_range() {
let now = Local::now();
let map = create_datetime_map(now);
let month = map.get("month").unwrap().as_int().unwrap();
assert!(month >= 1 && month <= 12);
}
#[test]
fn test_timestamp() {
let ts = chrono::Utc::now().timestamp();
assert!(ts > 1700000000);
fn test_hour12_range() {
let now = Local::now();
let map = create_datetime_map(now);
let hour12 = map.get("hour12").unwrap().as_int().unwrap();
assert!(hour12 >= 1 && hour12 <= 12);
}
#[test]
fn test_quarter_calculation() {
let now = Local::now();
let map = create_datetime_map(now);
let quarter = map.get("quarter").unwrap().as_int().unwrap();
assert!(quarter >= 1 && quarter <= 4);
}
}

View file

@ -3,44 +3,407 @@ use diesel::prelude::*;
use log::trace;
use serde_json::{json, Value};
use uuid::Uuid;
/// Parses natural language schedule expressions into cron format.
/// Uses a fast rule-based parser - no LLM or external dependencies needed.
///
/// # Supported Patterns
///
/// ## Time Intervals
/// - "every minute" -> "* * * * *"
/// - "every 5 minutes" -> "*/5 * * * *"
/// - "every hour" -> "0 * * * *"
/// - "every 2 hours" -> "0 */2 * * *"
/// - "every day" / "daily" -> "0 0 * * *"
/// - "every week" / "weekly" -> "0 0 * * 0"
/// - "every month" / "monthly" -> "0 0 1 * *"
///
/// ## Specific Times
/// - "at 9am" -> "0 9 * * *"
/// - "at 9:30am" -> "30 9 * * *"
/// - "at 14:00" -> "0 14 * * *"
/// - "at midnight" -> "0 0 * * *"
/// - "at noon" -> "0 12 * * *"
///
/// ## Day-specific
/// - "every monday" -> "0 0 * * 1"
/// - "every monday at 9am" -> "0 9 * * 1"
/// - "weekdays" / "every weekday" -> "0 0 * * 1-5"
/// - "weekends" -> "0 0 * * 0,6"
/// - "weekdays at 8am" -> "0 8 * * 1-5"
///
/// ## Combined
/// - "every day at 9am" -> "0 9 * * *"
/// - "every hour from 9 to 17" -> "0 9-17 * * *"
/// - "every 30 minutes during business hours" -> "*/30 9-17 * * 1-5"
///
/// ## Raw Cron (fallback)
/// - Any 5-part cron expression is passed through: "0 */2 * * *"
pub fn parse_natural_schedule(input: &str) -> Result<String, String> {
let input = input.trim().to_lowercase();
// If it looks like a cron expression (5 space-separated parts), pass through
let parts: Vec<&str> = input.split_whitespace().collect();
if parts.len() == 5 && is_cron_expression(&parts) {
return Ok(input);
}
// Parse natural language
parse_natural_language(&input)
}
fn is_cron_expression(parts: &[&str]) -> bool {
// Check if all parts look like valid cron fields
parts.iter().all(|part| {
part.chars()
.all(|c| c.is_ascii_digit() || c == '*' || c == '/' || c == '-' || c == ',')
})
}
fn parse_natural_language(input: &str) -> Result<String, String> {
// Normalize input
let input = input
.replace("every ", "every_")
.replace(" at ", "_at_")
.replace(" from ", "_from_")
.replace(" to ", "_to_")
.replace(" during ", "_during_");
let input = input.trim();
// Simple interval patterns
if let Some(cron) = parse_simple_interval(input) {
return Ok(cron);
}
// Time-specific patterns
if let Some(cron) = parse_at_time(input) {
return Ok(cron);
}
// Day-specific patterns
if let Some(cron) = parse_day_pattern(input) {
return Ok(cron);
}
// Combined patterns
if let Some(cron) = parse_combined_pattern(input) {
return Ok(cron);
}
// Business hours patterns
if let Some(cron) = parse_business_hours(input) {
return Ok(cron);
}
Err(format!(
"Could not parse schedule '{}'. Use patterns like 'every hour', 'every 5 minutes', \
'at 9am', 'every monday at 9am', 'weekdays at 8am', or raw cron '0 * * * *'",
input.replace('_', " ")
))
}
fn parse_simple_interval(input: &str) -> Option<String> {
// every_minute
if input == "every_minute" || input == "every_1_minute" {
return Some("* * * * *".to_string());
}
// every_N_minutes
if let Some(rest) = input.strip_prefix("every_") {
if let Some(num_str) = rest.strip_suffix("_minutes") {
if let Ok(n) = num_str.parse::<u32>() {
if n > 0 && n <= 59 {
return Some(format!("*/{} * * * *", n));
}
}
}
// every_hour
if rest == "hour" || rest == "1_hour" {
return Some("0 * * * *".to_string());
}
// every_N_hours
if let Some(num_str) = rest.strip_suffix("_hours") {
if let Ok(n) = num_str.parse::<u32>() {
if n > 0 && n <= 23 {
return Some(format!("0 */{} * * *", n));
}
}
}
// every_day / daily
if rest == "day" {
return Some("0 0 * * *".to_string());
}
// every_week / weekly
if rest == "week" {
return Some("0 0 * * 0".to_string());
}
// every_month / monthly
if rest == "month" {
return Some("0 0 1 * *".to_string());
}
// every_year / yearly
if rest == "year" {
return Some("0 0 1 1 *".to_string());
}
}
// Aliases
match input {
"daily" => Some("0 0 * * *".to_string()),
"weekly" => Some("0 0 * * 0".to_string()),
"monthly" => Some("0 0 1 * *".to_string()),
"yearly" | "annually" => Some("0 0 1 1 *".to_string()),
"hourly" => Some("0 * * * *".to_string()),
_ => None,
}
}
fn parse_at_time(input: &str) -> Option<String> {
// Handle "_at_TIME" patterns
let time_str = if input.starts_with("_at_") {
&input[4..]
} else if input.starts_with("at_") {
&input[3..]
} else {
return None;
};
parse_time_to_cron(time_str, "*", "*")
}
fn parse_time_to_cron(time_str: &str, hour_default: &str, dow: &str) -> Option<String> {
// midnight
if time_str == "midnight" {
return Some(format!("0 0 * * {}", dow));
}
// noon
if time_str == "noon" {
return Some(format!("0 12 * * {}", dow));
}
// Parse time like "9am", "9:30am", "14:00", "9:30pm"
let (hour, minute) = parse_time_value(time_str)?;
Some(format!("{} {} * * {}", minute, hour, dow))
}
fn parse_time_value(time_str: &str) -> Option<(u32, u32)> {
let time_str = time_str.trim();
// Check for am/pm
let (time_part, is_pm) = if time_str.ends_with("am") {
(&time_str[..time_str.len() - 2], false)
} else if time_str.ends_with("pm") {
(&time_str[..time_str.len() - 2], true)
} else {
(time_str, false)
};
// Parse hour:minute or just hour
let (hour, minute) = if time_part.contains(':') {
let parts: Vec<&str> = time_part.split(':').collect();
if parts.len() != 2 {
return None;
}
let h: u32 = parts[0].parse().ok()?;
let m: u32 = parts[1].parse().ok()?;
(h, m)
} else {
let h: u32 = time_part.parse().ok()?;
(h, 0)
};
// Validate
if minute > 59 {
return None;
}
// Convert to 24-hour if needed
let hour = if is_pm && hour < 12 {
hour + 12
} else if !is_pm && hour == 12 && time_str.ends_with("am") {
0
} else {
hour
};
if hour > 23 {
return None;
}
Some((hour, minute))
}
fn parse_day_pattern(input: &str) -> Option<String> {
let dow = get_day_of_week(input)?;
// Check for "_at_TIME" suffix
if let Some(at_pos) = input.find("_at_") {
let time_str = &input[at_pos + 4..];
return parse_time_to_cron(time_str, "0", &dow.to_string());
}
// Just the day, default to midnight
Some(format!("0 0 * * {}", dow))
}
fn get_day_of_week(input: &str) -> Option<String> {
let input_lower = input.to_lowercase();
// Handle "every_DAYNAME" patterns
let day_part = input_lower.strip_prefix("every_").unwrap_or(&input_lower);
// Remove any "_at_..." suffix for day matching
let day_part = if let Some(at_pos) = day_part.find("_at_") {
&day_part[..at_pos]
} else {
day_part
};
match day_part {
"sunday" | "sun" => Some("0".to_string()),
"monday" | "mon" => Some("1".to_string()),
"tuesday" | "tue" | "tues" => Some("2".to_string()),
"wednesday" | "wed" => Some("3".to_string()),
"thursday" | "thu" | "thurs" => Some("4".to_string()),
"friday" | "fri" => Some("5".to_string()),
"saturday" | "sat" => Some("6".to_string()),
"weekday" | "weekdays" => Some("1-5".to_string()),
"weekend" | "weekends" => Some("0,6".to_string()),
_ => None,
}
}
fn parse_combined_pattern(input: &str) -> Option<String> {
// every_day_at_TIME
if input.starts_with("every_day_at_") {
let time_str = &input[13..];
return parse_time_to_cron(time_str, "0", "*");
}
// every_weekday_at_TIME
if input.starts_with("every_weekday_at_") || input.starts_with("weekdays_at_") {
let time_str = if input.starts_with("every_weekday_at_") {
&input[17..]
} else {
&input[12..]
};
return parse_time_to_cron(time_str, "0", "1-5");
}
// every_weekend_at_TIME / weekends_at_TIME
if input.starts_with("every_weekend_at_") || input.starts_with("weekends_at_") {
let time_str = if input.starts_with("every_weekend_at_") {
&input[17..]
} else {
&input[12..]
};
return parse_time_to_cron(time_str, "0", "0,6");
}
// every_hour_from_X_to_Y (e.g., "every_hour_from_9_to_17")
if input.starts_with("every_hour_from_") {
let rest = &input[16..];
if let Some(to_pos) = rest.find("_to_") {
let start: u32 = rest[..to_pos].parse().ok()?;
let end: u32 = rest[to_pos + 4..].parse().ok()?;
if start <= 23 && end <= 23 {
return Some(format!("0 {}-{} * * *", start, end));
}
}
}
None
}
fn parse_business_hours(input: &str) -> Option<String> {
// business_hours or during_business_hours
if input.contains("business_hours") || input.contains("business hours") {
// Default business hours: 9-17, weekdays
// Check for interval prefix
if input.starts_with("every_") {
// every_N_minutes_during_business_hours
if let Some(rest) = input.strip_prefix("every_") {
if let Some(minutes_pos) = rest.find("_minutes") {
let num_str = &rest[..minutes_pos];
if let Ok(n) = num_str.parse::<u32>() {
if n > 0 && n <= 59 {
return Some(format!("*/{} 9-17 * * 1-5", n));
}
}
}
// every_hour_during_business_hours
if rest.starts_with("hour") {
return Some("0 9-17 * * 1-5".to_string());
}
}
}
// Just "business hours" or "during business hours"
return Some("0 9-17 * * 1-5".to_string());
}
None
}
pub fn execute_set_schedule(
conn: &mut diesel::PgConnection,
cron: &str,
cron_or_natural: &str,
script_name: &str,
bot_uuid: Uuid,
) -> Result<Value, Box<dyn std::error::Error>> {
// Parse natural language to cron if needed
let cron = parse_natural_schedule(cron_or_natural)?;
trace!(
"Scheduling SET SCHEDULE cron: {}, script: {}, bot_id: {:?}",
"Scheduling SET SCHEDULE cron: {} (from: '{}'), script: {}, bot_id: {:?}",
cron,
cron_or_natural,
script_name,
bot_uuid
);
use crate::shared::models::bots::dsl::bots;
let bot_exists: bool = diesel::select(diesel::dsl::exists(
bots.filter(crate::shared::models::bots::dsl::id.eq(bot_uuid)),
))
.get_result(conn)?;
if !bot_exists {
return Err(format!("Bot with id {} does not exist", bot_uuid).into());
}
use crate::shared::models::system_automations::dsl::*;
let new_automation = (
bot_id.eq(bot_uuid),
kind.eq(TriggerKind::Scheduled as i32),
schedule.eq(cron),
schedule.eq(&cron),
param.eq(script_name),
is_active.eq(true),
);
let update_result = diesel::update(system_automations)
.filter(bot_id.eq(bot_uuid))
.filter(kind.eq(TriggerKind::Scheduled as i32))
.filter(param.eq(script_name))
.set((
schedule.eq(cron),
schedule.eq(&cron),
is_active.eq(true),
last_triggered.eq(None::<chrono::DateTime<chrono::Utc>>),
))
.execute(&mut *conn)?;
let result = if update_result == 0 {
diesel::insert_into(system_automations)
.values(&new_automation)
@ -48,11 +411,181 @@ pub fn execute_set_schedule(
} else {
update_result
};
Ok(json!({
"command": "set_schedule",
"schedule": cron,
"script": script_name,
"bot_id": bot_uuid.to_string(),
"rows_affected": result
"command": "set_schedule",
"schedule": cron,
"original_input": cron_or_natural,
"script": script_name,
"bot_id": bot_uuid.to_string(),
"rows_affected": result
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_every_minute() {
assert_eq!(parse_natural_schedule("every minute").unwrap(), "* * * * *");
}
#[test]
fn test_every_n_minutes() {
assert_eq!(
parse_natural_schedule("every 5 minutes").unwrap(),
"*/5 * * * *"
);
assert_eq!(
parse_natural_schedule("every 15 minutes").unwrap(),
"*/15 * * * *"
);
assert_eq!(
parse_natural_schedule("every 30 minutes").unwrap(),
"*/30 * * * *"
);
}
#[test]
fn test_every_hour() {
assert_eq!(parse_natural_schedule("every hour").unwrap(), "0 * * * *");
assert_eq!(parse_natural_schedule("hourly").unwrap(), "0 * * * *");
}
#[test]
fn test_every_n_hours() {
assert_eq!(
parse_natural_schedule("every 2 hours").unwrap(),
"0 */2 * * *"
);
assert_eq!(
parse_natural_schedule("every 6 hours").unwrap(),
"0 */6 * * *"
);
}
#[test]
fn test_every_day() {
assert_eq!(parse_natural_schedule("every day").unwrap(), "0 0 * * *");
assert_eq!(parse_natural_schedule("daily").unwrap(), "0 0 * * *");
}
#[test]
fn test_every_week() {
assert_eq!(parse_natural_schedule("every week").unwrap(), "0 0 * * 0");
assert_eq!(parse_natural_schedule("weekly").unwrap(), "0 0 * * 0");
}
#[test]
fn test_every_month() {
assert_eq!(parse_natural_schedule("every month").unwrap(), "0 0 1 * *");
assert_eq!(parse_natural_schedule("monthly").unwrap(), "0 0 1 * *");
}
#[test]
fn test_at_time() {
assert_eq!(parse_natural_schedule("at 9am").unwrap(), "0 9 * * *");
assert_eq!(parse_natural_schedule("at 9:30am").unwrap(), "30 9 * * *");
assert_eq!(parse_natural_schedule("at 2pm").unwrap(), "0 14 * * *");
assert_eq!(parse_natural_schedule("at 14:00").unwrap(), "0 14 * * *");
assert_eq!(parse_natural_schedule("at midnight").unwrap(), "0 0 * * *");
assert_eq!(parse_natural_schedule("at noon").unwrap(), "0 12 * * *");
}
#[test]
fn test_day_of_week() {
assert_eq!(parse_natural_schedule("every monday").unwrap(), "0 0 * * 1");
assert_eq!(parse_natural_schedule("every friday").unwrap(), "0 0 * * 5");
assert_eq!(parse_natural_schedule("every sunday").unwrap(), "0 0 * * 0");
}
#[test]
fn test_day_with_time() {
assert_eq!(
parse_natural_schedule("every monday at 9am").unwrap(),
"0 9 * * 1"
);
assert_eq!(
parse_natural_schedule("every friday at 5pm").unwrap(),
"0 17 * * 5"
);
}
#[test]
fn test_weekdays() {
assert_eq!(parse_natural_schedule("weekdays").unwrap(), "0 0 * * 1-5");
assert_eq!(
parse_natural_schedule("every weekday").unwrap(),
"0 0 * * 1-5"
);
assert_eq!(
parse_natural_schedule("weekdays at 8am").unwrap(),
"0 8 * * 1-5"
);
}
#[test]
fn test_weekends() {
assert_eq!(parse_natural_schedule("weekends").unwrap(), "0 0 * * 0,6");
assert_eq!(
parse_natural_schedule("every weekend").unwrap(),
"0 0 * * 0,6"
);
}
#[test]
fn test_combined() {
assert_eq!(
parse_natural_schedule("every day at 9am").unwrap(),
"0 9 * * *"
);
assert_eq!(
parse_natural_schedule("every day at 6:30pm").unwrap(),
"30 18 * * *"
);
}
#[test]
fn test_hour_range() {
assert_eq!(
parse_natural_schedule("every hour from 9 to 17").unwrap(),
"0 9-17 * * *"
);
}
#[test]
fn test_business_hours() {
assert_eq!(
parse_natural_schedule("business hours").unwrap(),
"0 9-17 * * 1-5"
);
assert_eq!(
parse_natural_schedule("every 30 minutes during business hours").unwrap(),
"*/30 9-17 * * 1-5"
);
assert_eq!(
parse_natural_schedule("every hour during business hours").unwrap(),
"0 9-17 * * 1-5"
);
}
#[test]
fn test_raw_cron_passthrough() {
assert_eq!(parse_natural_schedule("0 * * * *").unwrap(), "0 * * * *");
assert_eq!(
parse_natural_schedule("*/5 * * * *").unwrap(),
"*/5 * * * *"
);
assert_eq!(
parse_natural_schedule("0 9-17 * * 1-5").unwrap(),
"0 9-17 * * 1-5"
);
}
#[test]
fn test_invalid_input() {
assert!(parse_natural_schedule("potato salad").is_err());
assert!(parse_natural_schedule("every 100 minutes").is_err()); // > 59
}
}

View file

@ -22,6 +22,7 @@ mod editor;
pub mod file_tree;
mod log_panel;
mod status_panel;
pub mod wizard;
use chat_panel::ChatPanel;
use editor::Editor;
use file_tree::{FileTree, TreeNode};

968
src/console/wizard.rs Normal file
View file

@ -0,0 +1,968 @@
//! Startup Wizard Module
//!
//! Interactive wizard for first-run configuration or --wizard flag.
//! Guides users through:
//! - LLM provider selection
//! - Component installation choices
//! - Admin user setup
//! - Organization configuration
//! - Bot template selection
use crate::core::shared::branding::platform_name;
use crate::core::shared::version::BOTSERVER_VERSION;
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEvent},
execute,
style::{Color, Print, ResetColor, SetForegroundColor, Stylize},
terminal::{self, ClearType},
};
use serde::{Deserialize, Serialize};
use std::io::{self, Write};
use std::path::PathBuf;
/// Wizard configuration result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WizardConfig {
/// Selected LLM provider
pub llm_provider: LlmProvider,
/// LLM API key (if applicable)
pub llm_api_key: Option<String>,
/// Local model path (if using local LLM)
pub local_model_path: Option<String>,
/// Components to install
pub components: Vec<ComponentChoice>,
/// Admin user configuration
pub admin: AdminConfig,
/// Organization configuration
pub organization: OrgConfig,
/// Selected bot template
pub template: Option<String>,
/// Installation mode
pub install_mode: InstallMode,
/// Data directory
pub data_dir: PathBuf,
}
/// LLM Provider options
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LlmProvider {
Claude,
OpenAI,
Gemini,
Local,
None,
}
impl std::fmt::Display for LlmProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LlmProvider::Claude => write!(f, "Claude (Anthropic) - Best for complex reasoning"),
LlmProvider::OpenAI => write!(f, "GPT-4 (OpenAI) - General purpose"),
LlmProvider::Gemini => write!(f, "Gemini (Google) - Google integration"),
LlmProvider::Local => write!(f, "Local (Llama/Mistral) - Privacy focused"),
LlmProvider::None => write!(f, "None - Configure later"),
}
}
}
/// Component installation choices
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ComponentChoice {
Drive, // MinIO storage
Email, // Email server
Meet, // Video meetings (LiveKit)
Tables, // PostgreSQL
Cache, // Redis
VectorDb, // pgvector
Proxy, // Caddy reverse proxy
Directory, // LDAP/SSO
BotModels, // AI models server
}
impl std::fmt::Display for ComponentChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ComponentChoice::Drive => write!(f, "Drive (MinIO) - File storage"),
ComponentChoice::Email => write!(f, "Email Server - Send/receive emails"),
ComponentChoice::Meet => write!(f, "Meet (LiveKit) - Video meetings"),
ComponentChoice::Tables => write!(f, "Database (PostgreSQL) - Required"),
ComponentChoice::Cache => write!(f, "Cache (Redis) - Sessions & queues"),
ComponentChoice::VectorDb => write!(f, "Vector DB - AI embeddings"),
ComponentChoice::Proxy => write!(f, "Proxy (Caddy) - HTTPS & routing"),
ComponentChoice::Directory => write!(f, "Directory - Users & SSO"),
ComponentChoice::BotModels => write!(f, "BotModels - Local AI models"),
}
}
}
/// Admin user configuration
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AdminConfig {
pub username: String,
pub email: String,
pub password: String,
pub display_name: String,
}
/// Organization configuration
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OrgConfig {
pub name: String,
pub slug: String,
pub domain: Option<String>,
}
/// Installation mode
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum InstallMode {
Development,
Production,
Container,
}
impl Default for WizardConfig {
fn default() -> Self {
Self {
llm_provider: LlmProvider::None,
llm_api_key: None,
local_model_path: None,
components: vec![
ComponentChoice::Tables,
ComponentChoice::Cache,
ComponentChoice::Drive,
],
admin: AdminConfig::default(),
organization: OrgConfig::default(),
template: None,
install_mode: InstallMode::Development,
data_dir: PathBuf::from("./botserver-stack"),
}
}
}
/// Startup Wizard
pub struct StartupWizard {
config: WizardConfig,
current_step: usize,
total_steps: usize,
}
impl StartupWizard {
pub fn new() -> Self {
Self {
config: WizardConfig::default(),
current_step: 0,
total_steps: 7,
}
}
/// Run the interactive wizard
pub fn run(&mut self) -> io::Result<WizardConfig> {
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
// Clear screen and show welcome
execute!(
stdout,
terminal::Clear(ClearType::All),
cursor::MoveTo(0, 0)
)?;
self.show_welcome(&mut stdout)?;
self.wait_for_enter()?;
// Step 1: Installation Mode
self.current_step = 1;
self.step_install_mode(&mut stdout)?;
// Step 2: LLM Provider
self.current_step = 2;
self.step_llm_provider(&mut stdout)?;
// Step 3: Components
self.current_step = 3;
self.step_components(&mut stdout)?;
// Step 4: Organization
self.current_step = 4;
self.step_organization(&mut stdout)?;
// Step 5: Admin User
self.current_step = 5;
self.step_admin_user(&mut stdout)?;
// Step 6: Template Selection
self.current_step = 6;
self.step_template(&mut stdout)?;
// Step 7: Summary & Confirm
self.current_step = 7;
self.step_summary(&mut stdout)?;
terminal::disable_raw_mode()?;
Ok(self.config.clone())
}
fn show_welcome(&self, stdout: &mut io::Stdout) -> io::Result<()> {
execute!(
stdout,
terminal::Clear(ClearType::All),
cursor::MoveTo(0, 0)
)?;
let banner = r#"
"#;
execute!(
stdout,
SetForegroundColor(Color::Green),
Print(banner),
ResetColor
)?;
execute!(
stdout,
cursor::MoveTo(20, 18),
SetForegroundColor(Color::Cyan),
Print(format!(
"Welcome to {} Setup Wizard v{}",
platform_name(),
BOTSERVER_VERSION
)),
ResetColor
)?;
execute!(
stdout,
cursor::MoveTo(20, 20),
Print("This wizard will help you configure your bot server."),
cursor::MoveTo(20, 21),
Print("You can re-run this wizard anytime with: "),
SetForegroundColor(Color::Yellow),
Print("botserver --wizard"),
ResetColor
)?;
execute!(
stdout,
cursor::MoveTo(20, 24),
SetForegroundColor(Color::DarkGrey),
Print("Press ENTER to continue..."),
ResetColor
)?;
stdout.flush()?;
Ok(())
}
fn show_step_header(&self, stdout: &mut io::Stdout, title: &str) -> io::Result<()> {
execute!(
stdout,
terminal::Clear(ClearType::All),
cursor::MoveTo(0, 0)
)?;
// Progress bar
let progress = format!("Step {}/{}: {}", self.current_step, self.total_steps, title);
let bar_width = 50;
let filled = (self.current_step * bar_width) / self.total_steps;
execute!(
stdout,
SetForegroundColor(Color::Cyan),
Print(""),
Print("".repeat(bar_width + 2)),
Print("\n"),
Print(""),
SetForegroundColor(Color::Green),
Print("".repeat(filled)),
SetForegroundColor(Color::DarkGrey),
Print("".repeat(bar_width - filled)),
SetForegroundColor(Color::Cyan),
Print("\n"),
Print(""),
Print("".repeat(bar_width + 2)),
Print(""),
ResetColor
)?;
execute!(
stdout,
cursor::MoveTo(0, 4),
SetForegroundColor(Color::White),
Print(format!(" {}\n", progress)),
ResetColor,
Print("\n")
)?;
stdout.flush()?;
Ok(())
}
fn step_install_mode(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
self.show_step_header(stdout, "Installation Mode")?;
let options = vec![
(
"Development",
"Local development with hot reload",
InstallMode::Development,
),
(
"Production",
"Optimized for production servers",
InstallMode::Production,
),
(
"Container",
"Docker/LXC container deployment",
InstallMode::Container,
),
];
let selected = self.select_option(stdout, &options, 0)?;
self.config.install_mode = options[selected].2.clone();
Ok(())
}
fn step_llm_provider(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
self.show_step_header(stdout, "AI/LLM Provider")?;
execute!(
stdout,
cursor::MoveTo(2, 7),
Print("Select your preferred AI provider:"),
cursor::MoveTo(2, 8),
SetForegroundColor(Color::DarkGrey),
Print("(You can use multiple providers later)"),
ResetColor
)?;
let options = vec![
(
"Claude (Anthropic)",
"Best reasoning, 200K context - Recommended",
LlmProvider::Claude,
),
(
"GPT-4 (OpenAI)",
"Widely compatible, good all-around",
LlmProvider::OpenAI,
),
(
"Gemini (Google)",
"Great for Google Workspace integration",
LlmProvider::Gemini,
),
(
"Local Models",
"Llama, Mistral - Full privacy, no API costs",
LlmProvider::Local,
),
(
"Skip for now",
"Configure AI providers later",
LlmProvider::None,
),
];
let selected = self.select_option(stdout, &options, 0)?;
self.config.llm_provider = options[selected].2.clone();
// Ask for API key if needed
if self.config.llm_provider != LlmProvider::Local
&& self.config.llm_provider != LlmProvider::None
{
terminal::disable_raw_mode()?;
execute!(
stdout,
cursor::MoveTo(2, 20),
Print("Enter API key (or press Enter to skip): ")
)?;
stdout.flush()?;
let mut api_key = String::new();
io::stdin().read_line(&mut api_key)?;
let api_key = api_key.trim().to_string();
if !api_key.is_empty() {
self.config.llm_api_key = Some(api_key);
}
terminal::enable_raw_mode()?;
}
if self.config.llm_provider == LlmProvider::Local {
terminal::disable_raw_mode()?;
execute!(
stdout,
cursor::MoveTo(2, 20),
Print("Enter model path (default: ./models/llama-3.1-8b): ")
)?;
stdout.flush()?;
let mut model_path = String::new();
io::stdin().read_line(&mut model_path)?;
let model_path = model_path.trim().to_string();
self.config.local_model_path = Some(if model_path.is_empty() {
"./models/llama-3.1-8b".to_string()
} else {
model_path
});
terminal::enable_raw_mode()?;
}
Ok(())
}
fn step_components(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
self.show_step_header(stdout, "Components to Install")?;
execute!(
stdout,
cursor::MoveTo(2, 7),
Print("Select components to install (Space to toggle, Enter to confirm):"),
cursor::MoveTo(2, 8),
SetForegroundColor(Color::DarkGrey),
Print("PostgreSQL and Redis are required and pre-selected"),
ResetColor
)?;
let components = vec![
(ComponentChoice::Tables, true, false), // required, can't toggle
(ComponentChoice::Cache, true, false), // required, can't toggle
(ComponentChoice::Drive, true, true), // default on
(ComponentChoice::VectorDb, true, true), // default on
(ComponentChoice::Email, false, true), // default off
(ComponentChoice::Meet, false, true), // default off
(ComponentChoice::Proxy, true, true), // default on
(ComponentChoice::Directory, false, true), // default off
(ComponentChoice::BotModels, false, true), // default off
];
let selected = self.multi_select(stdout, &components)?;
self.config.components = selected;
Ok(())
}
fn step_organization(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
self.show_step_header(stdout, "Organization Setup")?;
terminal::disable_raw_mode()?;
execute!(stdout, cursor::MoveTo(2, 7), Print("Organization name: "))?;
stdout.flush()?;
let mut org_name = String::new();
io::stdin().read_line(&mut org_name)?;
self.config.organization.name = org_name.trim().to_string();
// Generate slug from name
self.config.organization.slug = self
.config
.organization
.name
.to_lowercase()
.replace(' ', "-")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect();
execute!(
stdout,
cursor::MoveTo(2, 9),
Print(format!("Slug ({}): ", self.config.organization.slug))
)?;
stdout.flush()?;
let mut slug = String::new();
io::stdin().read_line(&mut slug)?;
let slug = slug.trim();
if !slug.is_empty() {
self.config.organization.slug = slug.to_string();
}
execute!(
stdout,
cursor::MoveTo(2, 11),
Print("Domain (optional, e.g., example.com): ")
)?;
stdout.flush()?;
let mut domain = String::new();
io::stdin().read_line(&mut domain)?;
let domain = domain.trim();
if !domain.is_empty() {
self.config.organization.domain = Some(domain.to_string());
}
terminal::enable_raw_mode()?;
Ok(())
}
fn step_admin_user(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
self.show_step_header(stdout, "Admin User")?;
terminal::disable_raw_mode()?;
execute!(stdout, cursor::MoveTo(2, 7), Print("Admin username: "))?;
stdout.flush()?;
let mut username = String::new();
io::stdin().read_line(&mut username)?;
self.config.admin.username = username.trim().to_string();
execute!(stdout, cursor::MoveTo(2, 9), Print("Admin email: "))?;
stdout.flush()?;
let mut email = String::new();
io::stdin().read_line(&mut email)?;
self.config.admin.email = email.trim().to_string();
execute!(stdout, cursor::MoveTo(2, 11), Print("Admin display name: "))?;
stdout.flush()?;
let mut display_name = String::new();
io::stdin().read_line(&mut display_name)?;
self.config.admin.display_name = display_name.trim().to_string();
execute!(stdout, cursor::MoveTo(2, 13), Print("Admin password: "))?;
stdout.flush()?;
// Read password (in production, use rpassword for hidden input)
let mut password = String::new();
io::stdin().read_line(&mut password)?;
self.config.admin.password = password.trim().to_string();
terminal::enable_raw_mode()?;
Ok(())
}
fn step_template(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
self.show_step_header(stdout, "Bot Template")?;
execute!(
stdout,
cursor::MoveTo(2, 7),
Print("Select a template for your first bot:"),
)?;
let options = vec![
("default", "Basic bot with weather, email, and tools"),
("crm", "Customer relationship management"),
("edu", "Educational/course management"),
("store", "E-commerce bot"),
("hr", "Human resources assistant"),
("healthcare", "Healthcare appointment scheduling"),
("none", "Start from scratch"),
];
let templates: Vec<(&str, &str, Option<String>)> = options
.iter()
.map(|(name, desc)| {
(
*name,
*desc,
if *name == "none" {
None
} else {
Some(name.to_string())
},
)
})
.collect();
let selected = self.select_option(stdout, &templates, 0)?;
self.config.template = templates[selected].2.clone();
Ok(())
}
fn step_summary(&mut self, stdout: &mut io::Stdout) -> io::Result<()> {
self.show_step_header(stdout, "Configuration Summary")?;
let mode = match self.config.install_mode {
InstallMode::Development => "Development",
InstallMode::Production => "Production",
InstallMode::Container => "Container",
};
let llm = match &self.config.llm_provider {
LlmProvider::Claude => "Claude (Anthropic)",
LlmProvider::OpenAI => "GPT-4 (OpenAI)",
LlmProvider::Gemini => "Gemini (Google)",
LlmProvider::Local => "Local Models",
LlmProvider::None => "Not configured",
};
execute!(
stdout,
cursor::MoveTo(2, 7),
SetForegroundColor(Color::Cyan),
Print("═══════════════════════════════════════════════════"),
ResetColor,
cursor::MoveTo(2, 9),
Print(format!(" Installation Mode: {}", mode)),
cursor::MoveTo(2, 10),
Print(format!(" LLM Provider: {}", llm)),
cursor::MoveTo(2, 11),
Print(format!(
" Organization: {}",
self.config.organization.name
)),
cursor::MoveTo(2, 12),
Print(format!(
" Admin User: {}",
self.config.admin.username
)),
cursor::MoveTo(2, 13),
Print(format!(
" Template: {}",
self.config.template.as_deref().unwrap_or("None")
)),
cursor::MoveTo(2, 14),
Print(format!(
" Components: {}",
self.config.components.len()
)),
cursor::MoveTo(2, 16),
SetForegroundColor(Color::Cyan),
Print("═══════════════════════════════════════════════════"),
ResetColor,
cursor::MoveTo(2, 18),
Print("Components to install:"),
)?;
for (i, component) in self.config.components.iter().enumerate() {
execute!(
stdout,
cursor::MoveTo(4, 19 + i as u16),
SetForegroundColor(Color::Green),
Print(""),
ResetColor,
Print(format!("{}", component))
)?;
}
let last_line = 19 + self.config.components.len() as u16 + 2;
execute!(
stdout,
cursor::MoveTo(2, last_line),
SetForegroundColor(Color::Yellow),
Print("Press ENTER to apply configuration, or ESC to cancel"),
ResetColor
)?;
stdout.flush()?;
loop {
if let Event::Key(KeyEvent { code, .. }) = event::read()? {
match code {
KeyCode::Enter => break,
KeyCode::Esc => {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"Wizard cancelled",
));
}
_ => {}
}
}
}
Ok(())
}
fn select_option<T: Clone>(
&self,
stdout: &mut io::Stdout,
options: &[(&str, &str, T)],
default: usize,
) -> io::Result<usize> {
let mut selected = default;
let start_row = 10;
loop {
for (i, (name, desc, _)) in options.iter().enumerate() {
execute!(stdout, cursor::MoveTo(4, start_row + i as u16))?;
if i == selected {
execute!(
stdout,
SetForegroundColor(Color::Green),
Print(""),
Print(format!("{:<25}", name)),
SetForegroundColor(Color::DarkGrey),
Print(format!(" {}", desc)),
ResetColor
)?;
} else {
execute!(
stdout,
Print(" "),
Print(format!("{:<25}", name)),
SetForegroundColor(Color::DarkGrey),
Print(format!(" {}", desc)),
ResetColor
)?;
}
}
stdout.flush()?;
if let Event::Key(KeyEvent { code, .. }) = event::read()? {
match code {
KeyCode::Up => {
if selected > 0 {
selected -= 1;
}
}
KeyCode::Down => {
if selected < options.len() - 1 {
selected += 1;
}
}
KeyCode::Enter => break,
KeyCode::Esc => {
return Err(io::Error::new(io::ErrorKind::Interrupted, "Cancelled"));
}
_ => {}
}
}
}
Ok(selected)
}
fn multi_select(
&self,
stdout: &mut io::Stdout,
options: &[(ComponentChoice, bool, bool)], // (component, selected, can_toggle)
) -> io::Result<Vec<ComponentChoice>> {
let mut selected: Vec<bool> = options.iter().map(|(_, s, _)| *s).collect();
let mut cursor = 0;
let start_row = 10;
loop {
for (i, (component, _, can_toggle)) in options.iter().enumerate() {
execute!(stdout, cursor::MoveTo(4, start_row + i as u16))?;
let checkbox = if selected[i] { "[✓]" } else { "[ ]" };
let prefix = if i == cursor { "" } else { " " };
if !can_toggle {
execute!(
stdout,
SetForegroundColor(Color::DarkGrey),
Print(format!("{} {} {} (required)", prefix, checkbox, component)),
ResetColor
)?;
} else if i == cursor {
execute!(
stdout,
SetForegroundColor(Color::Green),
Print(format!("{} {} {}", prefix, checkbox, component)),
ResetColor
)?;
} else {
execute!(
stdout,
Print(format!("{} {} {}", prefix, checkbox, component)),
)?;
}
}
execute!(
stdout,
cursor::MoveTo(4, start_row + options.len() as u16 + 2),
SetForegroundColor(Color::DarkGrey),
Print("Use ↑↓ to navigate, SPACE to toggle, ENTER to confirm"),
ResetColor
)?;
stdout.flush()?;
if let Event::Key(KeyEvent { code, .. }) = event::read()? {
match code {
KeyCode::Up => {
if cursor > 0 {
cursor -= 1;
}
}
KeyCode::Down => {
if cursor < options.len() - 1 {
cursor += 1;
}
}
KeyCode::Char(' ') => {
if options[cursor].2 {
// can_toggle
selected[cursor] = !selected[cursor];
}
}
KeyCode::Enter => break,
KeyCode::Esc => {
return Err(io::Error::new(io::ErrorKind::Interrupted, "Cancelled"));
}
_ => {}
}
}
}
Ok(options
.iter()
.enumerate()
.filter(|(i, _)| selected[*i])
.map(|(_, (c, _, _))| c.clone())
.collect())
}
fn wait_for_enter(&self) -> io::Result<()> {
loop {
if let Event::Key(KeyEvent { code, .. }) = event::read()? {
if code == KeyCode::Enter {
break;
}
}
}
Ok(())
}
}
/// Save wizard configuration to file
pub fn save_wizard_config(config: &WizardConfig, path: &str) -> io::Result<()> {
let content = toml::to_string_pretty(config)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
std::fs::write(path, content)?;
Ok(())
}
/// Load wizard configuration from file
pub fn load_wizard_config(path: &str) -> io::Result<WizardConfig> {
let content = std::fs::read_to_string(path)?;
let config: WizardConfig =
toml::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(config)
}
/// Check if wizard should run (no botserver-stack exists)
pub fn should_run_wizard() -> bool {
!std::path::Path::new("./botserver-stack").exists()
&& !std::path::Path::new("/opt/gbo").exists()
}
/// Apply wizard configuration - create directories, config files, etc.
pub fn apply_wizard_config(config: &WizardConfig) -> io::Result<()> {
use std::fs;
// Create data directory
fs::create_dir_all(&config.data_dir)?;
// Create subdirectories
let subdirs = ["bots", "logs", "cache", "uploads", "config"];
for subdir in &subdirs {
fs::create_dir_all(config.data_dir.join(subdir))?;
}
// Save configuration
save_wizard_config(
config,
&config.data_dir.join("config/wizard.toml").to_string_lossy(),
)?;
// Create .env file
let mut env_content = String::new();
env_content.push_str(&format!(
"# Generated by {} Setup Wizard\n\n",
platform_name()
));
env_content.push_str(&format!("INSTALL_MODE={:?}\n", config.install_mode));
env_content.push_str(&format!("ORG_NAME={}\n", config.organization.name));
env_content.push_str(&format!("ORG_SLUG={}\n", config.organization.slug));
if let Some(domain) = &config.organization.domain {
env_content.push_str(&format!("DOMAIN={}\n", domain));
}
match &config.llm_provider {
LlmProvider::Claude => env_content.push_str("LLM_PROVIDER=anthropic\n"),
LlmProvider::OpenAI => env_content.push_str("LLM_PROVIDER=openai\n"),
LlmProvider::Gemini => env_content.push_str("LLM_PROVIDER=google\n"),
LlmProvider::Local => env_content.push_str("LLM_PROVIDER=local\n"),
LlmProvider::None => {}
}
if let Some(api_key) = &config.llm_api_key {
env_content.push_str(&format!("LLM_API_KEY={}\n", api_key));
}
if let Some(model_path) = &config.local_model_path {
env_content.push_str(&format!("LOCAL_MODEL_PATH={}\n", model_path));
}
fs::write(config.data_dir.join(".env"), env_content)?;
println!("\n✅ Configuration applied successfully!");
println!(" Data directory: {}", config.data_dir.display());
println!("\n Next steps:");
println!(" 1. Run: botserver start");
println!(" 2. Open: http://localhost:4242");
println!(" 3. Login with: {}", config.admin.username);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = WizardConfig::default();
assert_eq!(config.llm_provider, LlmProvider::None);
assert!(!config.components.is_empty());
}
#[test]
fn test_slug_generation() {
let mut config = WizardConfig::default();
config.organization.name = "My Test Company".to_string();
config.organization.slug = config
.organization
.name
.to_lowercase()
.replace(' ', "-")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect();
assert_eq!(config.organization.slug, "my-test-company");
}
}

974
src/core/bot/manager.rs Normal file
View file

@ -0,0 +1,974 @@
//! Bot Manager Module
//!
//! Manages bot lifecycle including:
//! - Creating new bots from templates
//! - MinIO bucket creation (folder = bucket)
//! - Security/access assignment
//! - Custom UI routing (/botname/gbui)
use crate::core::shared::branding::platform_name;
use chrono::{DateTime, Utc};
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
/// Bot configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BotConfig {
/// Unique bot ID
pub id: Uuid,
/// Bot name (used in URLs: /botname)
pub name: String,
/// Display name
pub display_name: String,
/// Organization ID
pub org_id: Uuid,
/// Organization slug
pub org_slug: String,
/// Template used to create this bot
pub template: Option<String>,
/// Bot status
pub status: BotStatus,
/// MinIO bucket name
pub bucket: String,
/// Custom UI path (optional)
pub custom_ui: Option<String>,
/// Bot settings
pub settings: BotSettings,
/// Access control
pub access: BotAccess,
/// Creation timestamp
pub created_at: DateTime<Utc>,
/// Last updated timestamp
pub updated_at: DateTime<Utc>,
/// Created by user ID
pub created_by: Uuid,
}
/// Bot status
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum BotStatus {
Active,
Inactive,
Maintenance,
Creating,
Error,
}
impl std::fmt::Display for BotStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BotStatus::Active => write!(f, "Active"),
BotStatus::Inactive => write!(f, "Inactive"),
BotStatus::Maintenance => write!(f, "Maintenance"),
BotStatus::Creating => write!(f, "Creating"),
BotStatus::Error => write!(f, "Error"),
}
}
}
/// Bot settings
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BotSettings {
/// Default LLM model
pub llm_model: Option<String>,
/// Knowledge bases enabled
pub knowledge_bases: Vec<String>,
/// Enabled channels
pub channels: Vec<String>,
/// Webhook endpoints
pub webhooks: Vec<String>,
/// Schedule definitions
pub schedules: Vec<String>,
/// Custom variables
pub variables: HashMap<String, String>,
}
/// Bot access control
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BotAccess {
/// Admin users (full access)
pub admins: Vec<Uuid>,
/// Editor users (can edit scripts)
pub editors: Vec<Uuid>,
/// Viewer users (read-only)
pub viewers: Vec<Uuid>,
/// Public access enabled
pub is_public: bool,
/// Allowed domains (for embedding)
pub allowed_domains: Vec<String>,
/// API key for external access
pub api_key: Option<String>,
}
/// Available bot templates
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BotTemplate {
pub name: String,
pub display_name: String,
pub description: String,
pub category: String,
pub files: Vec<TemplateFile>,
pub preview_image: Option<String>,
}
/// Template file
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateFile {
pub path: String,
pub content: String,
}
/// Bot creation request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateBotRequest {
/// Bot name (will be used in URLs)
pub name: String,
/// Display name
pub display_name: Option<String>,
/// Organization ID
pub org_id: Uuid,
/// Template to use (optional)
pub template: Option<String>,
/// Creator user ID
pub created_by: Uuid,
/// Initial settings
pub settings: Option<BotSettings>,
/// Custom UI name (optional)
pub custom_ui: Option<String>,
}
/// Bot Manager
pub struct BotManager {
/// MinIO client for bucket operations
minio_endpoint: String,
minio_access_key: String,
minio_secret_key: String,
/// Database connection string
database_url: String,
/// Templates directory
templates_dir: PathBuf,
/// Cached bots
bots_cache: Arc<RwLock<HashMap<Uuid, BotConfig>>>,
/// Available templates
templates: Arc<RwLock<HashMap<String, BotTemplate>>>,
}
impl BotManager {
/// Create a new BotManager
pub fn new(
minio_endpoint: &str,
minio_access_key: &str,
minio_secret_key: &str,
database_url: &str,
templates_dir: PathBuf,
) -> Self {
Self {
minio_endpoint: minio_endpoint.to_string(),
minio_access_key: minio_access_key.to_string(),
minio_secret_key: minio_secret_key.to_string(),
database_url: database_url.to_string(),
templates_dir,
bots_cache: Arc::new(RwLock::new(HashMap::new())),
templates: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Initialize manager and load templates
pub async fn init(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Initializing Bot Manager...");
// Load available templates
self.load_templates().await?;
info!("Bot Manager initialized");
Ok(())
}
/// Load templates from templates directory
async fn load_templates(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut templates = self.templates.write().await;
// Built-in templates
let builtin_templates = vec![
BotTemplate {
name: "default".to_string(),
display_name: "Default Bot".to_string(),
description: "Basic bot with weather, email, and calculation tools".to_string(),
category: "General".to_string(),
files: vec![
TemplateFile {
path: "default.gbdialog/start.bas".to_string(),
content: r#"REM Default start script
SET user_name = "Guest"
TALK "Hello, " + user_name + "! How can I help you today?"
HEAR user_input
response = LLM "Respond helpfully to: " + user_input
TALK response
"#
.to_string(),
},
TemplateFile {
path: "default.gbot/config.json".to_string(),
content: r#"{
"name": "{{botname}}",
"description": "Default bot created from template",
"version": "1.0.0"
}"#
.to_string(),
},
],
preview_image: None,
},
BotTemplate {
name: "crm".to_string(),
display_name: "CRM Bot".to_string(),
description: "Customer relationship management with lead scoring".to_string(),
category: "Business".to_string(),
files: vec![TemplateFile {
path: "crm.gbdialog/lead.bas".to_string(),
content: r#"REM Lead capture script
PARAM name AS string LIKE "John Doe"
PARAM email AS string LIKE "john@example.com"
PARAM company AS string LIKE "Acme Inc"
DESCRIPTION "Capture and score leads"
TALK "Welcome! Let me help you get started."
TALK "What's your name?"
HEAR name
TALK "And your email?"
HEAR email
TALK "What company are you from?"
HEAR company
score = AI SCORE LEAD email, company, "interested in our product"
INSERT "leads", name, email, company, score, NOW()
IF score > 80 THEN
CREATE TASK "Hot lead: " + name, "sales", "today"
TALK "Great! Our sales team will reach out shortly."
ELSE
TALK "Thanks for your interest! We'll send you some resources."
SEND MAIL email, "Welcome!", "Thanks for reaching out..."
END IF
"#
.to_string(),
}],
preview_image: None,
},
BotTemplate {
name: "edu".to_string(),
display_name: "Education Bot".to_string(),
description: "Course management and student enrollment".to_string(),
category: "Education".to_string(),
files: vec![TemplateFile {
path: "edu.gbdialog/enroll.bas".to_string(),
content: r#"REM Student enrollment script
PARAM student_name AS string LIKE "Jane Student"
PARAM course AS string LIKE "Introduction to AI"
DESCRIPTION "Enroll students in courses"
TALK "Welcome to our enrollment system!"
TALK "What's your full name?"
HEAR student_name
TALK "Which course would you like to enroll in?"
courses = FIND "courses", "status='open'"
FOR EACH course IN courses
TALK "- " + course.name
NEXT
HEAR selected_course
INSERT "enrollments", student_name, selected_course, NOW()
TALK "You're enrolled in " + selected_course + "!"
SEND MAIL student_email, "Enrollment Confirmed", "Welcome to " + selected_course
"#
.to_string(),
}],
preview_image: None,
},
BotTemplate {
name: "store".to_string(),
display_name: "E-commerce Bot".to_string(),
description: "Product catalog and order management".to_string(),
category: "Business".to_string(),
files: vec![TemplateFile {
path: "store.gbdialog/order.bas".to_string(),
content: r#"REM Order management script
DESCRIPTION "Help customers with orders"
TALK "Welcome to our store! How can I help?"
ADD SUGGESTION "Track my order"
ADD SUGGESTION "Browse products"
ADD SUGGESTION "Contact support"
HEAR choice
SWITCH choice
CASE "Track my order"
TALK "Please enter your order number:"
HEAR order_id
order = FIND "orders", "id=" + order_id
TALK "Order status: " + order.status
CASE "Browse products"
products = FIND "products", "in_stock=true"
TALK "Here are our available products:"
FOR EACH product IN products
TALK product.name + " - $" + product.price
NEXT
DEFAULT
ticket = CREATE TASK choice, "support", "normal"
TALK "Support ticket created: #" + ticket
END SWITCH
"#
.to_string(),
}],
preview_image: None,
},
BotTemplate {
name: "hr".to_string(),
display_name: "HR Assistant".to_string(),
description: "Human resources and employee management".to_string(),
category: "Business".to_string(),
files: vec![TemplateFile {
path: "hr.gbdialog/leave.bas".to_string(),
content: r#"REM Leave request script
DESCRIPTION "Handle employee leave requests"
TALK "HR Assistant here. How can I help?"
ADD SUGGESTION "Request leave"
ADD SUGGESTION "Check balance"
ADD SUGGESTION "View policies"
HEAR request
IF request = "Request leave" THEN
TALK "What type of leave? (vacation/sick/personal)"
HEAR leave_type
TALK "Start date? (YYYY-MM-DD)"
HEAR start_date
TALK "End date? (YYYY-MM-DD)"
HEAR end_date
INSERT "leave_requests", user_id, leave_type, start_date, end_date, "pending"
manager = FIND "employees", "id=" + user.manager_id
TALK TO manager.email, "Leave request from " + user.name
TALK "Leave request submitted! Your manager will review it."
ELSE IF request = "Check balance" THEN
balance = FIND "leave_balances", "user_id=" + user_id
TALK "Your leave balance:"
TALK "Vacation: " + balance.vacation + " days"
TALK "Sick: " + balance.sick + " days"
END IF
"#
.to_string(),
}],
preview_image: None,
},
BotTemplate {
name: "healthcare".to_string(),
display_name: "Healthcare Bot".to_string(),
description: "Appointment scheduling and patient management".to_string(),
category: "Healthcare".to_string(),
files: vec![TemplateFile {
path: "healthcare.gbdialog/appointment.bas".to_string(),
content: r#"REM Appointment scheduling
DESCRIPTION "Schedule healthcare appointments"
TALK "Welcome to our healthcare center. How can I help?"
ADD SUGGESTION "Book appointment"
ADD SUGGESTION "Cancel appointment"
ADD SUGGESTION "View my appointments"
HEAR choice
IF choice = "Book appointment" THEN
TALK "What type of appointment? (general/specialist/lab)"
HEAR apt_type
TALK "Preferred date? (YYYY-MM-DD)"
HEAR pref_date
available = FIND "slots", "date=" + pref_date + " AND type=" + apt_type
TALK "Available times:"
FOR EACH slot IN available
TALK slot.time + " - Dr. " + slot.doctor
NEXT
TALK "Which time would you prefer?"
HEAR selected_time
BOOK apt_type + " appointment", selected_time, user.email
TALK "Appointment booked! You'll receive a confirmation email."
END IF
"#
.to_string(),
}],
preview_image: None,
},
];
for template in builtin_templates {
templates.insert(template.name.clone(), template);
}
// Load templates from filesystem
if self.templates_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&self.templates_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && path.extension().map_or(false, |e| e == "gbai") {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
if !templates.contains_key(name) {
debug!("Found template directory: {}", name);
// Load template from directory
// TODO: Implement full template loading from filesystem
}
}
}
}
}
}
info!("Loaded {} templates", templates.len());
Ok(())
}
/// Create a new bot
pub async fn create_bot(
&self,
request: CreateBotRequest,
) -> Result<BotConfig, Box<dyn std::error::Error + Send + Sync>> {
info!("Creating bot: {} for org: {}", request.name, request.org_id);
// Validate bot name
let bot_name = self.sanitize_bot_name(&request.name);
if bot_name.is_empty() {
return Err("Invalid bot name".into());
}
// Get org slug (would come from database in production)
let org_slug = "default"; // TODO: Look up from database
// Generate bucket name: org_botname
let bucket_name = format!("{}_{}", org_slug, bot_name);
// Create MinIO bucket
self.create_minio_bucket(&bucket_name).await?;
// Create bot configuration
let bot_id = Uuid::new_v4();
let now = Utc::now();
let bot_config = BotConfig {
id: bot_id,
name: bot_name.clone(),
display_name: request.display_name.unwrap_or_else(|| bot_name.clone()),
org_id: request.org_id,
org_slug: org_slug.to_string(),
template: request.template.clone(),
status: BotStatus::Creating,
bucket: bucket_name.clone(),
custom_ui: request.custom_ui,
settings: request.settings.unwrap_or_default(),
access: BotAccess {
admins: vec![request.created_by],
..Default::default()
},
created_at: now,
updated_at: now,
created_by: request.created_by,
};
// Apply template if specified
if let Some(template_name) = &request.template {
self.apply_template(&bucket_name, template_name, &bot_name)
.await?;
} else {
// Create default directory structure
self.create_default_structure(&bucket_name, &bot_name)
.await?;
}
// Cache the bot
{
let mut cache = self.bots_cache.write().await;
cache.insert(bot_id, bot_config.clone());
}
// Update status to active
let mut bot_config = bot_config;
bot_config.status = BotStatus::Active;
info!("Bot created successfully: {} ({})", bot_name, bot_id);
Ok(bot_config)
}
/// Sanitize bot name for use in URLs and buckets
fn sanitize_bot_name(&self, name: &str) -> String {
name.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect::<String>()
.trim_matches(|c| c == '-' || c == '_')
.to_string()
}
/// Create MinIO bucket for bot
async fn create_minio_bucket(
&self,
bucket_name: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Creating MinIO bucket: {}", bucket_name);
// Use mc command to create bucket
// In production, use the AWS S3 SDK or minio-rs
let output = tokio::process::Command::new("mc")
.args(["mb", &format!("local/{}", bucket_name), "--ignore-existing"])
.output()
.await;
match output {
Ok(result) => {
if result.status.success() {
info!("Bucket created: {}", bucket_name);
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
if !stderr.contains("already exists") {
warn!("Bucket creation warning: {}", stderr);
}
}
}
Err(e) => {
error!("Failed to create bucket: {}", e);
// Don't fail - bucket might be created via other means
}
}
// Set bucket policy (optional - make specific paths public if needed)
// mc admin policy attach local/ readwrite --user botuser
Ok(())
}
/// Create MinIO user for bot admin access
pub async fn create_bot_user(
&self,
username: &str,
password: &str,
bucket: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Creating MinIO user: {} for bucket: {}", username, bucket);
// Create user
// mc admin user add local/ username password
let _ = tokio::process::Command::new("mc")
.args(["admin", "user", "add", "local/", username, password])
.output()
.await;
// Create policy for bucket access
let policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
format!("arn:aws:s3:::{}", bucket),
format!("arn:aws:s3:::{}/*", bucket)
]
}
]
});
// Write policy to temp file
let policy_path = format!("/tmp/policy_{}.json", bucket);
std::fs::write(&policy_path, policy.to_string())?;
// Create and attach policy
// mc admin policy create local/ policyname policy.json
let policy_name = format!("policy_{}", bucket);
let _ = tokio::process::Command::new("mc")
.args([
"admin",
"policy",
"create",
"local/",
&policy_name,
&policy_path,
])
.output()
.await;
// Attach policy to user
// mc admin policy attach local/ policyname --user username
let _ = tokio::process::Command::new("mc")
.args([
"admin",
"policy",
"attach",
"local/",
&policy_name,
"--user",
username,
])
.output()
.await;
// Clean up temp file
let _ = std::fs::remove_file(&policy_path);
info!("User created with bucket access: {}", username);
Ok(())
}
/// Apply template to bot bucket
async fn apply_template(
&self,
bucket: &str,
template_name: &str,
bot_name: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!(
"Applying template '{}' to bucket '{}'",
template_name, bucket
);
let templates = self.templates.read().await;
let template = templates
.get(template_name)
.ok_or_else(|| format!("Template not found: {}", template_name))?;
for file in &template.files {
// Replace template variables
let content = file
.content
.replace("{{botname}}", bot_name)
.replace("{{platform}}", platform_name());
// Upload file to MinIO
self.upload_file(bucket, &file.path, content.as_bytes())
.await?;
}
info!(
"Applied template '{}' ({} files)",
template_name,
template.files.len()
);
Ok(())
}
/// Create default directory structure for bot
async fn create_default_structure(
&self,
bucket: &str,
bot_name: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Creating default structure in bucket: {}", bucket);
// Create directory markers (empty files with trailing /)
let dirs = [
format!("{}.gbdialog/", bot_name),
format!("{}.gbkb/", bot_name),
format!("{}.gbot/", bot_name),
format!("{}.gbtheme/", bot_name),
"uploads/".to_string(),
"exports/".to_string(),
"cache/".to_string(),
];
for dir in &dirs {
self.upload_file(bucket, dir, b"").await?;
}
// Create default config
let config = serde_json::json!({
"name": bot_name,
"version": "1.0.0",
"created_at": Utc::now().to_rfc3339(),
"platform": platform_name()
});
self.upload_file(
bucket,
&format!("{}.gbot/config.json", bot_name),
config.to_string().as_bytes(),
)
.await?;
// Create default start script
let start_script = format!(
r#"REM {} - Start Script
TALK "Hello! I'm {}. How can I help you?"
HEAR user_input
response = LLM "Respond helpfully to: " + user_input
TALK response
"#,
bot_name, bot_name
);
self.upload_file(
bucket,
&format!("{}.gbdialog/start.bas", bot_name),
start_script.as_bytes(),
)
.await?;
info!("Default structure created");
Ok(())
}
/// Upload file to MinIO bucket
async fn upload_file(
&self,
bucket: &str,
path: &str,
content: &[u8],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
debug!("Uploading to {}/{}", bucket, path);
// Write to temp file
let temp_path = format!("/tmp/upload_{}", Uuid::new_v4());
std::fs::write(&temp_path, content)?;
// Use mc to upload
let result = tokio::process::Command::new("mc")
.args(["cp", &temp_path, &format!("local/{}/{}", bucket, path)])
.output()
.await;
// Clean up
let _ = std::fs::remove_file(&temp_path);
match result {
Ok(output) => {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Upload warning: {}", stderr);
}
}
Err(e) => {
warn!("Upload failed (mc not available): {}", e);
// Fallback: write directly to filesystem if mc not available
let fs_path = format!("./botserver-stack/minio/{}/{}", bucket, path);
if let Some(parent) = std::path::Path::new(&fs_path).parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&fs_path, content)?;
}
}
Ok(())
}
/// Get available templates
pub async fn get_templates(&self) -> Vec<BotTemplate> {
let templates = self.templates.read().await;
templates.values().cloned().collect()
}
/// Get bot by ID
pub async fn get_bot(&self, bot_id: Uuid) -> Option<BotConfig> {
let cache = self.bots_cache.read().await;
cache.get(&bot_id).cloned()
}
/// Get bot by name and org
pub async fn get_bot_by_name(&self, org_slug: &str, bot_name: &str) -> Option<BotConfig> {
let cache = self.bots_cache.read().await;
cache
.values()
.find(|b| b.org_slug == org_slug && b.name == bot_name)
.cloned()
}
/// List bots for organization
pub async fn list_bots(&self, org_id: Uuid) -> Vec<BotConfig> {
let cache = self.bots_cache.read().await;
cache
.values()
.filter(|b| b.org_id == org_id)
.cloned()
.collect()
}
/// Delete bot
pub async fn delete_bot(
&self,
bot_id: Uuid,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let bot = self.get_bot(bot_id).await.ok_or("Bot not found")?;
info!("Deleting bot: {} ({})", bot.name, bot_id);
// Delete bucket contents
let _ = tokio::process::Command::new("mc")
.args([
"rm",
"--recursive",
"--force",
&format!("local/{}", bot.bucket),
])
.output()
.await;
// Delete bucket
let _ = tokio::process::Command::new("mc")
.args(["rb", &format!("local/{}", bot.bucket)])
.output()
.await;
// Remove from cache
{
let mut cache = self.bots_cache.write().await;
cache.remove(&bot_id);
}
info!("Bot deleted: {}", bot_id);
Ok(())
}
/// Get URL for bot
pub fn get_bot_url(&self, bot: &BotConfig, base_url: &str) -> String {
format!("{}/{}", base_url.trim_end_matches('/'), bot.name)
}
/// Get custom UI URL for bot
pub fn get_custom_ui_url(&self, bot: &BotConfig, base_url: &str) -> Option<String> {
bot.custom_ui.as_ref().map(|ui| {
format!(
"{}/{}/gbui/{}",
base_url.trim_end_matches('/'),
bot.name,
ui
)
})
}
}
/// Bot routing configuration for web server
#[derive(Debug, Clone)]
pub struct BotRoute {
/// Bot name (used in URL path)
pub name: String,
/// Organization slug
pub org_slug: String,
/// Full bucket path
pub bucket: String,
/// Custom UI path (if any)
pub custom_ui: Option<String>,
}
impl From<&BotConfig> for BotRoute {
fn from(bot: &BotConfig) -> Self {
BotRoute {
name: bot.name.clone(),
org_slug: bot.org_slug.clone(),
bucket: bot.bucket.clone(),
custom_ui: bot.custom_ui.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_bot_name() {
let manager = BotManager::new("", "", "", "", PathBuf::new());
assert_eq!(manager.sanitize_bot_name("My Bot"), "mybot");
assert_eq!(manager.sanitize_bot_name("test-bot"), "test-bot");
assert_eq!(manager.sanitize_bot_name("Bot 123"), "bot123");
assert_eq!(manager.sanitize_bot_name("--invalid--"), "invalid");
assert_eq!(manager.sanitize_bot_name("my_bot_name"), "my_bot_name");
}
#[test]
fn test_bot_config_default() {
let settings = BotSettings::default();
assert!(settings.knowledge_bases.is_empty());
assert!(settings.channels.is_empty());
}
#[test]
fn test_bot_status_display() {
assert_eq!(format!("{}", BotStatus::Active), "Active");
assert_eq!(format!("{}", BotStatus::Creating), "Creating");
}
#[test]
fn test_bot_route_from_config() {
let config = BotConfig {
id: Uuid::new_v4(),
name: "testbot".to_string(),
display_name: "Test Bot".to_string(),
org_id: Uuid::new_v4(),
org_slug: "myorg".to_string(),
template: None,
status: BotStatus::Active,
bucket: "myorg_testbot".to_string(),
custom_ui: Some("custom".to_string()),
settings: BotSettings::default(),
access: BotAccess::default(),
created_at: Utc::now(),
updated_at: Utc::now(),
created_by: Uuid::new_v4(),
};
let route = BotRoute::from(&config);
assert_eq!(route.name, "testbot");
assert_eq!(route.org_slug, "myorg");
assert_eq!(route.bucket, "myorg_testbot");
assert_eq!(route.custom_ui, Some("custom".to_string()));
}
}

395
src/core/shared/branding.rs Normal file
View file

@ -0,0 +1,395 @@
//! White-Label Branding Module
//!
//! Allows complete customization of platform identity.
//! When a .product file exists with name=MyCustomPlatform,
//! "General Bots" never appears in logs, display, messages, footer - nothing.
//! Only "MyCustomPlatform" and custom components.
use log::info;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::OnceLock;
/// Global branding configuration - loaded once at startup
static BRANDING: OnceLock<BrandingConfig> = OnceLock::new();
/// Default platform name
const DEFAULT_PLATFORM_NAME: &str = "General Bots";
const DEFAULT_PLATFORM_SHORT: &str = "GB";
const DEFAULT_PLATFORM_DOMAIN: &str = "generalbots.com";
/// Branding configuration loaded from .product file
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrandingConfig {
/// Platform name (e.g., "MyCustomPlatform")
pub name: String,
/// Short name for logs and compact displays (e.g., "MCP")
pub short_name: String,
/// Company/organization name
pub company: Option<String>,
/// Domain for URLs and emails
pub domain: Option<String>,
/// Support email
pub support_email: Option<String>,
/// Logo URL (for web UI)
pub logo_url: Option<String>,
/// Favicon URL
pub favicon_url: Option<String>,
/// Primary color (hex)
pub primary_color: Option<String>,
/// Secondary color (hex)
pub secondary_color: Option<String>,
/// Footer text
pub footer_text: Option<String>,
/// Copyright text
pub copyright: Option<String>,
/// Custom CSS URL
pub custom_css: Option<String>,
/// Terms of service URL
pub terms_url: Option<String>,
/// Privacy policy URL
pub privacy_url: Option<String>,
/// Documentation URL
pub docs_url: Option<String>,
/// Whether this is a white-label deployment
pub is_white_label: bool,
}
impl Default for BrandingConfig {
fn default() -> Self {
Self {
name: DEFAULT_PLATFORM_NAME.to_string(),
short_name: DEFAULT_PLATFORM_SHORT.to_string(),
company: Some("pragmatismo.com.br".to_string()),
domain: Some(DEFAULT_PLATFORM_DOMAIN.to_string()),
support_email: Some("support@generalbots.com".to_string()),
logo_url: None,
favicon_url: None,
primary_color: Some("#25d366".to_string()), // WhatsApp green
secondary_color: Some("#075e54".to_string()),
footer_text: None,
copyright: Some(format!(
"© {} pragmatismo.com.br. All rights reserved.",
chrono::Utc::now().format("%Y")
)),
custom_css: None,
terms_url: None,
privacy_url: None,
docs_url: Some("https://docs.generalbots.com".to_string()),
is_white_label: false,
}
}
}
impl BrandingConfig {
/// Load branding from .product file if it exists
pub fn load() -> Self {
// Check multiple locations for .product file
let search_paths = [
".product",
"config/.product",
"/etc/botserver/.product",
"/opt/gbo/.product",
];
for path in &search_paths {
if let Ok(config) = Self::load_from_file(path) {
info!(
"Loaded white-label branding from {}: {}",
path, config.name
);
return config;
}
}
// Also check environment variable
if let Ok(product_file) = std::env::var("PRODUCT_FILE") {
if let Ok(config) = Self::load_from_file(&product_file) {
info!(
"Loaded white-label branding from PRODUCT_FILE={}: {}",
product_file, config.name
);
return config;
}
}
// Check for individual environment overrides
let mut config = Self::default();
if let Ok(name) = std::env::var("PLATFORM_NAME") {
config.name = name;
config.is_white_label = true;
}
if let Ok(short) = std::env::var("PLATFORM_SHORT_NAME") {
config.short_name = short;
}
if let Ok(company) = std::env::var("PLATFORM_COMPANY") {
config.company = Some(company);
}
if let Ok(domain) = std::env::var("PLATFORM_DOMAIN") {
config.domain = Some(domain);
}
if let Ok(logo) = std::env::var("PLATFORM_LOGO_URL") {
config.logo_url = Some(logo);
}
if let Ok(color) = std::env::var("PLATFORM_PRIMARY_COLOR") {
config.primary_color = Some(color);
}
config
}
/// Load from a specific file path
fn load_from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let path = Path::new(path);
if !path.exists() {
return Err("File not found".into());
}
let content = std::fs::read_to_string(path)?;
// Try parsing as TOML first
if let Ok(config) = toml::from_str::<ProductFile>(&content) {
return Ok(config.into());
}
// Try parsing as simple key=value format
let mut config = Self::default();
config.is_white_label = true;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim().to_lowercase();
let value = value.trim().trim_matches('"').trim_matches('\'');
match key.as_str() {
"name" | "platform_name" => config.name = value.to_string(),
"short_name" | "short" => config.short_name = value.to_string(),
"company" | "organization" => config.company = Some(value.to_string()),
"domain" => config.domain = Some(value.to_string()),
"support_email" | "email" => config.support_email = Some(value.to_string()),
"logo_url" | "logo" => config.logo_url = Some(value.to_string()),
"favicon_url" | "favicon" => config.favicon_url = Some(value.to_string()),
"primary_color" | "color" => config.primary_color = Some(value.to_string()),
"secondary_color" => config.secondary_color = Some(value.to_string()),
"footer_text" | "footer" => config.footer_text = Some(value.to_string()),
"copyright" => config.copyright = Some(value.to_string()),
"custom_css" | "css" => config.custom_css = Some(value.to_string()),
"terms_url" | "terms" => config.terms_url = Some(value.to_string()),
"privacy_url" | "privacy" => config.privacy_url = Some(value.to_string()),
"docs_url" | "docs" => config.docs_url = Some(value.to_string()),
_ => {}
}
}
}
Ok(config)
}
}
/// TOML format for .product file
#[derive(Debug, Deserialize)]
struct ProductFile {
name: String,
#[serde(default)]
short_name: Option<String>,
#[serde(default)]
company: Option<String>,
#[serde(default)]
domain: Option<String>,
#[serde(default)]
support_email: Option<String>,
#[serde(default)]
logo_url: Option<String>,
#[serde(default)]
favicon_url: Option<String>,
#[serde(default)]
primary_color: Option<String>,
#[serde(default)]
secondary_color: Option<String>,
#[serde(default)]
footer_text: Option<String>,
#[serde(default)]
copyright: Option<String>,
#[serde(default)]
custom_css: Option<String>,
#[serde(default)]
terms_url: Option<String>,
#[serde(default)]
privacy_url: Option<String>,
#[serde(default)]
docs_url: Option<String>,
}
impl From<ProductFile> for BrandingConfig {
fn from(pf: ProductFile) -> Self {
let short_name = pf.short_name.unwrap_or_else(|| {
// Generate short name from first letters
pf.name
.split_whitespace()
.map(|w| w.chars().next().unwrap_or('X'))
.collect::<String>()
.to_uppercase()
});
Self {
name: pf.name,
short_name,
company: pf.company,
domain: pf.domain,
support_email: pf.support_email,
logo_url: pf.logo_url,
favicon_url: pf.favicon_url,
primary_color: pf.primary_color,
secondary_color: pf.secondary_color,
footer_text: pf.footer_text,
copyright: pf.copyright,
custom_css: pf.custom_css,
terms_url: pf.terms_url,
privacy_url: pf.privacy_url,
docs_url: pf.docs_url,
is_white_label: true,
}
}
}
// ============================================================================
// Global Access Functions
// ============================================================================
/// Initialize branding at application startup
pub fn init_branding() {
let config = BrandingConfig::load();
let _ = BRANDING.set(config);
}
/// Get the current branding configuration
pub fn branding() -> &'static BrandingConfig {
BRANDING.get_or_init(BrandingConfig::load)
}
/// Get the platform name (use this instead of hardcoding "General Bots")
pub fn platform_name() -> &'static str {
&branding().name
}
/// Get the short platform name (for logs, compact displays)
pub fn platform_short() -> &'static str {
&branding().short_name
}
/// Check if this is a white-label deployment
pub fn is_white_label() -> bool {
branding().is_white_label
}
/// Get formatted copyright text
pub fn copyright_text() -> String {
branding().copyright.clone().unwrap_or_else(|| {
format!(
"© {} {}",
chrono::Utc::now().format("%Y"),
branding().company.as_deref().unwrap_or(&branding().name)
)
})
}
/// Get footer text
pub fn footer_text() -> String {
branding().footer_text.clone().unwrap_or_else(|| {
format!("Powered by {}", platform_name())
})
}
/// Format a log prefix with platform branding
pub fn log_prefix() -> String {
format!("[{}]", platform_short())
}
// ============================================================================
// Macros for Branded Logging
// ============================================================================
/// Log with platform branding
#[macro_export]
macro_rules! branded_info {
($($arg:tt)*) => {
log::info!("{} {}", $crate::core::shared::branding::log_prefix(), format!($($arg)*))
};
}
#[macro_export]
macro_rules! branded_warn {
($($arg:tt)*) => {
log::warn!("{} {}", $crate::core::shared::branding::log_prefix(), format!($($arg)*))
};
}
#[macro_export]
macro_rules! branded_error {
($($arg:tt)*) => {
log::error!("{} {}", $crate::core::shared::branding::log_prefix(), format!($($arg)*))
};
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_branding() {
let config = BrandingConfig::default();
assert_eq!(config.name, "General Bots");
assert_eq!(config.short_name, "GB");
assert!(!config.is_white_label);
}
#[test]
fn test_parse_simple_product_file() {
let content = r#"
name=MyCustomPlatform
short_name=MCP
company=My Company Inc.
domain=myplatform.com
primary_color=#ff6600
"#;
// Test would require file system access, skipping actual load
assert!(content.contains("MyCustomPlatform"));
}
#[test]
fn test_platform_name_function() {
// This test uses the default since no .product file exists
let name = platform_name();
assert!(!name.is_empty());
}
}

View file

@ -1,6 +1,15 @@
pub mod admin;
pub mod analytics;
pub mod branding;
pub mod message_types;
pub mod models;
pub mod state;
pub mod utils;
pub mod version;
// Re-export commonly used items
pub use branding::{branding, init_branding, is_white_label, platform_name, platform_short};
pub use version::{
get_botserver_version, init_version_registry, register_component, version_string,
ComponentStatus, ComponentVersion, VersionRegistry, BOTSERVER_VERSION,
};

514
src/core/shared/version.rs Normal file
View file

@ -0,0 +1,514 @@
//! Version Tracking Module
//!
//! Tracks versions of all components and checks for updates.
//! Displays in Monitor tab of Suite and UITree (Console).
use chrono::{DateTime, Utc};
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::RwLock;
/// Global version registry
static VERSION_REGISTRY: RwLock<Option<VersionRegistry>> = RwLock::new(None);
/// Current botserver version from Cargo.toml
pub const BOTSERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const BOTSERVER_NAME: &str = env!("CARGO_PKG_NAME");
/// Component version information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentVersion {
/// Component name (e.g., "drive", "llm", "email")
pub name: String,
/// Current installed version
pub version: String,
/// Latest available version (if known)
pub latest_version: Option<String>,
/// Whether an update is available
pub update_available: bool,
/// Component status
pub status: ComponentStatus,
/// Last check time
pub last_checked: Option<DateTime<Utc>>,
/// Source/origin of the component
pub source: ComponentSource,
/// Additional metadata
pub metadata: HashMap<String, String>,
}
/// Component status
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum ComponentStatus {
Running,
Stopped,
Error,
Updating,
NotInstalled,
Unknown,
}
impl std::fmt::Display for ComponentStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ComponentStatus::Running => write!(f, "✅ Running"),
ComponentStatus::Stopped => write!(f, "⏹️ Stopped"),
ComponentStatus::Error => write!(f, "❌ Error"),
ComponentStatus::Updating => write!(f, "🔄 Updating"),
ComponentStatus::NotInstalled => write!(f, "⚪ Not Installed"),
ComponentStatus::Unknown => write!(f, "❓ Unknown"),
}
}
}
/// Component source type
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ComponentSource {
/// Built into botserver
Builtin,
/// Docker container
Docker,
/// LXC container
Lxc,
/// System package (apt, yum, etc.)
System,
/// Downloaded binary
Binary,
/// External service
External,
}
impl std::fmt::Display for ComponentSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ComponentSource::Builtin => write!(f, "Built-in"),
ComponentSource::Docker => write!(f, "Docker"),
ComponentSource::Lxc => write!(f, "LXC"),
ComponentSource::System => write!(f, "System"),
ComponentSource::Binary => write!(f, "Binary"),
ComponentSource::External => write!(f, "External"),
}
}
}
/// Version registry holding all component versions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionRegistry {
/// Botserver core version
pub core_version: String,
/// All registered components
pub components: HashMap<String, ComponentVersion>,
/// Last global update check
pub last_update_check: Option<DateTime<Utc>>,
/// Update check URL
pub update_url: Option<String>,
}
impl Default for VersionRegistry {
fn default() -> Self {
Self {
core_version: BOTSERVER_VERSION.to_string(),
components: HashMap::new(),
last_update_check: None,
update_url: Some("https://api.generalbots.com/updates".to_string()),
}
}
}
impl VersionRegistry {
/// Create a new version registry
pub fn new() -> Self {
let mut registry = Self::default();
registry.register_builtin_components();
registry
}
/// Register built-in components
fn register_builtin_components(&mut self) {
// Core botserver
self.register_component(ComponentVersion {
name: "botserver".to_string(),
version: BOTSERVER_VERSION.to_string(),
latest_version: None,
update_available: false,
status: ComponentStatus::Running,
last_checked: Some(Utc::now()),
source: ComponentSource::Builtin,
metadata: HashMap::from([
("description".to_string(), "Core bot server".to_string()),
(
"repo".to_string(),
"https://github.com/GeneralBots/botserver".to_string(),
),
]),
});
// BASIC interpreter
self.register_component(ComponentVersion {
name: "basic".to_string(),
version: BOTSERVER_VERSION.to_string(),
latest_version: None,
update_available: false,
status: ComponentStatus::Running,
last_checked: Some(Utc::now()),
source: ComponentSource::Builtin,
metadata: HashMap::from([(
"description".to_string(),
"BASIC script interpreter".to_string(),
)]),
});
// LLM module
self.register_component(ComponentVersion {
name: "llm".to_string(),
version: BOTSERVER_VERSION.to_string(),
latest_version: None,
update_available: false,
status: ComponentStatus::Running,
last_checked: Some(Utc::now()),
source: ComponentSource::Builtin,
metadata: HashMap::from([(
"description".to_string(),
"LLM integration (Claude, GPT, etc.)".to_string(),
)]),
});
}
/// Register a component
pub fn register_component(&mut self, component: ComponentVersion) {
debug!(
"Registered component: {} v{}",
component.name, component.version
);
self.components.insert(component.name.clone(), component);
}
/// Update component status
pub fn update_status(&mut self, name: &str, status: ComponentStatus) {
if let Some(component) = self.components.get_mut(name) {
component.status = status;
}
}
/// Update component version
pub fn update_version(&mut self, name: &str, version: &str) {
if let Some(component) = self.components.get_mut(name) {
component.version = version.to_string();
component.last_checked = Some(Utc::now());
}
}
/// Check for updates for all components
pub async fn check_updates(
&mut self,
) -> Result<Vec<UpdateInfo>, Box<dyn std::error::Error + Send + Sync>> {
info!("Checking for component updates...");
self.last_update_check = Some(Utc::now());
let mut updates = Vec::new();
// Check GitHub releases for botserver
if let Ok(latest) = Self::check_github_release("GeneralBots", "botserver").await {
if let Some(component) = self.components.get_mut("botserver") {
component.latest_version = Some(latest.clone());
component.update_available = Self::is_newer_version(&component.version, &latest);
component.last_checked = Some(Utc::now());
if component.update_available {
updates.push(UpdateInfo {
component: "botserver".to_string(),
current_version: component.version.clone(),
new_version: latest,
release_notes: None,
});
}
}
}
// Check botmodels
if let Ok(latest) = Self::check_github_release("GeneralBots", "botmodels").await {
if let Some(component) = self.components.get_mut("botmodels") {
component.latest_version = Some(latest.clone());
component.update_available = Self::is_newer_version(&component.version, &latest);
component.last_checked = Some(Utc::now());
if component.update_available {
updates.push(UpdateInfo {
component: "botmodels".to_string(),
current_version: component.version.clone(),
new_version: latest,
release_notes: None,
});
}
}
}
if updates.is_empty() {
info!("All components are up to date");
} else {
info!("{} update(s) available", updates.len());
}
Ok(updates)
}
/// Check GitHub releases for latest version
async fn check_github_release(
owner: &str,
repo: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let url = format!(
"https://api.github.com/repos/{}/{}/releases/latest",
owner, repo
);
let client = reqwest::Client::new();
let response = client
.get(&url)
.header("User-Agent", format!("botserver/{}", BOTSERVER_VERSION))
.send()
.await?;
if !response.status().is_success() {
return Err(format!("GitHub API error: {}", response.status()).into());
}
let release: GitHubRelease = response.json().await?;
Ok(release.tag_name.trim_start_matches('v').to_string())
}
/// Compare versions (semver-like)
fn is_newer_version(current: &str, latest: &str) -> bool {
let parse_version = |v: &str| -> Vec<u32> {
v.trim_start_matches('v')
.split('.')
.filter_map(|s| s.parse().ok())
.collect()
};
let current_parts = parse_version(current);
let latest_parts = parse_version(latest);
for i in 0..3 {
let c = current_parts.get(i).copied().unwrap_or(0);
let l = latest_parts.get(i).copied().unwrap_or(0);
if l > c {
return true;
} else if c > l {
return false;
}
}
false
}
/// Get all components with updates available
pub fn get_available_updates(&self) -> Vec<&ComponentVersion> {
self.components
.values()
.filter(|c| c.update_available)
.collect()
}
/// Get component by name
pub fn get_component(&self, name: &str) -> Option<&ComponentVersion> {
self.components.get(name)
}
/// Get all components
pub fn get_all_components(&self) -> Vec<&ComponentVersion> {
self.components.values().collect()
}
/// Generate version summary for display
pub fn summary(&self) -> String {
let mut lines = vec![
format!("═══════════════════════════════════════════════"),
format!(" Component Versions"),
format!("═══════════════════════════════════════════════"),
];
let mut components: Vec<_> = self.components.values().collect();
components.sort_by(|a, b| a.name.cmp(&b.name));
for component in components {
let update_indicator = if component.update_available {
format!(" ⬆️ {}", component.latest_version.as_deref().unwrap_or("?"))
} else {
String::new()
};
lines.push(format!(
" {:15} v{:10} {}{}",
component.name, component.version, component.status, update_indicator
));
}
if let Some(last_check) = self.last_update_check {
lines.push(format!("───────────────────────────────────────────────"));
lines.push(format!(
" Last checked: {}",
last_check.format("%Y-%m-%d %H:%M UTC")
));
}
lines.push(format!("═══════════════════════════════════════════════"));
lines.join("\n")
}
/// Generate JSON version info for API
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"core_version": self.core_version,
"components": self.components,
"last_update_check": self.last_update_check,
"updates_available": self.get_available_updates().len()
})
}
}
/// GitHub release response
#[derive(Debug, Deserialize)]
struct GitHubRelease {
tag_name: String,
#[allow(dead_code)]
name: Option<String>,
#[allow(dead_code)]
body: Option<String>,
}
/// Update information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateInfo {
pub component: String,
pub current_version: String,
pub new_version: String,
pub release_notes: Option<String>,
}
// ============================================================================
// Global Functions
// ============================================================================
/// Initialize the global version registry
pub fn init_version_registry() {
let mut guard = VERSION_REGISTRY.write().unwrap();
if guard.is_none() {
*guard = Some(VersionRegistry::new());
info!(
"Version registry initialized - botserver v{}",
BOTSERVER_VERSION
);
}
}
/// Get the global version registry
pub fn version_registry() -> std::sync::RwLockReadGuard<'static, Option<VersionRegistry>> {
VERSION_REGISTRY.read().unwrap()
}
/// Get mutable access to the global version registry
pub fn version_registry_mut() -> std::sync::RwLockWriteGuard<'static, Option<VersionRegistry>> {
VERSION_REGISTRY.write().unwrap()
}
/// Register a new component in the global registry
pub fn register_component(component: ComponentVersion) {
if let Some(ref mut registry) = *version_registry_mut() {
registry.register_component(component);
} else {
warn!(
"Version registry not initialized when registering component: {}",
component.name
);
}
}
/// Update component status in the global registry
pub fn update_component_status(name: &str, status: ComponentStatus) {
if let Some(ref mut registry) = *version_registry_mut() {
registry.update_status(name, status);
}
}
/// Get version of a specific component
pub fn get_component_version(name: &str) -> Option<String> {
version_registry()
.as_ref()
.and_then(|r| r.get_component(name))
.map(|c| c.version.clone())
}
/// Get botserver version
pub fn get_botserver_version() -> &'static str {
BOTSERVER_VERSION
}
/// Generate version string for display
pub fn version_string() -> String {
format!("{} v{}", BOTSERVER_NAME, BOTSERVER_VERSION)
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_comparison() {
assert!(VersionRegistry::is_newer_version("1.0.0", "1.0.1"));
assert!(VersionRegistry::is_newer_version("1.0.0", "1.1.0"));
assert!(VersionRegistry::is_newer_version("1.0.0", "2.0.0"));
assert!(!VersionRegistry::is_newer_version("1.0.1", "1.0.0"));
assert!(!VersionRegistry::is_newer_version("1.0.0", "1.0.0"));
assert!(VersionRegistry::is_newer_version("v1.0.0", "v1.0.1"));
}
#[test]
fn test_registry_creation() {
let registry = VersionRegistry::new();
assert!(!registry.components.is_empty());
assert!(registry.get_component("botserver").is_some());
}
#[test]
fn test_component_registration() {
let mut registry = VersionRegistry::new();
registry.register_component(ComponentVersion {
name: "test-component".to_string(),
version: "1.0.0".to_string(),
latest_version: None,
update_available: false,
status: ComponentStatus::Running,
last_checked: None,
source: ComponentSource::External,
metadata: HashMap::new(),
});
assert!(registry.get_component("test-component").is_some());
}
#[test]
fn test_status_display() {
assert_eq!(format!("{}", ComponentStatus::Running), "✅ Running");
assert_eq!(format!("{}", ComponentStatus::Error), "❌ Error");
}
#[test]
fn test_version_string() {
let vs = version_string();
assert!(vs.contains(BOTSERVER_VERSION));
}
}