From 635f3a7923c2e119ec38fb600c4fb7445a67683f Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Sun, 30 Nov 2025 12:20:48 -0300 Subject: [PATCH] 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 --- docs/src/SUMMARY.md | 7 +- .../basic-vs-automation-tools.md | 454 ++++++++ .../keyword-set-schedule.md | 299 ++++-- docs/src/chapter-06-gbdialog/templates.md | 122 ++- .../src/chapter-06-gbdialog/templates/auth.md | 434 +++++++- .../templates/enrollment.md | 354 ++++++- .../chapter-06-gbdialog/templates/start.md | 267 ++++- .../assets/data-traceability.svg | 306 ++++++ docs/src/whatsapp-chat.css | 447 ++++++++ src/basic/keywords/datetime/now.rs | 325 +++++- src/basic/keywords/set_schedule.rs | 551 +++++++++- src/console/mod.rs | 1 + src/console/wizard.rs | 968 +++++++++++++++++ src/core/bot/manager.rs | 974 ++++++++++++++++++ src/core/shared/branding.rs | 395 +++++++ src/core/shared/mod.rs | 9 + src/core/shared/version.rs | 514 +++++++++ 17 files changed, 6233 insertions(+), 194 deletions(-) create mode 100644 docs/src/chapter-06-gbdialog/basic-vs-automation-tools.md create mode 100644 docs/src/chapter-07-gbapp/assets/data-traceability.svg create mode 100644 docs/src/whatsapp-chat.css create mode 100644 src/console/wizard.rs create mode 100644 src/core/bot/manager.rs create mode 100644 src/core/shared/branding.rs create mode 100644 src/core/shared/version.rs diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 47475e155..54340df52 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -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) diff --git a/docs/src/chapter-06-gbdialog/basic-vs-automation-tools.md b/docs/src/chapter-06-gbdialog/basic-vs-automation-tools.md new file mode 100644 index 000000000..12b5c77f2 --- /dev/null +++ b/docs/src/chapter-06-gbdialog/basic-vs-automation-tools.md @@ -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 \ No newline at end of file diff --git a/docs/src/chapter-06-gbdialog/keyword-set-schedule.md b/docs/src/chapter-06-gbdialog/keyword-set-schedule.md index 268e4313c..c5fd1a66c 100644 --- a/docs/src/chapter-06-gbdialog/keyword-set-schedule.md +++ b/docs/src/chapter-06-gbdialog/keyword-set-schedule.md @@ -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 \ No newline at end of file diff --git a/docs/src/chapter-06-gbdialog/templates.md b/docs/src/chapter-06-gbdialog/templates.md index 7e290ae00..c429a1683 100644 --- a/docs/src/chapter-06-gbdialog/templates.md +++ b/docs/src/chapter-06-gbdialog/templates.md @@ -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. \ No newline at end of file +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 \ No newline at end of file diff --git a/docs/src/chapter-06-gbdialog/templates/auth.md b/docs/src/chapter-06-gbdialog/templates/auth.md index d5bceede2..d5e45bcef 100644 --- a/docs/src/chapter-06-gbdialog/templates/auth.md +++ b/docs/src/chapter-06-gbdialog/templates/auth.md @@ -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 + +
+ + + + + + + + + + + + + +
+ +### Dialog 2: Failed Login with Retry + +
+ + + + + + + + + + + + + +
+ +### Dialog 3: Account Locked + +
+ + + + + + + + + + + +
+ +### Dialog 4: New User Registration + +
+ + + + + + + + + + + + + + + +
+ +## 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 --- + + \ No newline at end of file diff --git a/docs/src/chapter-06-gbdialog/templates/enrollment.md b/docs/src/chapter-06-gbdialog/templates/enrollment.md index 705ba8750..ceafc8eed 100644 --- a/docs/src/chapter-06-gbdialog/templates/enrollment.md +++ b/docs/src/chapter-06-gbdialog/templates/enrollment.md @@ -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 multi‑step 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 + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +### Dialog 2: User Corrects Information + +
+ + + + + + + + + + + + + + + +
+ +### Dialog 3: LLM-Assisted Natural Input + +When using the LLM, users can provide information naturally: + +
+ + + + + + + + + + + +
+ +## 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 --- + + \ No newline at end of file diff --git a/docs/src/chapter-06-gbdialog/templates/start.md b/docs/src/chapter-06-gbdialog/templates/start.md index c1522da7b..ede9e5369 100644 --- a/docs/src/chapter-06-gbdialog/templates/start.md +++ b/docs/src/chapter-06-gbdialog/templates/start.md @@ -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 + +
+ + + + + + + + + +
+ +### Dialog 2: Unknown Input + +
+ + + + + + + + + + + +
+ +### Dialog 3: Personalized Greeting (Enhanced Version) + +When you add user detection, the experience improves: + +
+ + + + + + + +
+ +## 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 --- + + \ No newline at end of file diff --git a/docs/src/chapter-07-gbapp/assets/data-traceability.svg b/docs/src/chapter-07-gbapp/assets/data-traceability.svg new file mode 100644 index 000000000..27fb71851 --- /dev/null +++ b/docs/src/chapter-07-gbapp/assets/data-traceability.svg @@ -0,0 +1,306 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Data Traceability Diagram - General Bots Architecture + + + + + + Legend + + + User Input Flow + + + LLM Processing + + + Storage Operations + + + Internal Flow + + + + + + CHANNELS + + + + πŸ“± WhatsApp + + + + ✈️ Telegram + + + + 🌐 Web UI / gbui + + + + πŸ”Œ REST API + + + + πŸͺ Webhooks + + + + + + BOTSERVER CORE + + + + BASIC Interpreter + .bas scripts β†’ Rhai engine + + + + Message Router + TALK / HEAR / SEND + + + + Scheduler + SET SCHEDULE "every hour" + + + + Keywords Engine + GET/POST/FIND/SAVE/LLM + + + + Session Manager + user_id β†’ bot_id β†’ org_id + + + + + + LLM PROVIDERS + + + 🧠 Claude Opus 4 + + + πŸ€– GPT-4 Turbo + + + πŸ’Ž Gemini Pro + + + πŸ¦™ Local: Llama/Mistral + + + + + + STORAGE (MinIO) + + + πŸ“ /{org}/{botname}/ + Bucket per bot + + + .gbdialog + + + .gbkb + + + .gbot + + + .gbtheme + + + uploads/ | exports/ | cache/ + + + + + + DATABASE + + + 🐘 PostgreSQL + Main database + + + organizations + + + bots + + + users / sessions + + + conversations + + + system_automations + + + πŸ”΄ Redis (cache/queue) + + + + + + + + + + LLM + + + + + + + FILES + + + + FIND/SAVE + + + + + + + + Key Data Flows + + + + + 1. User Message Flow + Channel β†’ Router β†’ Session + β†’ BASIC Script β†’ LLM (if needed) + β†’ Response β†’ Channel + Key: {org}_{bot}_{user}_{session} + + + + + + 2. Scheduled Task Flow + Cron Trigger β†’ Load Script + β†’ Execute Keywords β†’ External APIs + β†’ Save Results β†’ Log + Key: {org}_{bot}_schedule_{name} + + + + + + 3. File Operations Flow + UPLOAD/DOWNLOAD β†’ MinIO + Bucket: /{org}/{botname}/ + β†’ Metadata β†’ PostgreSQL + Path: s3://{org}/{bot}/{path} + + + + + + 4. Knowledge Base Flow + USE KB "docs" β†’ Load .gbkb + β†’ Embed β†’ Vector DB (pgvector) + β†’ Semantic Search β†’ LLM Context + Key: {org}_{bot}_kb_{name} + + + + + + 5. Webhook Flow + External POST β†’ /webhook/{id} + β†’ Validate β†’ Trigger Script + β†’ Process β†’ Response/Notify + Key: {org}_{bot}_webhook_{path} + + + + + + 6. Bot Memory Flow + SET/GET BOT MEMORY β†’ Redis + β†’ Persist β†’ PostgreSQL (backup) + β†’ Scope: global | user | session + Key: {org}_{bot}_mem_{scope}_{key} + + + + + + General Bots Data Traceability - All keys follow pattern: {org}_{botname}_{resource}_{identifier} + + diff --git a/docs/src/whatsapp-chat.css b/docs/src/whatsapp-chat.css new file mode 100644 index 000000000..9e4363675 --- /dev/null +++ b/docs/src/whatsapp-chat.css @@ -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; +} diff --git a/src/basic/keywords/datetime/now.rs b/src/basic/keywords/datetime/now.rs index a8c25a78f..5e313f39e 100644 --- a/src/basic/keywords/datetime/now.rs +++ b/src/basic/keywords/datetime/now.rs @@ -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) -> 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, _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, _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, _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, _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); } } diff --git a/src/basic/keywords/set_schedule.rs b/src/basic/keywords/set_schedule.rs index b4333ffba..13c981e22 100644 --- a/src/basic/keywords/set_schedule.rs +++ b/src/basic/keywords/set_schedule.rs @@ -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 { + 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 { + // 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 { + // 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::() { + 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::() { + 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 { + // 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 { + // 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 { + 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 { + 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 { + // 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 { + // 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::() { + 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> { + // 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::>), )) .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 + } +} diff --git a/src/console/mod.rs b/src/console/mod.rs index 245665038..648eec6f9 100644 --- a/src/console/mod.rs +++ b/src/console/mod.rs @@ -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}; diff --git a/src/console/wizard.rs b/src/console/wizard.rs new file mode 100644 index 000000000..ca2fbdfa7 --- /dev/null +++ b/src/console/wizard.rs @@ -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, + + /// Local model path (if using local LLM) + pub local_model_path: Option, + + /// Components to install + pub components: Vec, + + /// Admin user configuration + pub admin: AdminConfig, + + /// Organization configuration + pub organization: OrgConfig, + + /// Selected bot template + pub template: Option, + + /// 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, +} + +/// 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 { + 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)> = 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( + &self, + stdout: &mut io::Stdout, + options: &[(&str, &str, T)], + default: usize, + ) -> io::Result { + 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> { + let mut selected: Vec = 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 { + 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"); + } +} diff --git a/src/core/bot/manager.rs b/src/core/bot/manager.rs new file mode 100644 index 000000000..5b9e346b7 --- /dev/null +++ b/src/core/bot/manager.rs @@ -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, + + /// Bot status + pub status: BotStatus, + + /// MinIO bucket name + pub bucket: String, + + /// Custom UI path (optional) + pub custom_ui: Option, + + /// Bot settings + pub settings: BotSettings, + + /// Access control + pub access: BotAccess, + + /// Creation timestamp + pub created_at: DateTime, + + /// Last updated timestamp + pub updated_at: DateTime, + + /// 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, + + /// Knowledge bases enabled + pub knowledge_bases: Vec, + + /// Enabled channels + pub channels: Vec, + + /// Webhook endpoints + pub webhooks: Vec, + + /// Schedule definitions + pub schedules: Vec, + + /// Custom variables + pub variables: HashMap, +} + +/// Bot access control +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct BotAccess { + /// Admin users (full access) + pub admins: Vec, + + /// Editor users (can edit scripts) + pub editors: Vec, + + /// Viewer users (read-only) + pub viewers: Vec, + + /// Public access enabled + pub is_public: bool, + + /// Allowed domains (for embedding) + pub allowed_domains: Vec, + + /// API key for external access + pub api_key: Option, +} + +/// 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, + pub preview_image: Option, +} + +/// 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, + + /// Organization ID + pub org_id: Uuid, + + /// Template to use (optional) + pub template: Option, + + /// Creator user ID + pub created_by: Uuid, + + /// Initial settings + pub settings: Option, + + /// Custom UI name (optional) + pub custom_ui: Option, +} + +/// 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>>, + + /// Available templates + templates: Arc>>, +} + +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> { + 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> { + 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> { + 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::() + .trim_matches(|c| c == '-' || c == '_') + .to_string() + } + + /// Create MinIO bucket for bot + async fn create_minio_bucket( + &self, + bucket_name: &str, + ) -> Result<(), Box> { + 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> { + 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> { + 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> { + 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> { + 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 { + let templates = self.templates.read().await; + templates.values().cloned().collect() + } + + /// Get bot by ID + pub async fn get_bot(&self, bot_id: Uuid) -> Option { + 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 { + 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 { + 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> { + 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 { + 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, +} + +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())); + } +} diff --git a/src/core/shared/branding.rs b/src/core/shared/branding.rs new file mode 100644 index 000000000..012a78b15 --- /dev/null +++ b/src/core/shared/branding.rs @@ -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 = 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, + + /// Domain for URLs and emails + pub domain: Option, + + /// Support email + pub support_email: Option, + + /// Logo URL (for web UI) + pub logo_url: Option, + + /// Favicon URL + pub favicon_url: Option, + + /// Primary color (hex) + pub primary_color: Option, + + /// Secondary color (hex) + pub secondary_color: Option, + + /// Footer text + pub footer_text: Option, + + /// Copyright text + pub copyright: Option, + + /// Custom CSS URL + pub custom_css: Option, + + /// Terms of service URL + pub terms_url: Option, + + /// Privacy policy URL + pub privacy_url: Option, + + /// Documentation URL + pub docs_url: Option, + + /// 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> { + 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::(&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, + #[serde(default)] + company: Option, + #[serde(default)] + domain: Option, + #[serde(default)] + support_email: Option, + #[serde(default)] + logo_url: Option, + #[serde(default)] + favicon_url: Option, + #[serde(default)] + primary_color: Option, + #[serde(default)] + secondary_color: Option, + #[serde(default)] + footer_text: Option, + #[serde(default)] + copyright: Option, + #[serde(default)] + custom_css: Option, + #[serde(default)] + terms_url: Option, + #[serde(default)] + privacy_url: Option, + #[serde(default)] + docs_url: Option, +} + +impl From 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::() + .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()); + } +} diff --git a/src/core/shared/mod.rs b/src/core/shared/mod.rs index 3b1bb98b5..95cdbd163 100644 --- a/src/core/shared/mod.rs +++ b/src/core/shared/mod.rs @@ -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, +}; diff --git a/src/core/shared/version.rs b/src/core/shared/version.rs new file mode 100644 index 000000000..d4b85a040 --- /dev/null +++ b/src/core/shared/version.rs @@ -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> = 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, + + /// Whether an update is available + pub update_available: bool, + + /// Component status + pub status: ComponentStatus, + + /// Last check time + pub last_checked: Option>, + + /// Source/origin of the component + pub source: ComponentSource, + + /// Additional metadata + pub metadata: HashMap, +} + +/// 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, + + /// Last global update check + pub last_update_check: Option>, + + /// Update check URL + pub update_url: Option, +} + +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, Box> { + 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> { + 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 { + 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, + #[allow(dead_code)] + body: Option, +} + +/// 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, +} + +// ============================================================================ +// 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> { + VERSION_REGISTRY.read().unwrap() +} + +/// Get mutable access to the global version registry +pub fn version_registry_mut() -> std::sync::RwLockWriteGuard<'static, Option> { + 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 { + 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)); + } +}