Update library components and models
This commit is contained in:
parent
2de330dbe9
commit
b430866dbf
8 changed files with 14 additions and 212 deletions
22
Cargo.toml
22
Cargo.toml
|
|
@ -39,23 +39,5 @@ validator = { version = "0.18", features = ["derive"], optional = true }
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.41", features = ["rt", "macros"] }
|
tokio = { version = "1.41", features = ["rt", "macros"] }
|
||||||
|
|
||||||
[lints.rust]
|
[lints]
|
||||||
unused_imports = "warn"
|
workspace = true
|
||||||
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"
|
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,32 @@
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use log::info;
|
use log::info;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
/// Global branding configuration - loaded once at startup
|
|
||||||
static BRANDING: OnceLock<BrandingConfig> = OnceLock::new();
|
static BRANDING: OnceLock<BrandingConfig> = OnceLock::new();
|
||||||
|
|
||||||
/// Default platform name
|
|
||||||
const DEFAULT_PLATFORM_NAME: &str = "General Bots";
|
const DEFAULT_PLATFORM_NAME: &str = "General Bots";
|
||||||
const DEFAULT_PLATFORM_SHORT: &str = "GB";
|
const DEFAULT_PLATFORM_SHORT: &str = "GB";
|
||||||
const DEFAULT_PLATFORM_DOMAIN: &str = "generalbots.com";
|
const DEFAULT_PLATFORM_DOMAIN: &str = "generalbots.com";
|
||||||
|
|
||||||
/// Branding configuration loaded from `.product` file
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct BrandingConfig {
|
pub struct BrandingConfig {
|
||||||
/// Platform name (e.g., "`MyCustomPlatform`")
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Short name for logs and compact displays (e.g., "MCP")
|
|
||||||
pub short_name: String,
|
pub short_name: String,
|
||||||
/// Company/organization name
|
|
||||||
pub company: Option<String>,
|
pub company: Option<String>,
|
||||||
/// Domain for URLs and emails
|
|
||||||
pub domain: Option<String>,
|
pub domain: Option<String>,
|
||||||
/// Support email
|
|
||||||
pub support_email: Option<String>,
|
pub support_email: Option<String>,
|
||||||
/// Logo URL (for web UI)
|
|
||||||
pub logo_url: Option<String>,
|
pub logo_url: Option<String>,
|
||||||
/// Favicon URL
|
|
||||||
pub favicon_url: Option<String>,
|
pub favicon_url: Option<String>,
|
||||||
/// Primary color (hex)
|
|
||||||
pub primary_color: Option<String>,
|
pub primary_color: Option<String>,
|
||||||
/// Secondary color (hex)
|
|
||||||
pub secondary_color: Option<String>,
|
pub secondary_color: Option<String>,
|
||||||
/// Footer text
|
|
||||||
pub footer_text: Option<String>,
|
pub footer_text: Option<String>,
|
||||||
/// Copyright text
|
|
||||||
pub copyright: Option<String>,
|
pub copyright: Option<String>,
|
||||||
/// Custom CSS URL
|
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
/// Terms of service URL
|
|
||||||
pub terms_url: Option<String>,
|
pub terms_url: Option<String>,
|
||||||
/// Privacy policy URL
|
|
||||||
pub privacy_url: Option<String>,
|
pub privacy_url: Option<String>,
|
||||||
/// Documentation URL
|
|
||||||
pub docs_url: Option<String>,
|
pub docs_url: Option<String>,
|
||||||
/// Whether this is a white-label deployment
|
|
||||||
pub is_white_label: bool,
|
pub is_white_label: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,7 +57,6 @@ impl Default for BrandingConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BrandingConfig {
|
impl BrandingConfig {
|
||||||
/// Load branding from .product file if it exists
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
let search_paths = [
|
let search_paths = [
|
||||||
|
|
@ -98,7 +73,6 @@ impl BrandingConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check environment variable
|
|
||||||
if let Ok(product_file) = std::env::var("PRODUCT_FILE") {
|
if let Ok(product_file) = std::env::var("PRODUCT_FILE") {
|
||||||
if let Ok(config) = Self::load_from_file(&product_file) {
|
if let Ok(config) = Self::load_from_file(&product_file) {
|
||||||
info!(
|
info!(
|
||||||
|
|
@ -109,7 +83,6 @@ impl BrandingConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for individual environment overrides
|
|
||||||
let mut config = Self::default();
|
let mut config = Self::default();
|
||||||
|
|
||||||
if let Ok(name) = std::env::var("PLATFORM_NAME") {
|
if let Ok(name) = std::env::var("PLATFORM_NAME") {
|
||||||
|
|
@ -135,7 +108,6 @@ impl BrandingConfig {
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load from a specific file path
|
|
||||||
fn load_from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
fn load_from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let path = Path::new(path);
|
let path = Path::new(path);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
|
|
@ -144,12 +116,10 @@ impl BrandingConfig {
|
||||||
|
|
||||||
let content = std::fs::read_to_string(path)?;
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
|
||||||
// Try parsing as TOML first
|
|
||||||
if let Ok(config) = toml::from_str::<ProductFile>(&content) {
|
if let Ok(config) = toml::from_str::<ProductFile>(&content) {
|
||||||
return Ok(config.into());
|
return Ok(config.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try parsing as simple key=value format
|
|
||||||
let mut config = Self {
|
let mut config = Self {
|
||||||
is_white_label: true,
|
is_white_label: true,
|
||||||
..Self::default()
|
..Self::default()
|
||||||
|
|
@ -190,7 +160,6 @@ impl BrandingConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TOML format for .product file
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ProductFile {
|
struct ProductFile {
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -255,39 +224,32 @@ impl From<ProductFile> for BrandingConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global Access Functions
|
|
||||||
|
|
||||||
/// Initialize branding at application startup
|
|
||||||
pub fn init_branding() {
|
pub fn init_branding() {
|
||||||
let config = BrandingConfig::load();
|
let config = BrandingConfig::load();
|
||||||
let _ = BRANDING.set(config);
|
let _ = BRANDING.set(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current branding configuration
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn branding() -> &'static BrandingConfig {
|
pub fn branding() -> &'static BrandingConfig {
|
||||||
BRANDING.get_or_init(BrandingConfig::load)
|
BRANDING.get_or_init(BrandingConfig::load)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the platform name
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn platform_name() -> &'static str {
|
pub fn platform_name() -> &'static str {
|
||||||
&branding().name
|
&branding().name
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the short platform name
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn platform_short() -> &'static str {
|
pub fn platform_short() -> &'static str {
|
||||||
&branding().short_name
|
&branding().short_name
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if this is a white-label deployment
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_white_label() -> bool {
|
pub fn is_white_label() -> bool {
|
||||||
branding().is_white_label
|
branding().is_white_label
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get formatted copyright text
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn copyright_text() -> String {
|
pub fn copyright_text() -> String {
|
||||||
branding().copyright.clone().unwrap_or_else(|| {
|
branding().copyright.clone().unwrap_or_else(|| {
|
||||||
|
|
@ -299,7 +261,6 @@ pub fn copyright_text() -> String {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get footer text
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn footer_text() -> String {
|
pub fn footer_text() -> String {
|
||||||
branding()
|
branding()
|
||||||
|
|
@ -308,7 +269,6 @@ pub fn footer_text() -> String {
|
||||||
.unwrap_or_else(|| format!("Powered by {}", platform_name()))
|
.unwrap_or_else(|| format!("Powered by {}", platform_name()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a log prefix with platform branding
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn log_prefix() -> String {
|
pub fn log_prefix() -> String {
|
||||||
format!("[{}]", platform_short())
|
format!("[{}]", platform_short())
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
//! HTTP client for botserver communication.
|
|
||||||
|
|
||||||
use crate::error::BotError;
|
use crate::error::BotError;
|
||||||
use log::{debug, error};
|
use log::{debug, error};
|
||||||
|
|
@ -9,7 +8,6 @@ use std::time::Duration;
|
||||||
const DEFAULT_BOTSERVER_URL: &str = "https://localhost:8088";
|
const DEFAULT_BOTSERVER_URL: &str = "https://localhost:8088";
|
||||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
/// HTTP client for communicating with the botserver.
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct BotServerClient {
|
pub struct BotServerClient {
|
||||||
client: Arc<reqwest::Client>,
|
client: Arc<reqwest::Client>,
|
||||||
|
|
@ -17,19 +15,11 @@ pub struct BotServerClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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]
|
#[must_use]
|
||||||
pub fn new(base_url: Option<String>) -> Self {
|
pub fn new(base_url: Option<String>) -> Self {
|
||||||
Self::with_timeout(base_url, Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
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]
|
#[must_use]
|
||||||
pub fn with_timeout(base_url: Option<String>, timeout: Duration) -> Self {
|
pub fn with_timeout(base_url: Option<String>, timeout: Duration) -> Self {
|
||||||
let url = base_url.unwrap_or_else(|| {
|
let url = base_url.unwrap_or_else(|| {
|
||||||
|
|
@ -49,17 +39,13 @@ impl BotServerClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the base URL of the client.
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn base_url(&self) -> &str {
|
pub fn base_url(&self) -> &str {
|
||||||
&self.base_url
|
&self.base_url
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a GET request to the specified endpoint.
|
|
||||||
///
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
/// Returns `BotError` if the HTTP request fails or response parsing fails.
|
||||||
/// 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> {
|
pub async fn get<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, BotError> {
|
||||||
let url = format!("{}{endpoint}", self.base_url);
|
let url = format!("{}{endpoint}", self.base_url);
|
||||||
debug!("GET {url}");
|
debug!("GET {url}");
|
||||||
|
|
@ -68,11 +54,8 @@ impl BotServerClient {
|
||||||
self.handle_response(response).await
|
self.handle_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a POST request with a JSON body.
|
|
||||||
///
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
/// Returns `BotError` if the HTTP request fails or response parsing fails.
|
||||||
/// Returns an error if the request fails or the response cannot be parsed.
|
|
||||||
pub async fn post<T: Serialize + Send + Sync, R: DeserializeOwned>(
|
pub async fn post<T: Serialize + Send + Sync, R: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
|
|
@ -85,11 +68,8 @@ impl BotServerClient {
|
||||||
self.handle_response(response).await
|
self.handle_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a PUT request with a JSON body.
|
|
||||||
///
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
/// Returns `BotError` if the HTTP request fails or response parsing fails.
|
||||||
/// Returns an error if the request fails or the response cannot be parsed.
|
|
||||||
pub async fn put<T: Serialize + Send + Sync, R: DeserializeOwned>(
|
pub async fn put<T: Serialize + Send + Sync, R: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
|
|
@ -102,11 +82,8 @@ impl BotServerClient {
|
||||||
self.handle_response(response).await
|
self.handle_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a PATCH request with a JSON body.
|
|
||||||
///
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
/// Returns `BotError` if the HTTP request fails or response parsing fails.
|
||||||
/// Returns an error if the request fails or the response cannot be parsed.
|
|
||||||
pub async fn patch<T: Serialize + Send + Sync, R: DeserializeOwned>(
|
pub async fn patch<T: Serialize + Send + Sync, R: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
|
|
@ -119,11 +96,8 @@ impl BotServerClient {
|
||||||
self.handle_response(response).await
|
self.handle_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a DELETE request to the specified endpoint.
|
|
||||||
///
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
/// Returns `BotError` if the HTTP request fails or response parsing fails.
|
||||||
/// 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> {
|
pub async fn delete<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, BotError> {
|
||||||
let url = format!("{}{endpoint}", self.base_url);
|
let url = format!("{}{endpoint}", self.base_url);
|
||||||
debug!("DELETE {url}");
|
debug!("DELETE {url}");
|
||||||
|
|
@ -132,11 +106,8 @@ impl BotServerClient {
|
||||||
self.handle_response(response).await
|
self.handle_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs an authorized GET request with a bearer token.
|
|
||||||
///
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
/// Returns `BotError` if the HTTP request fails or response parsing fails.
|
||||||
/// Returns an error if the request fails or the response cannot be parsed.
|
|
||||||
pub async fn get_authorized<T: DeserializeOwned>(
|
pub async fn get_authorized<T: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
|
|
@ -149,11 +120,8 @@ impl BotServerClient {
|
||||||
self.handle_response(response).await
|
self.handle_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs an authorized POST request with a bearer token and JSON body.
|
|
||||||
///
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
/// Returns `BotError` if the HTTP request fails or response parsing fails.
|
||||||
/// Returns an error if the request fails or the response cannot be parsed.
|
|
||||||
pub async fn post_authorized<T: Serialize + Send + Sync, R: DeserializeOwned>(
|
pub async fn post_authorized<T: Serialize + Send + Sync, R: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
|
|
@ -173,11 +141,8 @@ impl BotServerClient {
|
||||||
self.handle_response(response).await
|
self.handle_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs an authorized DELETE request with a bearer token.
|
|
||||||
///
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
/// Returns `BotError` if the HTTP request fails or response parsing fails.
|
||||||
/// Returns an error if the request fails or the response cannot be parsed.
|
|
||||||
pub async fn delete_authorized<T: DeserializeOwned>(
|
pub async fn delete_authorized<T: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
|
|
@ -190,9 +155,6 @@ impl BotServerClient {
|
||||||
self.handle_response(response).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 {
|
pub async fn health_check(&self) -> bool {
|
||||||
match self.get::<serde_json::Value>("/health").await {
|
match self.get::<serde_json::Value>("/health").await {
|
||||||
Ok(_) => true,
|
Ok(_) => true,
|
||||||
|
|
|
||||||
10
src/lib.rs
10
src/lib.rs
|
|
@ -1,12 +1,3 @@
|
||||||
//! `BotLib` - Shared library for General Bots
|
|
||||||
//!
|
|
||||||
//! This crate provides common types, utilities, and abstractions
|
|
||||||
//! shared between botserver and botui.
|
|
||||||
//!
|
|
||||||
//! # Features
|
|
||||||
//! - `database` - Database connection utilities (diesel)
|
|
||||||
//! - `http-client` - HTTP client for API calls
|
|
||||||
//! - `validation` - Request validation derive macros
|
|
||||||
|
|
||||||
pub mod branding;
|
pub mod branding;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|
@ -16,7 +7,6 @@ pub mod message_types;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
// Re-exports for convenience
|
|
||||||
pub use branding::{
|
pub use branding::{
|
||||||
branding, init_branding, is_white_label, platform_name, platform_short, BrandingConfig,
|
branding, init_branding, is_white_label, platform_name, platform_short, BrandingConfig,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,21 @@
|
||||||
//! Message type definitions
|
|
||||||
//!
|
|
||||||
//! Defines the different types of messages in the bot system.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Enum representing different types of messages in the bot system
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub struct MessageType(pub i32);
|
pub struct MessageType(pub i32);
|
||||||
|
|
||||||
impl MessageType {
|
impl MessageType {
|
||||||
/// Regular message from external systems (`WhatsApp`, Instagram, etc.)
|
|
||||||
pub const EXTERNAL: Self = Self(0);
|
pub const EXTERNAL: Self = Self(0);
|
||||||
|
|
||||||
/// User message from web interface
|
|
||||||
pub const USER: Self = Self(1);
|
pub const USER: Self = Self(1);
|
||||||
|
|
||||||
/// Bot response (can be regular content or event)
|
|
||||||
pub const BOT_RESPONSE: Self = Self(2);
|
pub const BOT_RESPONSE: Self = Self(2);
|
||||||
|
|
||||||
/// Continue interrupted response
|
|
||||||
pub const CONTINUE: Self = Self(3);
|
pub const CONTINUE: Self = Self(3);
|
||||||
|
|
||||||
/// Suggestion or command message
|
|
||||||
pub const SUGGESTION: Self = Self(4);
|
pub const SUGGESTION: Self = Self(4);
|
||||||
|
|
||||||
/// Context change notification
|
|
||||||
pub const CONTEXT_CHANGE: Self = Self(5);
|
pub const CONTEXT_CHANGE: Self = Self(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
//! API models for bot communication.
|
|
||||||
|
|
||||||
use crate::message_types::MessageType;
|
use crate::message_types::MessageType;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
@ -119,7 +118,7 @@ impl Session {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_expiry(mut self, expires_at: DateTime<Utc>) -> Self {
|
pub const fn with_expiry(mut self, expires_at: DateTime<Utc>) -> Self {
|
||||||
self.expires_at = Some(expires_at);
|
self.expires_at = Some(expires_at);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
@ -346,7 +345,7 @@ impl BotResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn complete(mut self) -> Self {
|
pub const fn complete(mut self) -> Self {
|
||||||
self.is_complete = true;
|
self.is_complete = true;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
@ -357,7 +356,7 @@ impl BotResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn has_suggestions(&self) -> bool {
|
pub const fn has_suggestions(&self) -> bool {
|
||||||
!self.suggestions.is_empty()
|
!self.suggestions.is_empty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,3 @@
|
||||||
//! Resilience Module - Production-grade fault tolerance primitives
|
|
||||||
//!
|
|
||||||
//! This module provides battle-tested resilience patterns:
|
|
||||||
//! - Retry with exponential backoff and jitter
|
|
||||||
//! - Circuit breaker with half-open state
|
|
||||||
//! - Timeout wrappers
|
|
||||||
//! - Bulkhead isolation
|
|
||||||
//! - Fallback chains
|
|
||||||
//!
|
|
||||||
//! # Design Principles
|
|
||||||
//! - Zero-cost abstractions where possible
|
|
||||||
//! - No panics - all errors are recoverable
|
|
||||||
//! - Composable patterns
|
|
||||||
//! - Observable state for metrics
|
|
||||||
|
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||||
|
|
@ -20,21 +6,15 @@ use std::time::{Duration, Instant};
|
||||||
use tokio::sync::{RwLock, Semaphore, SemaphorePermit};
|
use tokio::sync::{RwLock, Semaphore, SemaphorePermit};
|
||||||
use tokio::time::{sleep, timeout};
|
use tokio::time::{sleep, timeout};
|
||||||
|
|
||||||
/// Errors that can occur during resilient operations
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ResilienceError {
|
pub enum ResilienceError {
|
||||||
/// Operation timed out
|
|
||||||
Timeout { duration: Duration },
|
Timeout { duration: Duration },
|
||||||
/// Circuit breaker is open, rejecting requests
|
|
||||||
CircuitOpen { until: Option<Duration> },
|
CircuitOpen { until: Option<Duration> },
|
||||||
/// All retry attempts exhausted
|
|
||||||
RetriesExhausted {
|
RetriesExhausted {
|
||||||
attempts: u32,
|
attempts: u32,
|
||||||
last_error: String,
|
last_error: String,
|
||||||
},
|
},
|
||||||
/// Bulkhead rejected request (too many concurrent)
|
|
||||||
BulkheadFull { max_concurrent: usize },
|
BulkheadFull { max_concurrent: usize },
|
||||||
/// Wrapped error from the underlying operation
|
|
||||||
Operation(String),
|
Operation(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,24 +55,14 @@ impl std::fmt::Display for ResilienceError {
|
||||||
|
|
||||||
impl std::error::Error for ResilienceError {}
|
impl std::error::Error for ResilienceError {}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Retry Configuration and Execution
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Retry strategy configuration
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RetryConfig {
|
pub struct RetryConfig {
|
||||||
/// Maximum number of attempts (including the first one)
|
|
||||||
pub max_attempts: u32,
|
pub max_attempts: u32,
|
||||||
/// Initial delay between retries
|
|
||||||
pub initial_delay: Duration,
|
pub initial_delay: Duration,
|
||||||
/// Maximum delay between retries
|
|
||||||
pub max_delay: Duration,
|
pub max_delay: Duration,
|
||||||
/// Multiplier for exponential backoff (typically 2.0)
|
|
||||||
pub backoff_multiplier: f64,
|
pub backoff_multiplier: f64,
|
||||||
/// Add random jitter to prevent thundering herd (0.0 to 1.0)
|
|
||||||
pub jitter_factor: f64,
|
pub jitter_factor: f64,
|
||||||
/// Predicate to determine if error is retryable
|
|
||||||
retryable: Option<Arc<dyn Fn(&str) -> bool + Send + Sync>>,
|
retryable: Option<Arc<dyn Fn(&str) -> bool + Send + Sync>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,37 +80,31 @@ impl Default for RetryConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RetryConfig {
|
impl RetryConfig {
|
||||||
/// Create a new retry config with custom max attempts
|
|
||||||
pub fn with_max_attempts(mut self, attempts: u32) -> Self {
|
pub fn with_max_attempts(mut self, attempts: u32) -> Self {
|
||||||
self.max_attempts = attempts.max(1);
|
self.max_attempts = attempts.max(1);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set initial delay
|
|
||||||
pub fn with_initial_delay(mut self, delay: Duration) -> Self {
|
pub fn with_initial_delay(mut self, delay: Duration) -> Self {
|
||||||
self.initial_delay = delay;
|
self.initial_delay = delay;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set maximum delay cap
|
|
||||||
pub fn with_max_delay(mut self, delay: Duration) -> Self {
|
pub fn with_max_delay(mut self, delay: Duration) -> Self {
|
||||||
self.max_delay = delay;
|
self.max_delay = delay;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set backoff multiplier
|
|
||||||
pub fn with_backoff_multiplier(mut self, multiplier: f64) -> Self {
|
pub fn with_backoff_multiplier(mut self, multiplier: f64) -> Self {
|
||||||
self.backoff_multiplier = multiplier.max(1.0);
|
self.backoff_multiplier = multiplier.max(1.0);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set jitter factor (0.0 to 1.0)
|
|
||||||
pub fn with_jitter(mut self, jitter: f64) -> Self {
|
pub fn with_jitter(mut self, jitter: f64) -> Self {
|
||||||
self.jitter_factor = jitter.clamp(0.0, 1.0);
|
self.jitter_factor = jitter.clamp(0.0, 1.0);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set custom retryable predicate
|
|
||||||
pub fn with_retryable<F>(mut self, predicate: F) -> Self
|
pub fn with_retryable<F>(mut self, predicate: F) -> Self
|
||||||
where
|
where
|
||||||
F: Fn(&str) -> bool + Send + Sync + 'static,
|
F: Fn(&str) -> bool + Send + Sync + 'static,
|
||||||
|
|
@ -149,7 +113,6 @@ impl RetryConfig {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aggressive retry for critical operations
|
|
||||||
pub fn aggressive() -> Self {
|
pub fn aggressive() -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_attempts: 5,
|
max_attempts: 5,
|
||||||
|
|
@ -161,7 +124,6 @@ impl RetryConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Conservative retry for non-critical operations
|
|
||||||
pub fn conservative() -> Self {
|
pub fn conservative() -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_attempts: 2,
|
max_attempts: 2,
|
||||||
|
|
@ -173,17 +135,14 @@ impl RetryConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate delay for a given attempt number
|
|
||||||
fn calculate_delay(&self, attempt: u32) -> Duration {
|
fn calculate_delay(&self, attempt: u32) -> Duration {
|
||||||
let base_delay = self.initial_delay.as_secs_f64()
|
let base_delay = self.initial_delay.as_secs_f64()
|
||||||
* self.backoff_multiplier.powi(attempt.saturating_sub(1) as i32);
|
* self.backoff_multiplier.powi(attempt.saturating_sub(1) as i32);
|
||||||
|
|
||||||
let capped_delay = base_delay.min(self.max_delay.as_secs_f64());
|
let capped_delay = base_delay.min(self.max_delay.as_secs_f64());
|
||||||
|
|
||||||
// Add jitter
|
|
||||||
let jitter = if self.jitter_factor > 0.0 {
|
let jitter = if self.jitter_factor > 0.0 {
|
||||||
let jitter_range = capped_delay * self.jitter_factor;
|
let jitter_range = capped_delay * self.jitter_factor;
|
||||||
// Simple deterministic "random" based on attempt number
|
|
||||||
let pseudo_random = ((attempt as f64 * 1.618033988749895) % 1.0) * 2.0 - 1.0;
|
let pseudo_random = ((attempt as f64 * 1.618033988749895) % 1.0) * 2.0 - 1.0;
|
||||||
jitter_range * pseudo_random
|
jitter_range * pseudo_random
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -193,9 +152,7 @@ impl RetryConfig {
|
||||||
Duration::from_secs_f64((capped_delay + jitter).max(0.001))
|
Duration::from_secs_f64((capped_delay + jitter).max(0.001))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an error is retryable
|
|
||||||
fn is_retryable(&self, error: &str) -> bool {
|
fn is_retryable(&self, error: &str) -> bool {
|
||||||
if let Some(ref predicate) = self.retryable {
|
if let Some(ref predicate) = self.retryable {
|
||||||
predicate(error)
|
predicate(error)
|
||||||
} else {
|
} else {
|
||||||
//
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
//! Version Tracking Module
|
|
||||||
//!
|
|
||||||
//! Tracks versions of all components and checks for updates.
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use log::debug;
|
use log::debug;
|
||||||
|
|
@ -8,35 +5,23 @@ use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
|
||||||
/// Global version registry
|
|
||||||
static VERSION_REGISTRY: RwLock<Option<VersionRegistry>> = RwLock::new(None);
|
static VERSION_REGISTRY: RwLock<Option<VersionRegistry>> = RwLock::new(None);
|
||||||
|
|
||||||
/// Current botserver version from Cargo.toml
|
|
||||||
pub const BOTSERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
pub const BOTSERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
pub const BOTSERVER_NAME: &str = env!("CARGO_PKG_NAME");
|
pub const BOTSERVER_NAME: &str = env!("CARGO_PKG_NAME");
|
||||||
|
|
||||||
/// Component version information
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ComponentVersion {
|
pub struct ComponentVersion {
|
||||||
/// Component name
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Current installed version
|
|
||||||
pub version: String,
|
pub version: String,
|
||||||
/// Latest available version (if known)
|
|
||||||
pub latest_version: Option<String>,
|
pub latest_version: Option<String>,
|
||||||
/// Whether an update is available
|
|
||||||
pub update_available: bool,
|
pub update_available: bool,
|
||||||
/// Component status
|
|
||||||
pub status: ComponentStatus,
|
pub status: ComponentStatus,
|
||||||
/// Last check time
|
|
||||||
pub last_checked: Option<DateTime<Utc>>,
|
pub last_checked: Option<DateTime<Utc>>,
|
||||||
/// Source/origin of the component
|
|
||||||
pub source: ComponentSource,
|
pub source: ComponentSource,
|
||||||
/// Additional metadata
|
|
||||||
pub metadata: HashMap<String, String>,
|
pub metadata: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Component status
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub enum ComponentStatus {
|
pub enum ComponentStatus {
|
||||||
Running,
|
Running,
|
||||||
|
|
@ -60,7 +45,6 @@ impl std::fmt::Display for ComponentStatus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Component source type
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub enum ComponentSource {
|
pub enum ComponentSource {
|
||||||
Builtin,
|
Builtin,
|
||||||
|
|
@ -84,7 +68,6 @@ impl std::fmt::Display for ComponentSource {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Version registry holding all component versions
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct VersionRegistry {
|
pub struct VersionRegistry {
|
||||||
pub core_version: String,
|
pub core_version: String,
|
||||||
|
|
@ -105,7 +88,6 @@ impl Default for VersionRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VersionRegistry {
|
impl VersionRegistry {
|
||||||
/// Create a new version registry
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let mut registry = Self::default();
|
let mut registry = Self::default();
|
||||||
|
|
@ -113,7 +95,6 @@ impl VersionRegistry {
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register built-in components
|
|
||||||
fn register_builtin_components(&mut self) {
|
fn register_builtin_components(&mut self) {
|
||||||
self.register_component(ComponentVersion {
|
self.register_component(ComponentVersion {
|
||||||
name: "botserver".to_string(),
|
name: "botserver".to_string(),
|
||||||
|
|
@ -161,7 +142,6 @@ impl VersionRegistry {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a component
|
|
||||||
pub fn register_component(&mut self, component: ComponentVersion) {
|
pub fn register_component(&mut self, component: ComponentVersion) {
|
||||||
debug!(
|
debug!(
|
||||||
"Registered component: {} v{}",
|
"Registered component: {} v{}",
|
||||||
|
|
@ -170,14 +150,12 @@ impl VersionRegistry {
|
||||||
self.components.insert(component.name.clone(), component);
|
self.components.insert(component.name.clone(), component);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update component status
|
|
||||||
pub fn update_status(&mut self, name: &str, status: ComponentStatus) {
|
pub fn update_status(&mut self, name: &str, status: ComponentStatus) {
|
||||||
if let Some(component) = self.components.get_mut(name) {
|
if let Some(component) = self.components.get_mut(name) {
|
||||||
component.status = status;
|
component.status = status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update component version
|
|
||||||
pub fn update_version(&mut self, name: &str, version: String) {
|
pub fn update_version(&mut self, name: &str, version: String) {
|
||||||
if let Some(component) = self.components.get_mut(name) {
|
if let Some(component) = self.components.get_mut(name) {
|
||||||
component.version = version;
|
component.version = version;
|
||||||
|
|
@ -185,19 +163,16 @@ impl VersionRegistry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get component by name
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn get_component(&self, name: &str) -> Option<&ComponentVersion> {
|
pub fn get_component(&self, name: &str) -> Option<&ComponentVersion> {
|
||||||
self.components.get(name)
|
self.components.get(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all components
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn get_all_components(&self) -> &HashMap<String, ComponentVersion> {
|
pub const fn get_all_components(&self) -> &HashMap<String, ComponentVersion> {
|
||||||
&self.components
|
&self.components
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get components with available updates
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn get_available_updates(&self) -> Vec<&ComponentVersion> {
|
pub fn get_available_updates(&self) -> Vec<&ComponentVersion> {
|
||||||
self.components
|
self.components
|
||||||
|
|
@ -206,7 +181,6 @@ impl VersionRegistry {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get summary of all components
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn summary(&self) -> String {
|
pub fn summary(&self) -> String {
|
||||||
let running = self
|
let running = self
|
||||||
|
|
@ -223,19 +197,14 @@ impl VersionRegistry {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get summary as JSON
|
|
||||||
///
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
/// Returns `serde_json::Error` if serialization fails.
|
||||||
/// Returns an error if JSON serialization fails.
|
|
||||||
pub fn to_json(&self) -> Result<String, serde_json::Error> {
|
pub fn to_json(&self) -> Result<String, serde_json::Error> {
|
||||||
serde_json::to_string_pretty(self)
|
serde_json::to_string_pretty(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global Access Functions
|
|
||||||
|
|
||||||
/// Initialize version registry at startup
|
|
||||||
pub fn init_version_registry() {
|
pub fn init_version_registry() {
|
||||||
let registry = VersionRegistry::new();
|
let registry = VersionRegistry::new();
|
||||||
if let Ok(mut guard) = VERSION_REGISTRY.write() {
|
if let Ok(mut guard) = VERSION_REGISTRY.write() {
|
||||||
|
|
@ -243,19 +212,16 @@ pub fn init_version_registry() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get version registry (read-only)
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn version_registry() -> Option<VersionRegistry> {
|
pub fn version_registry() -> Option<VersionRegistry> {
|
||||||
VERSION_REGISTRY.read().ok()?.clone()
|
VERSION_REGISTRY.read().ok()?.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get mutable version registry
|
|
||||||
pub fn version_registry_mut(
|
pub fn version_registry_mut(
|
||||||
) -> Option<std::sync::RwLockWriteGuard<'static, Option<VersionRegistry>>> {
|
) -> Option<std::sync::RwLockWriteGuard<'static, Option<VersionRegistry>>> {
|
||||||
VERSION_REGISTRY.write().ok()
|
VERSION_REGISTRY.write().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a component
|
|
||||||
pub fn register_component(component: ComponentVersion) {
|
pub fn register_component(component: ComponentVersion) {
|
||||||
if let Ok(mut guard) = VERSION_REGISTRY.write() {
|
if let Ok(mut guard) = VERSION_REGISTRY.write() {
|
||||||
if let Some(ref mut registry) = *guard {
|
if let Some(ref mut registry) = *guard {
|
||||||
|
|
@ -264,7 +230,6 @@ pub fn register_component(component: ComponentVersion) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update component status
|
|
||||||
pub fn update_component_status(name: &str, status: ComponentStatus) {
|
pub fn update_component_status(name: &str, status: ComponentStatus) {
|
||||||
if let Ok(mut guard) = VERSION_REGISTRY.write() {
|
if let Ok(mut guard) = VERSION_REGISTRY.write() {
|
||||||
if let Some(ref mut registry) = *guard {
|
if let Some(ref mut registry) = *guard {
|
||||||
|
|
@ -273,7 +238,6 @@ pub fn update_component_status(name: &str, status: ComponentStatus) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get component version
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn get_component_version(name: &str) -> Option<ComponentVersion> {
|
pub fn get_component_version(name: &str) -> Option<ComponentVersion> {
|
||||||
VERSION_REGISTRY
|
VERSION_REGISTRY
|
||||||
|
|
@ -284,13 +248,11 @@ pub fn get_component_version(name: &str) -> Option<ComponentVersion> {
|
||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get botserver version
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn get_botserver_version() -> &'static str {
|
pub const fn get_botserver_version() -> &'static str {
|
||||||
BOTSERVER_VERSION
|
BOTSERVER_VERSION
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get version string for display
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn version_string() -> String {
|
pub fn version_string() -> String {
|
||||||
format!("{BOTSERVER_NAME} v{BOTSERVER_VERSION}")
|
format!("{BOTSERVER_NAME} v{BOTSERVER_VERSION}")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue