Update botlib

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-21 23:40:41 -03:00
parent 7928c0ef14
commit 2de330dbe9
9 changed files with 368 additions and 331 deletions

View file

@ -6,6 +6,8 @@ description = "Shared library for General Bots - common types, utilities, and HT
license = "AGPL-3.0"
authors = ["Pragmatismo.com.br", "General Bots Community"]
repository = "https://github.com/GeneralBots/BotServer"
keywords = ["bot", "chatbot", "ai", "conversational", "library"]
categories = ["api-bindings", "web-programming"]
[features]
default = []
@ -36,3 +38,24 @@ validator = { version = "0.18", features = ["derive"], optional = true }
[dev-dependencies]
tokio = { version = "1.41", features = ["rt", "macros"] }
[lints.rust]
unused_imports = "warn"
unused_variables = "warn"
unused_mut = "warn"
unsafe_code = "deny"
missing_debug_implementations = "warn"
[lints.clippy]
all = "warn"
pedantic = "warn"
nursery = "warn"
cargo = "warn"
unwrap_used = "warn"
expect_used = "warn"
panic = "warn"
todo = "warn"
# Disabled: has false positives for functions with mut self, heap types (Vec, String)
missing_const_for_fn = "allow"
# Disabled: transitive dependencies we cannot control
multiple_crate_versions = "allow"

362
PROMPT.md
View file

@ -5,135 +5,137 @@
---
## Version Management - CRITICAL
## ZERO TOLERANCE POLICY
**Current version is 6.1.0 - DO NOT CHANGE without explicit approval!**
**This project has the strictest code quality requirements possible.**
### Rules
1. **Version is 6.1.0 across ALL workspace crates**
2. **NEVER change version without explicit user approval**
3. **All workspace crates share version 6.1.0**
4. **BotLib does not have migrations - all migrations are in botserver/**
**EVERY SINGLE WARNING MUST BE FIXED. NO EXCEPTIONS.**
---
## Official Icons - Reference
## ABSOLUTE PROHIBITIONS
**BotLib does not contain icons.** Icons are managed in:
- `botui/ui/suite/assets/icons/` - Runtime UI icons
- `botbook/src/assets/icons/` - Documentation icons
When documenting or referencing UI elements in BotLib:
- Reference icons by name (e.g., `gb-chat.svg`, `gb-drive.svg`)
- Never generate or embed icon content
- See `botui/PROMPT.md` for the complete icon list
```
❌ NEVER use #![allow()] or #[allow()] in source code to silence warnings
❌ NEVER use _ prefix for unused variables - DELETE the variable or USE it
❌ NEVER use .unwrap() - use ? or proper error handling
❌ NEVER use .expect() - use ? or proper error handling
❌ NEVER use panic!() or unreachable!() - handle all cases
❌ NEVER use todo!() or unimplemented!() - write real code
❌ NEVER leave unused imports - DELETE them
❌ NEVER leave dead code - DELETE it or IMPLEMENT it
❌ NEVER use approximate constants (3.14159) - use std::f64::consts::PI
❌ NEVER silence clippy in code - FIX THE CODE or configure in Cargo.toml
❌ NEVER add comments explaining what code does - code must be self-documenting
```
---
## Weekly Maintenance - EVERY MONDAY
## CARGO.TOML LINT EXCEPTIONS
### Package Review Checklist
When a clippy lint has **technical false positives** that cannot be fixed in code,
disable it in `Cargo.toml` with a comment explaining why:
**Every Monday, review the following:**
```toml
[lints.clippy]
# Disabled: has false positives for functions with mut self, heap types (Vec, String)
missing_const_for_fn = "allow"
# Disabled: Tauri commands require owned types (Window) that cannot be passed by reference
needless_pass_by_value = "allow"
# Disabled: transitive dependencies we cannot control
multiple_crate_versions = "allow"
```
1. **Dependency Updates**
```bash
cargo outdated
cargo audit
```
**Approved exceptions:**
- `missing_const_for_fn` - false positives for `mut self`, heap types
- `needless_pass_by_value` - Tauri/framework requirements
- `multiple_crate_versions` - transitive dependencies
- `future_not_send` - when async traits require non-Send futures
2. **Package Consolidation Opportunities**
- Check if new crates can replace custom code
- Look for crates that combine multiple dependencies
- Review `Cargo.toml` for redundant dependencies
---
3. **Code Reduction Candidates**
- Custom implementations that now have crate equivalents
- Boilerplate that can be replaced with derive macros
- Re-exports that can simplify downstream usage
## MANDATORY CODE PATTERNS
4. **Feature Flag Review**
- Check if optional features are still needed
- Consolidate similar features
- Remove unused feature gates
### Error Handling - Use `?` Operator
### Packages to Watch
```rust
// ❌ WRONG
let value = something.unwrap();
let value = something.expect("msg");
| Area | Potential Packages | Purpose |
|------|-------------------|---------|
| Error Handling | `anyhow`, `thiserror` | Consolidate error types |
| Validation | `validator` | Replace manual validation |
| Serialization | `serde` derives | Reduce boilerplate |
| UUID | `uuid` | Consistent ID generation |
// ✅ CORRECT
let value = something?;
let value = something.ok_or_else(|| Error::NotFound)?;
```
### Self Usage in Impl Blocks
```rust
// ❌ WRONG
impl MyStruct {
fn new() -> MyStruct { MyStruct { } }
}
// ✅ CORRECT
impl MyStruct {
fn new() -> Self { Self { } }
}
```
### Format Strings - Inline Variables
```rust
// ❌ WRONG
format!("Hello {}", name)
// ✅ CORRECT
format!("Hello {name}")
```
### Display vs ToString
```rust
// ❌ WRONG
impl ToString for MyType { }
// ✅ CORRECT
impl std::fmt::Display for MyType { }
```
### Derive Eq with PartialEq
```rust
// ❌ WRONG
#[derive(PartialEq)]
struct MyStruct { }
// ✅ CORRECT
#[derive(PartialEq, Eq)]
struct MyStruct { }
```
---
## Version Management
**Version is 6.1.0 - NEVER CHANGE without explicit approval**
---
## Project Overview
BotLib is the shared foundation library for the General Bots workspace. It provides common types, utilities, error handling, and optional integrations that are consumed by botserver, botui, and botapp.
### Workspace Position
BotLib is the shared foundation library for the General Bots workspace.
```
botlib/ # THIS PROJECT - Shared library
botserver/ # Main server (depends on botlib)
botui/ # Web/Desktop UI (depends on botlib)
botapp/ # Desktop app (depends on botlib)
botbook/ # Documentation
```
### What BotLib Provides
- **Error Types**: Common error handling with anyhow/thiserror
- **Models**: Shared data structures and types
- **HTTP Client**: Optional reqwest wrapper
- **Database**: Optional diesel integration
- **Validation**: Optional input validation
- **Branding**: Version and branding constants
---
## Feature Flags
```toml
[features]
default = []
full = ["database", "http-client", "validation"]
database = ["dep:diesel"]
http-client = ["dep:reqwest"]
validation = ["dep:validator"]
```
### Usage in Dependent Crates
```toml
# botserver/Cargo.toml
[dependencies.botlib]
path = "../botlib"
features = ["database"]
# botui/Cargo.toml
[dependencies.botlib]
path = "../botlib"
features = ["http-client"]
```
---
## Code Generation Rules
### CRITICAL REQUIREMENTS
```
- Library code must be generic and reusable
- No hardcoded values or project-specific logic
- All public APIs must be well-documented
- Feature gates for optional dependencies
- Zero warnings - clean compilation required
```
### Module Structure
## Module Structure
```
src/
@ -148,153 +150,31 @@ src/
---
## Adding New Features
### Adding a Shared Type
```rust
// src/models.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SharedEntity {
pub id: Uuid,
pub name: String,
pub created_at: DateTime<Utc>,
}
```
### Adding a Feature-Gated Module
```rust
// src/lib.rs
#[cfg(feature = "my-feature")]
pub mod my_module;
#[cfg(feature = "my-feature")]
pub use my_module::MyType;
```
```toml
# Cargo.toml
[features]
my-feature = ["dep:some-crate"]
[dependencies]
some-crate = { version = "1.0", optional = true }
```
### Adding Error Types
```rust
// src/error.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum BotLibError {
#[error("Configuration error: {0}")]
Config(String),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Database error: {0}")]
Database(String),
}
pub type Result<T> = std::result::Result<T, BotLibError>;
```
---
## Re-exports Strategy
BotLib should re-export common dependencies to ensure version consistency:
```rust
// src/lib.rs
pub use anyhow;
pub use chrono;
pub use serde;
pub use serde_json;
pub use thiserror;
pub use uuid;
#[cfg(feature = "database")]
pub use diesel;
#[cfg(feature = "http-client")]
pub use reqwest;
```
Consumers then use:
```rust
use botlib::uuid::Uuid;
use botlib::chrono::Utc;
```
---
## Dependencies
| Library | Version | Purpose | Optional |
|---------|---------|---------|----------|
| anyhow | 1.0 | Error handling | No |
| thiserror | 2.0 | Error derive | No |
| log | 0.4 | Logging facade | No |
| chrono | 0.4 | Date/time | No |
| serde | 1.0 | Serialization | No |
| serde_json | 1.0 | JSON | No |
| uuid | 1.11 | UUIDs | No |
| toml | 0.8 | Config parsing | No |
| diesel | 2.1 | Database ORM | Yes |
| reqwest | 0.12 | HTTP client | Yes |
| validator | 0.18 | Validation | Yes |
| Library | Version | Purpose |
|---------|---------|---------|
| anyhow | 1.0 | Error handling |
| thiserror | 2.0 | Error derive |
| chrono | 0.4 | Date/time |
| serde | 1.0 | Serialization |
| uuid | 1.11 | UUIDs |
| diesel | 2.1 | Database ORM |
| reqwest | 0.12 | HTTP client |
---
## Testing
## Remember
```bash
# Test all features
cargo test --all-features
# Test specific feature
cargo test --features database
# Test without optional features
cargo test
```
---
## Final Checks Before Commit
```bash
# Verify version is 6.1.0
grep "^version" Cargo.toml | grep "6.1.0"
# Build with all features
cargo build --all-features
# Check for warnings
cargo check --all-features 2>&1 | grep warning
# Run tests
cargo test --all-features
```
---
## Rules
- Keep botlib minimal and focused
- No business logic - only utilities and types
- Feature gate all optional dependencies
- Maintain backward compatibility
- Document all public APIs
- Target zero warnings
- **Version**: Always 6.1.0 - do not change without approval
- **ZERO WARNINGS** - Every clippy warning must be fixed
- **NO ALLOW IN CODE** - Never use #[allow()] in source files
- **CARGO.TOML EXCEPTIONS OK** - Disable lints with false positives in Cargo.toml with comment
- **NO DEAD CODE** - Delete unused code, never prefix with _
- **NO UNWRAP/EXPECT** - Use ? operator or proper error handling
- **INLINE FORMAT ARGS** - format!("{name}") not format!("{}", name)
- **USE SELF** - In impl blocks, use Self not the type name
- **DERIVE EQ** - Always derive Eq with PartialEq
- **DISPLAY NOT TOSTRING** - Implement Display, not ToString
- **USE DIAGNOSTICS** - Use IDE diagnostics tool, never call cargo clippy directly
- **Version**: Always 6.1.0 - do not change without approval
- **Session Continuation**: When running out of context, create detailed summary: (1) what was done, (2) what remains, (3) specific files and line numbers, (4) exact next steps.

View file

@ -17,10 +17,10 @@ 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
/// Branding configuration loaded from `.product` file
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrandingConfig {
/// Platform name (e.g., "MyCustomPlatform")
/// Platform name (e.g., "`MyCustomPlatform`")
pub name: String,
/// Short name for logs and compact displays (e.g., "MCP")
pub short_name: String,
@ -82,6 +82,7 @@ impl Default for BrandingConfig {
impl BrandingConfig {
/// Load branding from .product file if it exists
#[must_use]
pub fn load() -> Self {
let search_paths = [
".product",
@ -92,7 +93,7 @@ impl BrandingConfig {
for path in &search_paths {
if let Ok(config) = Self::load_from_file(path) {
info!("Loaded white-label branding from {}: {}", path, config.name);
info!("Loaded white-label branding from {path}: {}", config.name);
return config;
}
}
@ -101,8 +102,8 @@ impl BrandingConfig {
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
"Loaded white-label branding from PRODUCT_FILE={product_file}: {}",
config.name
);
return config;
}
@ -149,8 +150,10 @@ impl BrandingConfig {
}
// Try parsing as simple key=value format
let mut config = Self::default();
config.is_white_label = true;
let mut config = Self {
is_white_label: true,
..Self::default()
};
for line in content.lines() {
let line = line.trim();
@ -261,26 +264,31 @@ pub fn init_branding() {
}
/// Get the current branding configuration
#[must_use]
pub fn branding() -> &'static BrandingConfig {
BRANDING.get_or_init(BrandingConfig::load)
}
/// Get the platform name
#[must_use]
pub fn platform_name() -> &'static str {
&branding().name
}
/// Get the short platform name
#[must_use]
pub fn platform_short() -> &'static str {
&branding().short_name
}
/// Check if this is a white-label deployment
#[must_use]
pub fn is_white_label() -> bool {
branding().is_white_label
}
/// Get formatted copyright text
#[must_use]
pub fn copyright_text() -> String {
branding().copyright.clone().unwrap_or_else(|| {
format!(
@ -292,6 +300,7 @@ pub fn copyright_text() -> String {
}
/// Get footer text
#[must_use]
pub fn footer_text() -> String {
branding()
.footer_text
@ -300,6 +309,7 @@ pub fn footer_text() -> String {
}
/// Format a log prefix with platform branding
#[must_use]
pub fn log_prefix() -> String {
format!("[{}]", platform_short())
}

View file

@ -88,7 +88,8 @@ impl BotError {
Self::Conflict(msg.into())
}
pub fn rate_limited(retry_after_secs: u64) -> Self {
#[must_use]
pub const fn rate_limited(retry_after_secs: u64) -> Self {
Self::RateLimited { retry_after_secs }
}
@ -96,7 +97,8 @@ impl BotError {
Self::ServiceUnavailable(msg.into())
}
pub fn timeout(duration_ms: u64) -> Self {
#[must_use]
pub const fn timeout(duration_ms: u64) -> Self {
Self::Timeout { duration_ms }
}
@ -104,26 +106,27 @@ impl BotError {
Self::Internal(msg.into())
}
pub fn status_code(&self) -> u16 {
#[must_use]
pub const fn status_code(&self) -> u16 {
match self {
Self::Config(_) => 500,
Self::Database(_) => 500,
Self::Http { status, .. } => *status,
Self::Auth(_) => 401,
Self::Validation(_) => 400,
Self::Validation(_) | Self::Json(_) => 400,
Self::NotFound { .. } => 404,
Self::Conflict(_) => 409,
Self::RateLimited { .. } => 429,
Self::ServiceUnavailable(_) => 503,
Self::Timeout { .. } => 504,
Self::Internal(_) => 500,
Self::Io(_) => 500,
Self::Json(_) => 400,
Self::Other(_) => 500,
Self::Config(_)
| Self::Database(_)
| Self::Internal(_)
| Self::Io(_)
| Self::Other(_) => 500,
}
}
pub fn is_retryable(&self) -> bool {
#[must_use]
pub const fn is_retryable(&self) -> bool {
match self {
Self::RateLimited { .. } | Self::ServiceUnavailable(_) | Self::Timeout { .. } => true,
Self::Http { status, .. } => *status >= 500,
@ -131,12 +134,14 @@ impl BotError {
}
}
pub fn is_client_error(&self) -> bool {
#[must_use]
pub const fn is_client_error(&self) -> bool {
let code = self.status_code();
(400..500).contains(&code)
code >= 400 && code < 500
}
pub fn is_server_error(&self) -> bool {
#[must_use]
pub const fn is_server_error(&self) -> bool {
self.status_code() >= 500
}
}
@ -162,7 +167,7 @@ impl From<&str> for BotError {
#[cfg(feature = "http-client")]
impl From<reqwest::Error> for BotError {
fn from(err: reqwest::Error) -> Self {
let status = err.status().map(|s| s.as_u16()).unwrap_or(500);
let status = err.status().map_or(500, |s| s.as_u16());
Self::Http {
status,
message: err.to_string(),

View file

@ -1,3 +1,5 @@
//! HTTP client for botserver communication.
use crate::error::BotError;
use log::{debug, error};
use serde::{de::DeserializeOwned, Serialize};
@ -7,6 +9,7 @@ use std::time::Duration;
const DEFAULT_BOTSERVER_URL: &str = "https://localhost:8088";
const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// HTTP client for communicating with the botserver.
#[derive(Clone)]
pub struct BotServerClient {
client: Arc<reqwest::Client>,
@ -14,10 +17,20 @@ pub struct BotServerClient {
}
impl BotServerClient {
/// Creates a new client with an optional base URL.
///
/// Uses `BOTSERVER_URL` environment variable if no URL is provided,
/// or falls back to the default localhost URL.
#[must_use]
pub fn new(base_url: Option<String>) -> Self {
Self::with_timeout(base_url, Duration::from_secs(DEFAULT_TIMEOUT_SECS))
}
/// Creates a new client with a custom timeout.
///
/// Uses `BOTSERVER_URL` environment variable if no URL is provided,
/// or falls back to the default localhost URL.
#[must_use]
pub fn with_timeout(base_url: Option<String>, timeout: Duration) -> Self {
let url = base_url.unwrap_or_else(|| {
std::env::var("BOTSERVER_URL").unwrap_or_else(|_| DEFAULT_BOTSERVER_URL.to_string())
@ -28,7 +41,7 @@ impl BotServerClient {
.user_agent(format!("BotLib/{}", env!("CARGO_PKG_VERSION")))
.danger_accept_invalid_certs(true)
.build()
.expect("Failed to create HTTP client");
.unwrap_or_else(|_| reqwest::Client::new());
Self {
client: Arc::new(client),
@ -36,82 +49,119 @@ impl BotServerClient {
}
}
/// Returns the base URL of the client.
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
/// Performs a GET request to the specified endpoint.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn get<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, BotError> {
let url = format!("{}{}", self.base_url, endpoint);
debug!("GET {}", url);
let url = format!("{}{endpoint}", self.base_url);
debug!("GET {url}");
let response = self.client.get(&url).send().await?;
self.handle_response(response).await
}
pub async fn post<T: Serialize, R: DeserializeOwned>(
/// Performs a POST request with a JSON body.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn post<T: Serialize + Send + Sync, R: DeserializeOwned>(
&self,
endpoint: &str,
body: &T,
) -> Result<R, BotError> {
let url = format!("{}{}", self.base_url, endpoint);
debug!("POST {}", url);
let url = format!("{}{endpoint}", self.base_url);
debug!("POST {url}");
let response = self.client.post(&url).json(body).send().await?;
self.handle_response(response).await
}
pub async fn put<T: Serialize, R: DeserializeOwned>(
/// Performs a PUT request with a JSON body.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn put<T: Serialize + Send + Sync, R: DeserializeOwned>(
&self,
endpoint: &str,
body: &T,
) -> Result<R, BotError> {
let url = format!("{}{}", self.base_url, endpoint);
debug!("PUT {}", url);
let url = format!("{}{endpoint}", self.base_url);
debug!("PUT {url}");
let response = self.client.put(&url).json(body).send().await?;
self.handle_response(response).await
}
pub async fn patch<T: Serialize, R: DeserializeOwned>(
/// Performs a PATCH request with a JSON body.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn patch<T: Serialize + Send + Sync, R: DeserializeOwned>(
&self,
endpoint: &str,
body: &T,
) -> Result<R, BotError> {
let url = format!("{}{}", self.base_url, endpoint);
debug!("PATCH {}", url);
let url = format!("{}{endpoint}", self.base_url);
debug!("PATCH {url}");
let response = self.client.patch(&url).json(body).send().await?;
self.handle_response(response).await
}
/// Performs a DELETE request to the specified endpoint.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn delete<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, BotError> {
let url = format!("{}{}", self.base_url, endpoint);
debug!("DELETE {}", url);
let url = format!("{}{endpoint}", self.base_url);
debug!("DELETE {url}");
let response = self.client.delete(&url).send().await?;
self.handle_response(response).await
}
/// Performs an authorized GET request with a bearer token.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn get_authorized<T: DeserializeOwned>(
&self,
endpoint: &str,
token: &str,
) -> Result<T, BotError> {
let url = format!("{}{}", self.base_url, endpoint);
debug!("GET {} (authorized)", url);
let url = format!("{}{endpoint}", self.base_url);
debug!("GET {url} (authorized)");
let response = self.client.get(&url).bearer_auth(token).send().await?;
self.handle_response(response).await
}
pub async fn post_authorized<T: Serialize, R: DeserializeOwned>(
/// Performs an authorized POST request with a bearer token and JSON body.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn post_authorized<T: Serialize + Send + Sync, R: DeserializeOwned>(
&self,
endpoint: &str,
body: &T,
token: &str,
) -> Result<R, BotError> {
let url = format!("{}{}", self.base_url, endpoint);
debug!("POST {} (authorized)", url);
let url = format!("{}{endpoint}", self.base_url);
debug!("POST {url} (authorized)");
let response = self
.client
@ -123,23 +173,31 @@ impl BotServerClient {
self.handle_response(response).await
}
/// Performs an authorized DELETE request with a bearer token.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn delete_authorized<T: DeserializeOwned>(
&self,
endpoint: &str,
token: &str,
) -> Result<T, BotError> {
let url = format!("{}{}", self.base_url, endpoint);
debug!("DELETE {} (authorized)", url);
let url = format!("{}{endpoint}", self.base_url);
debug!("DELETE {url} (authorized)");
let response = self.client.delete(&url).bearer_auth(token).send().await?;
self.handle_response(response).await
}
/// Performs a health check against the server.
///
/// Returns `true` if the server is healthy, `false` otherwise.
pub async fn health_check(&self) -> bool {
match self.get::<serde_json::Value>("/health").await {
Ok(_) => true,
Err(e) => {
error!("Health check failed: {}", e);
error!("Health check failed: {e}");
false
}
}
@ -157,13 +215,13 @@ impl BotServerClient {
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
error!("HTTP {} error: {}", status_code, error_text);
error!("HTTP {status_code} error: {error_text}");
return Err(BotError::http(status_code, error_text));
}
response.json().await.map_err(|e| {
error!("Failed to parse response: {}", e);
BotError::http(status_code, format!("Failed to parse response: {}", e))
error!("Failed to parse response: {e}");
BotError::http(status_code, format!("Failed to parse response: {e}"))
})
}
}
@ -172,7 +230,7 @@ impl std::fmt::Debug for BotServerClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BotServerClient")
.field("base_url", &self.base_url)
.finish()
.finish_non_exhaustive()
}
}
@ -212,7 +270,7 @@ mod tests {
#[test]
fn test_client_debug() {
let client = BotServerClient::new(Some("http://debug-test".to_string()));
let debug_str = format!("{:?}", client);
let debug_str = format!("{client:?}");
assert!(debug_str.contains("BotServerClient"));
assert!(debug_str.contains("http://debug-test"));
}

View file

@ -1,4 +1,4 @@
//! BotLib - Shared library for General Bots
//! `BotLib` - Shared library for General Bots
//!
//! This crate provides common types, utilities, and abstractions
//! shared between botserver and botui.
@ -17,7 +17,9 @@ pub mod models;
pub mod version;
// Re-exports for convenience
pub use branding::{branding, init_branding, is_white_label, platform_name, platform_short, BrandingConfig};
pub use branding::{
branding, init_branding, is_white_label, platform_name, platform_short, BrandingConfig,
};
pub use error::{BotError, BotResult};
pub use message_types::MessageType;
pub use models::{ApiResponse, BotResponse, Session, Suggestion, UserMessage};

View file

@ -10,28 +10,28 @@ use serde::{Deserialize, Serialize};
pub struct MessageType(pub i32);
impl MessageType {
/// Regular message from external systems (WhatsApp, Instagram, etc.)
pub const EXTERNAL: MessageType = MessageType(0);
/// Regular message from external systems (`WhatsApp`, Instagram, etc.)
pub const EXTERNAL: Self = Self(0);
/// User message from web interface
pub const USER: MessageType = MessageType(1);
pub const USER: Self = Self(1);
/// Bot response (can be regular content or event)
pub const BOT_RESPONSE: MessageType = MessageType(2);
pub const BOT_RESPONSE: Self = Self(2);
/// Continue interrupted response
pub const CONTINUE: MessageType = MessageType(3);
pub const CONTINUE: Self = Self(3);
/// Suggestion or command message
pub const SUGGESTION: MessageType = MessageType(4);
pub const SUGGESTION: Self = Self(4);
/// Context change notification
pub const CONTEXT_CHANGE: MessageType = MessageType(5);
pub const CONTEXT_CHANGE: Self = Self(5);
}
impl From<i32> for MessageType {
fn from(value: i32) -> Self {
MessageType(value)
Self(value)
}
}
@ -43,7 +43,7 @@ impl From<MessageType> for i32 {
impl Default for MessageType {
fn default() -> Self {
MessageType::USER
Self::USER
}
}
@ -58,7 +58,7 @@ impl std::fmt::Display for MessageType {
5 => "CONTEXT_CHANGE",
_ => "UNKNOWN",
};
write!(f, "{}", name)
write!(f, "{name}")
}
}

View file

@ -1,3 +1,5 @@
//! API models for bot communication.
use crate::message_types::MessageType;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@ -17,7 +19,8 @@ pub struct ApiResponse<T> {
}
impl<T> ApiResponse<T> {
pub fn success(data: T) -> Self {
#[must_use]
pub const fn success(data: T) -> Self {
Self {
success: true,
data: Some(data),
@ -27,6 +30,7 @@ impl<T> ApiResponse<T> {
}
}
#[must_use]
pub fn success_with_message(data: T, message: impl Into<String>) -> Self {
Self {
success: true,
@ -37,6 +41,7 @@ impl<T> ApiResponse<T> {
}
}
#[must_use]
pub fn error(message: impl Into<String>) -> Self {
Self {
success: false,
@ -47,6 +52,7 @@ impl<T> ApiResponse<T> {
}
}
#[must_use]
pub fn error_with_code(message: impl Into<String>, code: impl Into<String>) -> Self {
Self {
success: false,
@ -57,6 +63,7 @@ impl<T> ApiResponse<T> {
}
}
#[must_use]
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> ApiResponse<U> {
ApiResponse {
success: self.success,
@ -67,11 +74,13 @@ impl<T> ApiResponse<T> {
}
}
pub fn is_success(&self) -> bool {
#[must_use]
pub const fn is_success(&self) -> bool {
self.success
}
pub fn is_error(&self) -> bool {
#[must_use]
pub const fn is_error(&self) -> bool {
!self.success
}
}
@ -95,6 +104,7 @@ pub struct Session {
}
impl Session {
#[must_use]
pub fn new(user_id: Uuid, bot_id: Uuid, title: impl Into<String>) -> Self {
let now = Utc::now();
Self {
@ -108,19 +118,23 @@ impl Session {
}
}
#[must_use]
pub fn with_expiry(mut self, expires_at: DateTime<Utc>) -> Self {
self.expires_at = Some(expires_at);
self
}
#[must_use]
pub fn is_expired(&self) -> bool {
self.expires_at.map(|exp| Utc::now() > exp).unwrap_or(false)
self.expires_at.is_some_and(|exp| Utc::now() > exp)
}
#[must_use]
pub fn is_active(&self) -> bool {
!self.is_expired()
}
#[must_use]
pub fn remaining_time(&self) -> Option<chrono::Duration> {
self.expires_at.map(|exp| exp - Utc::now())
}
@ -142,6 +156,7 @@ pub struct UserMessage {
}
impl UserMessage {
#[must_use]
pub fn text(
bot_id: impl Into<String>,
user_id: impl Into<String>,
@ -162,17 +177,20 @@ impl UserMessage {
}
}
#[must_use]
pub fn with_media(mut self, url: impl Into<String>) -> Self {
self.media_url = Some(url.into());
self
}
#[must_use]
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context_name = Some(context.into());
self
}
pub fn has_media(&self) -> bool {
#[must_use]
pub const fn has_media(&self) -> bool {
self.media_url.is_some()
}
}
@ -189,6 +207,7 @@ pub struct Suggestion {
}
impl Suggestion {
#[must_use]
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
@ -198,16 +217,19 @@ impl Suggestion {
}
}
#[must_use]
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
self
}
#[must_use]
pub fn with_action(mut self, action: impl Into<String>) -> Self {
self.action = Some(action.into());
self
}
#[must_use]
pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
@ -242,6 +264,7 @@ pub struct BotResponse {
}
impl BotResponse {
#[must_use]
pub fn new(
bot_id: impl Into<String>,
session_id: impl Into<String>,
@ -265,6 +288,7 @@ impl BotResponse {
}
}
#[must_use]
pub fn streaming(
bot_id: impl Into<String>,
session_id: impl Into<String>,
@ -288,6 +312,7 @@ impl BotResponse {
}
}
#[must_use]
pub fn with_suggestions<I, S>(mut self, suggestions: I) -> Self
where
I: IntoIterator<Item = S>,
@ -297,11 +322,13 @@ impl BotResponse {
self
}
#[must_use]
pub fn add_suggestion(mut self, suggestion: impl Into<Suggestion>) -> Self {
self.suggestions.push(suggestion.into());
self
}
#[must_use]
pub fn with_context(
mut self,
name: impl Into<String>,
@ -318,15 +345,18 @@ impl BotResponse {
self.content.push_str(chunk);
}
#[must_use]
pub fn complete(mut self) -> Self {
self.is_complete = true;
self
}
pub fn is_streaming(&self) -> bool {
#[must_use]
pub const fn is_streaming(&self) -> bool {
self.stream_token.is_some() && !self.is_complete
}
#[must_use]
pub fn has_suggestions(&self) -> bool {
!self.suggestions.is_empty()
}
@ -376,6 +406,7 @@ pub enum AttachmentType {
}
impl Attachment {
#[must_use]
pub fn new(attachment_type: AttachmentType, url: impl Into<String>) -> Self {
Self {
attachment_type,
@ -387,51 +418,62 @@ impl Attachment {
}
}
#[must_use]
pub fn image(url: impl Into<String>) -> Self {
Self::new(AttachmentType::Image, url)
}
#[must_use]
pub fn audio(url: impl Into<String>) -> Self {
Self::new(AttachmentType::Audio, url)
}
#[must_use]
pub fn video(url: impl Into<String>) -> Self {
Self::new(AttachmentType::Video, url)
}
#[must_use]
pub fn document(url: impl Into<String>) -> Self {
Self::new(AttachmentType::Document, url)
}
#[must_use]
pub fn file(url: impl Into<String>) -> Self {
Self::new(AttachmentType::File, url)
}
#[must_use]
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
#[must_use]
pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
self.filename = Some(filename.into());
self
}
pub fn with_size(mut self, size: u64) -> Self {
#[must_use]
pub const fn with_size(mut self, size: u64) -> Self {
self.size = Some(size);
self
}
#[must_use]
pub fn with_thumbnail(mut self, thumbnail_url: impl Into<String>) -> Self {
self.thumbnail_url = Some(thumbnail_url.into());
self
}
pub fn is_image(&self) -> bool {
self.attachment_type == AttachmentType::Image
#[must_use]
pub const fn is_image(&self) -> bool {
matches!(self.attachment_type, AttachmentType::Image)
}
pub fn is_media(&self) -> bool {
#[must_use]
pub const fn is_media(&self) -> bool {
matches!(
self.attachment_type,
AttachmentType::Image | AttachmentType::Audio | AttachmentType::Video

View file

@ -50,12 +50,12 @@ pub enum ComponentStatus {
impl std::fmt::Display for ComponentStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ComponentStatus::Running => write!(f, "[OK] Running"),
ComponentStatus::Stopped => write!(f, "[STOP] Stopped"),
ComponentStatus::Error => write!(f, "[ERR] Error"),
ComponentStatus::Updating => write!(f, "[UPD] Updating"),
ComponentStatus::NotInstalled => write!(f, "[--] Not Installed"),
ComponentStatus::Unknown => write!(f, "[?] Unknown"),
Self::Running => write!(f, "[OK] Running"),
Self::Stopped => write!(f, "[STOP] Stopped"),
Self::Error => write!(f, "[ERR] Error"),
Self::Updating => write!(f, "[UPD] Updating"),
Self::NotInstalled => write!(f, "[--] Not Installed"),
Self::Unknown => write!(f, "[?] Unknown"),
}
}
}
@ -74,12 +74,12 @@ pub enum ComponentSource {
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"),
Self::Builtin => write!(f, "Built-in"),
Self::Docker => write!(f, "Docker"),
Self::Lxc => write!(f, "LXC"),
Self::System => write!(f, "System"),
Self::Binary => write!(f, "Binary"),
Self::External => write!(f, "External"),
}
}
}
@ -106,6 +106,7 @@ impl Default for VersionRegistry {
impl VersionRegistry {
/// Create a new version registry
#[must_use]
pub fn new() -> Self {
let mut registry = Self::default();
registry.register_builtin_components();
@ -185,16 +186,19 @@ impl VersionRegistry {
}
/// Get component by name
#[must_use]
pub fn get_component(&self, name: &str) -> Option<&ComponentVersion> {
self.components.get(name)
}
/// Get all components
pub fn get_all_components(&self) -> &HashMap<String, ComponentVersion> {
#[must_use]
pub const fn get_all_components(&self) -> &HashMap<String, ComponentVersion> {
&self.components
}
/// Get components with available updates
#[must_use]
pub fn get_available_updates(&self) -> Vec<&ComponentVersion> {
self.components
.values()
@ -203,6 +207,7 @@ impl VersionRegistry {
}
/// Get summary of all components
#[must_use]
pub fn summary(&self) -> String {
let running = self
.components
@ -213,12 +218,16 @@ impl VersionRegistry {
let updates = self.get_available_updates().len();
format!(
"{} v{} | {}/{} components running | {} updates available",
BOTSERVER_NAME, self.core_version, running, total, updates
"{BOTSERVER_NAME} v{} | {running}/{total} components running | {updates} updates available",
self.core_version
)
}
/// Get summary as JSON
///
/// # Errors
///
/// Returns an error if JSON serialization fails.
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
@ -235,6 +244,7 @@ pub fn init_version_registry() {
}
/// Get version registry (read-only)
#[must_use]
pub fn version_registry() -> Option<VersionRegistry> {
VERSION_REGISTRY.read().ok()?.clone()
}
@ -264,6 +274,7 @@ pub fn update_component_status(name: &str, status: ComponentStatus) {
}
/// Get component version
#[must_use]
pub fn get_component_version(name: &str) -> Option<ComponentVersion> {
VERSION_REGISTRY
.read()
@ -274,13 +285,15 @@ pub fn get_component_version(name: &str) -> Option<ComponentVersion> {
}
/// Get botserver version
pub fn get_botserver_version() -> &'static str {
#[must_use]
pub const fn get_botserver_version() -> &'static str {
BOTSERVER_VERSION
}
/// Get version string for display
#[must_use]
pub fn version_string() -> String {
format!("{} v{}", BOTSERVER_NAME, BOTSERVER_VERSION)
format!("{BOTSERVER_NAME} v{BOTSERVER_VERSION}")
}
#[cfg(test)]
@ -333,8 +346,12 @@ mod tests {
fn test_update_status() {
let mut registry = VersionRegistry::new();
registry.update_status("botserver", ComponentStatus::Stopped);
let component = registry.get_component("botserver").unwrap();
assert_eq!(component.status, ComponentStatus::Stopped);
let component = registry.get_component("botserver");
assert!(
component.is_some(),
"botserver component should exist in registry"
);
assert_eq!(component.map(|c| c.status), Some(ComponentStatus::Stopped));
}
#[test]