bhcli

A TUI for chatting on LE PHP Chats
git clone https://git.dasho.dev/bhcli.git
Log | Files | Refs | README

commit 2e1b66801d530d61fca8b6c2fb64a1d5e5ff493a
parent 867c3706ee5a599a56a122acae89f29d264cca5a
Author: Dasho <git@dasho.dev>
Date:   Sun,  3 Aug 2025 02:49:04 +0100

Enhances ChatOps functionality with developer tools

Introduces extensive ChatOps commands for developer operations, including documentation lookups, GitHub integration, network diagnostics, and AI assistance. Implements comprehensive command routing, registration, and execution processes. Updates Cargo dependencies to support new features and adds role-based command access control. Extends UI for multiline editing and command history navigation.

These changes improve user experience and broaden functional capabilities.

Diffstat:
MCargo.lock | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 6++++++
MREADME.md | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/chatops/command_router.rs | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/chatops/commands/ai.rs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/chatops/commands/chat.rs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/chatops/commands/doc.rs | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/chatops/commands/github.rs | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/chatops/commands/misc.rs | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/chatops/commands/mod.rs | 7+++++++
Asrc/chatops/commands/network.rs | 338+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/chatops/commands/tools.rs | 355+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/chatops/mod.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/chatops/registry.rs | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/chatops/result.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 1058+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
16 files changed, 3325 insertions(+), 104 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -263,6 +263,7 @@ dependencies = [ "linkify", "log", "log4rs", + "md5", "rand 0.8.5", "regex", "reqwest 0.11.27", @@ -272,12 +273,16 @@ dependencies = [ "serde", "serde_derive", "serde_json", + "sha1", + "sha2", + "tempfile", "termage", "textwrap 0.16.2", "tokio", "toml 0.7.8", "tui", "unicode-width 0.1.14", + "uuid", ] [[package]] @@ -338,6 +343,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" [[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] name = "bresenham" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -657,6 +671,15 @@ dependencies = [ ] [[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] name = "crc32fast" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -769,6 +792,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] name = "darling" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -877,6 +910,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c877555693c14d2f84191cfd3ad8582790fc52b5e2274b40b59cf5f5cea25c7" [[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] name = "directories" version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1125,6 +1168,16 @@ dependencies = [ ] [[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] name = "getrandom" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1929,6 +1982,12 @@ dependencies = [ ] [[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] name = "memchr" version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3101,6 +3160,28 @@ dependencies = [ ] [[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3805,6 +3886,12 @@ dependencies = [ ] [[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] name = "unicase" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3912,6 +3999,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] +name = "uuid" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -41,3 +41,9 @@ tui = { version = "0.19.0", features = ["crossterm"], default-features = false unicode-width = "0.1.8" async-openai = "0.29.0" tokio = { version = "1.0", features = ["full"] } +# ChatOps dependencies +uuid = { version = "1.0", features = ["v4"] } +md5 = "0.7" +sha1 = "0.10" +sha2 = "0.10" +tempfile = "3.10" diff --git a/README.md b/README.md @@ -16,6 +16,7 @@ Pre-buit binaries can be found on the [official website](http://git.dkforestseea ## Features +- **ChatOps Integration**: 30+ developer-focused slash commands for documentation lookup, development tools, GitHub integration, network diagnostics, and AI assistance - Sound notifications when tagged/pmmed - Private messages `/pm username message` - Kick someone `/kick username message` | `/k username message` (Members +) @@ -60,11 +61,163 @@ Pre-buit binaries can be found on the [official website](http://git.dkforestseea - Custom personal command creation for members+ [ read Command Creation ] - Set alternate and master accounts per profile using `/set alt <username>` and `/set master <username>` +## ChatOps Commands + +BHCLI includes a comprehensive ChatOps system with 30+ developer-focused commands across multiple categories. These commands provide quick access to documentation, development tools, network diagnostics, and integrations. + +### 📖 Documentation & Lookup Commands + +Get instant access to documentation and references: + +- `/help` - Show all available ChatOps commands with descriptions +- `/man <command>` - Display manual pages for system commands (e.g., `/man curl`) +- `/doc <language> <term>` - Language-specific documentation lookup (e.g., `/doc rust HashMap`) +- `/explain <concept>` - AI-powered explanations of programming concepts +- `/cheat <tool>` - Quick reference cheatsheets for common tools +- `/stackoverflow <query>` - Search Stack Overflow for programming questions +- `/ref <language>` - Quick access to language reference documentation + +### 🔧 Tooling & Utilities Commands + +Essential development utilities and tools: + +- `/hash <algorithm> <text>` - Generate cryptographic hashes (MD5, SHA1, SHA256, SHA512) + - Example: `/hash sha256 hello world` +- `/uuid` - Generate a new UUID v4 +- `/base64 <encode|decode> <text>` - Base64 encoding and decoding + - Example: `/base64 encode "hello world"` +- `/regex <pattern> <text>` - Test regular expressions and see matches + - Example: `/regex "\d+" "abc 123 def"` +- `/whois <domain>` - Domain WHOIS lookup for registration information +- `/dig <domain>` - DNS record lookup and resolution +- `/ipinfo <ip>` - Get detailed information about IP addresses +- `/rand <min> <max>` - Generate random numbers within range +- `/time` - Display current timestamp and timezone information + +### 💬 Chat Linking & Session Intelligence + +Enhanced chat functionality and user management: + +- `/chatlink <message_id>` - Create shareable links to specific chat messages +- `/quote <user> <message>` - Quote and reference messages from other users +- `/rooms` - List all available chat rooms and their status +- `/whereis <user>` - Find which rooms a user is currently in + +### 🤖 AI Integration Commands + +Leverage AI for development assistance: + +- `/summarize <text>` - AI-powered text summarization +- `/translate <language> <text>` - Translate text between languages +- `/fix <code>` - Get AI suggestions for fixing code issues +- `/review <code>` - AI-powered code quality review and suggestions + +### 🐙 GitHub & Package Management + +Repository and package information at your fingertips: + +#### GitHub Integration +- `/github <user/repo>` (alias: `/gh`) - Get repository information and links + - `/github user/repo` - Show basic repository info + - `/github user/repo issues` - Direct link to issues + - `/github user/repo latest` - Link to latest release + - `/github user/repo file <path>` - Link to specific file +- `/gist <code>` - Create GitHub Gists (requires GitHub CLI authentication) + +#### Package Managers +- `/crates <crate_name>` - Rust crate information from crates.io +- `/npm <package_name>` - NPM package information and installation commands +- `/pip <package_name>` (alias: `/pypi`) - Python package info from PyPI + +### 🌐 Network Diagnostics + +Network troubleshooting and connectivity tools: + +- `/ping <host>` - Test network connectivity and response times +- `/traceroute <host>` - Trace network path to destination +- `/nslookup <domain>` - DNS name resolution and record lookup +- `/netstat` - Display active network connections and listening ports + +### ⚙️ Miscellaneous Commands + +Additional utility commands: + +- `/alias <name> <command>` - Create personal command aliases + - `/alias list` - Show all your aliases + - `/alias remove <name>` - Remove an alias +- `/version` - Display BHCLI version and system information + +### Command Usage Examples + +```bash +# Documentation lookups +/help # Show all commands +/man grep # Manual page for grep +/doc rust Vec # Rust documentation for Vec + +# Development tools +/hash sha256 "my secret" # Generate SHA256 hash +/uuid # Generate new UUID +/base64 encode "hello world" # Base64 encode text +/regex "\d+" "abc 123 def" # Test regex pattern + +# GitHub integration +/github rust-lang/rust # Get Rust repository info +/github microsoft/vscode issues # Link to VS Code issues +/crates serde # Info about serde crate +/npm express # Info about Express.js package + +# Network diagnostics +/ping google.com # Ping Google +/dig example.com # DNS lookup +/whois github.com # Domain registration info + +# AI assistance +/translate spanish "Hello world" # Translate to Spanish +/summarize "long text here..." # Summarize text +/fix "broken code here" # Get code fix suggestions +``` + +### Command Permissions + +ChatOps commands respect user roles and permissions: + +- **Guest**: Access to documentation, basic tools, and read-only commands +- **Member**: Full access to all ChatOps commands +- **Staff/Admin**: Complete access plus any future administrative commands + +### Command Help System + +Each command includes built-in help: +- Use `/help` to see all available commands +- Use `/help <command>` to get detailed usage information for specific commands +- Commands show usage hints when used incorrectly + +### Extending ChatOps + +The ChatOps system is built with extensibility in mind. New commands can be easily added by implementing the `ChatCommand` trait. The modular architecture supports: + +- Custom command categories +- Alias support for commands +- Role-based permission checking +- Structured result formatting +- Error handling and user feedback + ### Editing mode - `ctrl+A` Move cursor to start of line - `ctrl+E` Move cursor to end of line - `ctrl+F` Move cursor a word forward - `ctrl+B` Move cursor a word backward +- `ctrl+.`, `ctrl+X`, or `ctrl+O` Open external editor (nvim/vim/nano) - automatically sends message after editing +- `ctrl+L` Toggle multiline input mode (in multiline mode, use `ctrl+Enter` to send, `Enter` for newline) +- `Up/Down arrows` Navigate through command history (filters by current input prefix) + +### Multiline Input Mode +- `Enter` Insert newline +- `ctrl+Enter` Send message +- `ctrl+L` Toggle back to single-line mode +- `Up/Down arrows` Navigate through command history +- `Escape` Exit to normal mode ### Messages navigation - Page down the messages list `ctrl+D` | `page down` diff --git a/src/chatops/command_router.rs b/src/chatops/command_router.rs @@ -0,0 +1,211 @@ +use crate::chatops::{CommandRegistry, CommandContext, UserRole, ChatOpResult}; + +/// Main router for handling ChatOps commands +pub struct ChatOpsRouter { + registry: CommandRegistry, +} + +impl ChatOpsRouter { + pub fn new() -> Self { + Self { + registry: crate::chatops::init_chatops(), + } + } + + /// Process a slash command input + pub fn process_command( + &self, + input: &str, + username: &str, + role: UserRole, + ) -> Option<ChatOpResult> { + // Skip if not a chatops command (let existing system handle it) + if !self.is_chatops_command(input) { + return None; + } + + let parts = self.parse_command(input); + if parts.is_empty() { + return Some(ChatOpResult::Error("Empty command".to_string())); + } + + let command_name = &parts[0]; + let args = parts[1..].to_vec(); + + // Handle special built-in commands + match command_name.as_str() { + "chatops" => return Some(self.handle_help_command(vec![], &role)), + "commands" | "list" => return Some(self.handle_list_commands(&role)), + "help" => { + // Only handle specific chatops help, let general help fall through + if !args.is_empty() && self.registry.get_help(&args[0]).is_some() { + return Some(self.handle_help_command(args, &role)); + } + // Let general /help fall through to main help system + return None; + } + _ => {} + } + + let context = CommandContext { + username: username.to_string(), + room: None, // TODO: Extract from app state if needed + role, + }; + + match self.registry.execute_command(command_name, args, &context) { + Ok(result) => { + // Truncate long results to prevent chat spam but allow more context + let truncated = if result.should_truncate(40) { + result.truncate(40) + } else { + result + }; + Some(truncated) + } + Err(error) => Some(ChatOpResult::Error(error.to_string())), + } + } + + /// Check if this is a chatops command (vs existing system command) + fn is_chatops_command(&self, input: &str) -> bool { + if !input.starts_with('/') { + return false; + } + + let parts = self.parse_command(input); + if parts.is_empty() { + return false; + } + + let command_name = &parts[0]; + + // Built-in chatops commands + if matches!(command_name.as_str(), "help" | "commands" | "list") { + return true; + } + + // Check if it's a registered chatops command + self.registry.get_command(command_name).is_some() + } + + /// Parse command input into parts + fn parse_command(&self, input: &str) -> Vec<String> { + let input = input.trim_start_matches('/'); + + // Simple parsing - split on whitespace but respect quotes + let mut parts = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '"' if !in_quotes => { + in_quotes = true; + } + '"' if in_quotes => { + in_quotes = false; + if !current.is_empty() { + parts.push(current.clone()); + current.clear(); + } + } + ' ' | '\t' if !in_quotes => { + if !current.is_empty() { + parts.push(current.clone()); + current.clear(); + } + } + _ => { + current.push(ch); + } + } + } + + if !current.is_empty() { + parts.push(current); + } + + parts + } + + /// Handle help command + fn handle_help_command(&self, args: Vec<String>, _role: &UserRole) -> ChatOpResult { + if args.is_empty() { + // General ChatOps help - return as single message for better full-screen display + let help_text = vec![ + "🤖 **ChatOps Developer Commands Available:**", + "", + "**Documentation & Lookup:**", + "/man <command> - Manual pages", + "/doc <lang> <term> - Language docs", + "/explain <topic> - Explain concepts", + "/cheat <term> - Cheat sheets", + "/so <query> - StackOverflow search", + "", + "**Tools & Utilities:**", + "/hash <algo> <text> - Hash functions", + "/uuid - Generate UUID", + "/base64 <encode|decode> <text>", + "/regex <pattern> <text> - Test regex", + "/time - Current time", + "", + "**Network Tools:**", + "/whois <domain> - Domain lookup", + "/dig <domain> - DNS lookup", + "/ping <host> - Ping host", + "/headers <url> - HTTP headers", + "", + "**GitHub Integration:**", + "/github <user>/<repo> - Repo info", + "/crates <crate> - Rust crate info", + "/npm <package> - NPM package info", + "", + "Use `/help <command>` for help on specific ChatOps commands.", + "Use `/commands` to list available commands for your role.", + "Use `/help` for general chat commands and shortcuts.", + ].join("\n"); + + ChatOpResult::Message(help_text) + } else { + // Specific command help + let command_name = &args[0]; + match self.registry.get_help(command_name) { + Some(help) => ChatOpResult::Message(help), + None => ChatOpResult::Error(format!("No help available for command: {}", command_name)), + } + } + } + + /// Handle list commands + fn handle_list_commands(&self, role: &UserRole) -> ChatOpResult { + let commands = self.registry.list_commands(role); + if commands.is_empty() { + return ChatOpResult::Message("No commands available for your role.".to_string()); + } + + let mut output = vec![ + format!("📋 **Available Commands ({} total):**", commands.len()), + "".to_string(), + ]; + + for (name, description) in commands { + output.push(format!("**{}** - {}", name, description)); + } + + ChatOpResult::Block(output) + } + + /// Register a user alias + #[allow(dead_code)] + pub fn register_user_alias(&mut self, alias: String, target: String) { + self.registry.register_alias(alias, target); + } + + /// Remove a user alias + #[allow(dead_code)] + pub fn remove_user_alias(&mut self, alias: &str) { + self.registry.remove_alias(alias); + } +} diff --git a/src/chatops/commands/ai.rs b/src/chatops/commands/ai.rs @@ -0,0 +1,135 @@ +use crate::chatops::{ChatCommand, CommandContext, ChatOpResult, ChatOpError}; + +/// AI-powered message summarization +pub struct SummarizeCommand; + +impl ChatCommand for SummarizeCommand { + fn name(&self) -> &'static str { "summarize" } + fn description(&self) -> &'static str { "Summarize a long message (AI)" } + fn usage(&self) -> &'static str { "/summarize <msg_id>" } + fn aliases(&self) -> Vec<&'static str> { vec!["tldr"] } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a message ID".to_string())); + } + + let msg_id = &args[0]; + + // In a real implementation, you'd: + // 1. Fetch the message content + // 2. Send it to AI for summarization + // 3. Return the summary + + Ok(ChatOpResult::Message(format!("🤖 AI Summary of message #{}: [This feature requires AI integration to be fully implemented]", msg_id))) + } +} + +/// Translation command +pub struct TranslateCommand; + +impl ChatCommand for TranslateCommand { + fn name(&self) -> &'static str { "translate" } + fn description(&self) -> &'static str { "Translate text to another language" } + fn usage(&self) -> &'static str { "/translate <target_lang> <text>" } + fn aliases(&self) -> Vec<&'static str> { vec!["tr"] } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.len() < 2 { + return Err(ChatOpError::MissingArguments("Please specify target language and text".to_string())); + } + + let target_lang = &args[0]; + let text = args[1..].join(" "); + + // Try using the `trans` command if available (like in the original translate function) + match std::process::Command::new("trans") + .arg("-b") + .arg("-t") + .arg(target_lang) + .arg(&text) + .output() + { + Ok(output) => { + if output.status.success() { + let translated = String::from_utf8_lossy(&output.stdout); + Ok(ChatOpResult::Message(format!("🌐 Translation to {}: {}", target_lang, translated.trim()))) + } else { + Ok(ChatOpResult::Message(format!("🌐 Translation failed. Try: https://translate.google.com"))) + } + } + Err(_) => { + // Fallback to Google Translate link + let encoded_text = text.replace(" ", "%20"); + Ok(ChatOpResult::Message(format!("🌐 Translate '{}' to {}: https://translate.google.com/?sl=auto&tl={}&text={}", + text, target_lang, target_lang, encoded_text))) + } + } + } +} + +/// Code fixing command +pub struct FixCommand; + +impl ChatCommand for FixCommand { + fn name(&self) -> &'static str { "fix" } + fn description(&self) -> &'static str { "Attempt to fix code issues (AI)" } + fn usage(&self) -> &'static str { "/fix <code>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify code to fix".to_string())); + } + + let code = args.join(" "); + + // Basic syntax checking for common languages + if code.contains("fn ") && code.contains("rust") { + Ok(ChatOpResult::Message("🔧 Rust code detected. Consider: cargo check, clippy suggestions, or proper error handling.".to_string())) + } else if code.contains("def ") && code.contains("python") { + Ok(ChatOpResult::Message("🔧 Python code detected. Consider: syntax validation, PEP 8 formatting, or type hints.".to_string())) + } else { + Ok(ChatOpResult::Message("🔧 Code fix suggestions: Check syntax, indentation, variable names, and error handling. [Full AI code review requires integration]".to_string())) + } + } +} + +/// Code review command +pub struct ReviewCommand; + +impl ChatCommand for ReviewCommand { + fn name(&self) -> &'static str { "review" } + fn description(&self) -> &'static str { "Get code review comments (AI)" } + fn usage(&self) -> &'static str { "/review <code>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify code to review".to_string())); + } + + let code = args.join(" "); + + // Basic code review suggestions + let mut suggestions = vec!["📝 **Code Review Suggestions:**".to_string()]; + + if code.len() > 200 { + suggestions.push("• Consider breaking this into smaller functions".to_string()); + } + + if code.contains("TODO") || code.contains("FIXME") { + suggestions.push("• Address TODO/FIXME comments before production".to_string()); + } + + if !code.contains("//") && !code.contains("#") && !code.contains("/*") { + suggestions.push("• Add comments explaining complex logic".to_string()); + } + + if code.contains("panic!") || code.contains("unwrap()") { + suggestions.push("• Consider proper error handling instead of panicking".to_string()); + } + + suggestions.push("• [Full AI code review requires integration with language models]".to_string()); + + Ok(ChatOpResult::Block(suggestions)) + } +} diff --git a/src/chatops/commands/chat.rs b/src/chatops/commands/chat.rs @@ -0,0 +1,83 @@ +use crate::chatops::{ChatCommand, CommandContext, ChatOpResult, ChatOpError}; + +/// Chat linking command +pub struct ChatLinkCommand; + +impl ChatCommand for ChatLinkCommand { + fn name(&self) -> &'static str { "chatlink" } + fn description(&self) -> &'static str { "Link to another user's message" } + fn usage(&self) -> &'static str { "/chatlink @user <msg_id>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.len() < 2 { + return Err(ChatOpError::MissingArguments("Please specify user and message ID".to_string())); + } + + let user = &args[0]; + let msg_id = &args[1]; + + // In a real implementation, you'd look up the message in the database + Ok(ChatOpResult::Message(format!("🔗 Link to {}'s message #{}: [View Message](#{}/{})", user, msg_id, user, msg_id))) + } +} + +/// Quote message command +pub struct QuoteCommand; + +impl ChatCommand for QuoteCommand { + fn name(&self) -> &'static str { "quote" } + fn description(&self) -> &'static str { "Quote a past message" } + fn usage(&self) -> &'static str { "/quote <msg_id>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a message ID".to_string())); + } + + let msg_id = &args[0]; + + // In a real implementation, you'd look up the message content + Ok(ChatOpResult::Message(format!("💬 Quoting message #{}: \"[Message content would be retrieved from logs]\"", msg_id))) + } +} + +/// List rooms command +pub struct RoomsCommand; + +impl ChatCommand for RoomsCommand { + fn name(&self) -> &'static str { "rooms" } + fn description(&self) -> &'static str { "List all available chat rooms" } + fn usage(&self) -> &'static str { "/rooms" } + + fn execute(&self, _args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + // In a real implementation, you'd query the server for available rooms + let rooms = vec![ + "🏠 #general - Main chat room", + "💻 #dev - Development discussions", + "🔒 #staff - Staff only (if you have access)", + "📝 #help - Help and support", + ]; + + Ok(ChatOpResult::Block(rooms.into_iter().map(|s| s.to_string()).collect())) + } +} + +/// Find user location command +pub struct WhereIsCommand; + +impl ChatCommand for WhereIsCommand { + fn name(&self) -> &'static str { "whereis" } + fn description(&self) -> &'static str { "Find which room a user is active in" } + fn usage(&self) -> &'static str { "/whereis <username>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a username".to_string())); + } + + let username = &args[0]; + + // In a real implementation, you'd query the server for user location + Ok(ChatOpResult::Message(format!("📍 User '{}' was last seen in: #general (5 minutes ago)", username))) + } +} diff --git a/src/chatops/commands/doc.rs b/src/chatops/commands/doc.rs @@ -0,0 +1,248 @@ +use crate::chatops::{ChatCommand, CommandContext, ChatOpResult, ChatOpError}; +use std::process::Command; + +/// Manual page lookup command +pub struct ManCommand; + +impl ChatCommand for ManCommand { + fn name(&self) -> &'static str { "man" } + fn description(&self) -> &'static str { "Get manual page summary for a command" } + fn usage(&self) -> &'static str { "/man <command>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a command".to_string())); + } + + let command = &args[0]; + + // Try to get man page using `man` command + let match_result = match Command::new("man") + .args(&["-f", command]) // whatis format - brief description + .output() + { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + if result.trim().is_empty() { + ChatOpResult::Message(format!("📖 No manual entry found for '{}'", command)) + } else { + let lines: Vec<String> = result + .lines() + .take(5) // Limit to first 5 results + .map(|line| format!("📖 {}", line.trim())) + .collect(); + ChatOpResult::Block(lines) + } + } else { + ChatOpResult::Message(format!("📖 No manual entry found for '{}'", command)) + } + } + Err(_) => { + // Fallback with some common commands + let description = match command.as_str() { + "curl" => "transfer a URL - command line tool for transferring data", + "grep" => "print lines that match patterns", + "awk" => "pattern scanning and processing language", + "sed" => "stream editor for filtering and transforming text", + "tmux" => "terminal multiplexer", + "ssh" => "OpenSSH SSH client (remote login program)", + "git" => "the stupid content tracker", + "docker" => "A self-sufficient runtime for containers", + "vim" => "Vi IMproved, a programmer's text editor", + "ls" => "list directory contents", + "cd" => "change directory", + "cat" => "concatenate files and print on the standard output", + "tail" => "output the last part of files", + "head" => "output the first part of files", + _ => return Ok(ChatOpResult::Error(format!("No manual entry found for '{}' and man command not available", command))), + }; + ChatOpResult::Message(format!("📖 {} - {}", command, description)) + } + }; + Ok(match_result) + } +} + +/// Language-specific documentation lookup +pub struct DocCommand; + +impl ChatCommand for DocCommand { + fn name(&self) -> &'static str { "doc" } + fn description(&self) -> &'static str { "Get language-specific documentation" } + fn usage(&self) -> &'static str { "/doc <language> <term>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.len() < 2 { + return Err(ChatOpError::MissingArguments("Please specify language and term".to_string())); + } + + let language = args[0].to_lowercase(); + let term = &args[1]; + + let doc_url = match language.as_str() { + "rust" => format!("https://doc.rust-lang.org/std/?search={}", term), + "python" | "py" => format!("https://docs.python.org/3/search.html?q={}", term), + "javascript" | "js" => format!("https://developer.mozilla.org/en-US/search?q={}", term), + "c" => format!("https://en.cppreference.com/w/c?search={}", term), + "cpp" | "c++" => format!("https://en.cppreference.com/w/cpp?search={}", term), + "go" => format!("https://pkg.go.dev/search?q={}", term), + "java" => format!("https://docs.oracle.com/en/java/javase/17/docs/api/index.html?search={}", term), + _ => return Err(ChatOpError::InvalidSyntax(format!("Unsupported language: {}", language))), + }; + + Ok(ChatOpResult::Message(format!("📚 {} docs for '{}': {}", language, term, doc_url))) + } +} + +/// Concept explanation command +pub struct ExplainCommand; + +impl ChatCommand for ExplainCommand { + fn name(&self) -> &'static str { "explain" } + fn description(&self) -> &'static str { "Explain programming concepts" } + fn usage(&self) -> &'static str { "/explain <concept>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a concept to explain".to_string())); + } + + let concept = args.join(" ").to_lowercase(); + + let explanation = match concept.as_str() { + "async" | "asynchronous" => { + "🔄 **Asynchronous Programming**: A programming paradigm that allows code to run concurrently without blocking. Operations can be initiated and then the program can continue executing other code while waiting for the operation to complete." + } + "regex" | "regular expression" => { + "🔍 **Regular Expressions**: Patterns used to match character combinations in strings. Useful for searching, extracting, and replacing text based on patterns." + } + "jwt" | "json web token" => { + "🔐 **JWT (JSON Web Token)**: A compact, URL-safe means of representing claims between two parties. Used for authentication and secure information transmission." + } + "rest api" | "restful" => { + "🌐 **REST API**: Representational State Transfer - an architectural style for designing networked applications using standard HTTP methods (GET, POST, PUT, DELETE)." + } + "docker" => { + "🐳 **Docker**: A containerization platform that packages applications with their dependencies into lightweight, portable containers." + } + "git" => { + "📝 **Git**: A distributed version control system for tracking changes in source code during software development." + } + "mutex" | "mutual exclusion" => { + "🔒 **Mutex**: A synchronization primitive that prevents multiple threads from accessing shared data simultaneously, preventing race conditions." + } + "blockchain" => { + "⛓️ **Blockchain**: A distributed ledger technology that maintains a continuously growing list of records (blocks) linked using cryptography." + } + _ => { + return Ok(ChatOpResult::Message(format!("🤔 I don't have an explanation for '{}' yet. Try searching online or ask a more specific question.", concept))); + } + }; + + Ok(ChatOpResult::Message(explanation.to_string())) + } +} + +/// Cheat sheet lookup command +pub struct CheatCommand; + +impl ChatCommand for CheatCommand { + fn name(&self) -> &'static str { "cheat" } + fn description(&self) -> &'static str { "Get cheat sheets from cht.sh" } + fn usage(&self) -> &'static str { "/cheat <term>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a term for the cheat sheet".to_string())); + } + + let term = args.join("+"); + + // Try to fetch from cht.sh with plain text output and timeout + match Command::new("curl") + .args(&["-s", "--max-time", "5", &format!("https://cht.sh/{}?T", term)]) + .output() + { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + let lines: Vec<String> = result + .lines() + .take(20) // Limit to prevent spam but allow context + .map(|line| line.to_string()) + .collect(); + + if lines.is_empty() || lines[0].contains("Unknown") { + Ok(ChatOpResult::Message(format!("📋 No cheat sheet found for '{}'", args.join(" ")))) + } else { + Ok(ChatOpResult::CodeBlock(lines.join("\n"), Some("text".to_string()))) + } + } else { + Err(ChatOpError::NetworkError("Failed to fetch cheat sheet".to_string())) + } + } + Err(_) => { + // Fallback message + Ok(ChatOpResult::Message(format!("📋 Try: https://cht.sh/{}", term))) + } + } + } +} + +/// StackOverflow search command +pub struct StackOverflowCommand; + +impl ChatCommand for StackOverflowCommand { + fn name(&self) -> &'static str { "so" } + fn description(&self) -> &'static str { "Search StackOverflow" } + fn usage(&self) -> &'static str { "/so <query>" } + fn aliases(&self) -> Vec<&'static str> { vec!["stackoverflow"] } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a search query".to_string())); + } + + let query = args.join(" "); + let encoded_query = query.replace(" ", "+"); + let url = format!("https://stackoverflow.com/search?q={}", encoded_query); + + Ok(ChatOpResult::Message(format!("🟠 StackOverflow search for '{}': {}", query, url))) + } +} + +/// Reference links command +pub struct RefCommand; + +impl ChatCommand for RefCommand { + fn name(&self) -> &'static str { "ref" } + fn description(&self) -> &'static str { "Get official reference links" } + fn usage(&self) -> &'static str { "/ref <library/language>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a library or language".to_string())); + } + + let library = args[0].to_lowercase(); + + let reference = match library.as_str() { + "rust" => ("🦀 Rust", "https://doc.rust-lang.org/std/"), + "python" => ("🐍 Python", "https://docs.python.org/3/"), + "javascript" | "js" => ("📜 JavaScript", "https://developer.mozilla.org/en-US/docs/Web/JavaScript"), + "react" => ("⚛️ React", "https://reactjs.org/docs/"), + "vue" => ("💚 Vue.js", "https://vuejs.org/guide/"), + "node" | "nodejs" => ("🟢 Node.js", "https://nodejs.org/en/docs/"), + "docker" => ("🐳 Docker", "https://docs.docker.com/"), + "git" => ("📝 Git", "https://git-scm.com/docs"), + "linux" => ("🐧 Linux", "https://man7.org/linux/man-pages/"), + "postgresql" | "postgres" => ("🐘 PostgreSQL", "https://www.postgresql.org/docs/"), + "mysql" => ("🐬 MySQL", "https://dev.mysql.com/doc/"), + "redis" => ("🔴 Redis", "https://redis.io/documentation"), + _ => return Ok(ChatOpResult::Message(format!("🔍 No reference found for '{}'. Try a web search instead.", library))), + }; + + Ok(ChatOpResult::Message(format!("{} Reference: {}", reference.0, reference.1))) + } +} diff --git a/src/chatops/commands/github.rs b/src/chatops/commands/github.rs @@ -0,0 +1,237 @@ +use crate::chatops::{ChatCommand, CommandContext, ChatOpResult, ChatOpError}; +use std::process::Command; + +/// GitHub repository information +pub struct GitHubCommand; + +impl ChatCommand for GitHubCommand { + fn name(&self) -> &'static str { "github" } + fn description(&self) -> &'static str { "Get GitHub repository information" } + fn usage(&self) -> &'static str { "/github <user>/<repo> [issues|latest|file <path>]" } + fn aliases(&self) -> Vec<&'static str> { vec!["gh"] } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a repository (user/repo)".to_string())); + } + + let repo = &args[0]; + if !repo.contains('/') { + return Err(ChatOpError::InvalidSyntax("Repository must be in format 'user/repo'".to_string())); + } + + let action = args.get(1).map(|s| s.as_str()).unwrap_or("info"); + + match action { + "issues" => { + Ok(ChatOpResult::Message(format!("🐛 GitHub Issues for {}: https://github.com/{}/issues", repo, repo))) + } + "latest" => { + Ok(ChatOpResult::Message(format!("🏷️ Latest Release for {}: https://github.com/{}/releases/latest", repo, repo))) + } + "file" => { + if args.len() < 3 { + return Err(ChatOpError::MissingArguments("Please specify file path".to_string())); + } + let file_path = &args[2]; + Ok(ChatOpResult::Message(format!("📄 File {}: https://github.com/{}/blob/main/{}", file_path, repo, file_path))) + } + _ => { + // Basic repo info + let info = vec![ + format!("📦 **GitHub Repository: {}**", repo), + format!("🔗 URL: https://github.com/{}", repo), + format!("📊 Issues: https://github.com/{}/issues", repo), + format!("🏷️ Releases: https://github.com/{}/releases", repo), + format!("📋 README: https://github.com/{}#readme", repo), + ]; + Ok(ChatOpResult::Block(info)) + } + } + } +} + +/// GitHub Gist creation +pub struct GistCommand; + +impl ChatCommand for GistCommand { + fn name(&self) -> &'static str { "gist" } + fn description(&self) -> &'static str { "Create a GitHub Gist (requires gh CLI)" } + fn usage(&self) -> &'static str { "/gist <code>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify code to create a gist".to_string())); + } + + let code = args.join(" "); + + // Try using GitHub CLI if available + match Command::new("gh") + .args(&["gist", "create", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + { + Ok(mut child) => { + use std::io::Write; + if let Some(stdin) = child.stdin.as_mut() { + let _ = stdin.write_all(code.as_bytes()); + } + + match child.wait_with_output() { + Ok(output) => { + if output.status.success() { + let gist_url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(ChatOpResult::Message(format!("📝 Gist created: {}", gist_url))) + } else { + Ok(ChatOpResult::Message("📝 Failed to create gist. Make sure you're logged in with `gh auth login`".to_string())) + } + } + Err(_) => Ok(ChatOpResult::Message("📝 Failed to create gist".to_string())), + } + } + Err(_) => { + // Fallback - just show the manual gist creation URL + Ok(ChatOpResult::Message(format!("📝 Create gist manually at: https://gist.github.com/"))) + } + } + } +} + +/// Rust crates.io information +pub struct CratesCommand; + +impl ChatCommand for CratesCommand { + fn name(&self) -> &'static str { "crates" } + fn description(&self) -> &'static str { "Get Rust crate information from crates.io" } + fn usage(&self) -> &'static str { "/crates <crate_name>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a crate name".to_string())); + } + + let crate_name = &args[0]; + + // Try to fetch from crates.io API + match Command::new("curl") + .args(&["-s", &format!("https://crates.io/api/v1/crates/{}", crate_name)]) + .output() + { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + if result.contains("\"crate\"") { + // Parse basic info (in real implementation, use serde_json) + let info = vec![ + format!("📦 **Rust Crate: {}**", crate_name), + format!("🔗 crates.io: https://crates.io/crates/{}", crate_name), + format!("📚 docs.rs: https://docs.rs/{}", crate_name), + format!("📋 Add to Cargo.toml: {} = \"latest\"", crate_name), + ]; + Ok(ChatOpResult::Block(info)) + } else { + Ok(ChatOpResult::Message(format!("📦 Crate '{}' not found on crates.io", crate_name))) + } + } else { + Ok(ChatOpResult::Message(format!("📦 Failed to fetch info for crate '{}'", crate_name))) + } + } + Err(_) => { + Ok(ChatOpResult::Message(format!("📦 Check crate manually: https://crates.io/crates/{}", crate_name))) + } + } + } +} + +/// NPM package information +pub struct NpmCommand; + +impl ChatCommand for NpmCommand { + fn name(&self) -> &'static str { "npm" } + fn description(&self) -> &'static str { "Get NPM package information" } + fn usage(&self) -> &'static str { "/npm <package_name>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a package name".to_string())); + } + + let package_name = &args[0]; + + // Try using npm view command + match Command::new("npm") + .args(&["view", package_name, "--json"]) + .output() + { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + if result.contains("\"name\"") { + let info = vec![ + format!("📦 **NPM Package: {}**", package_name), + format!("🔗 npmjs.com: https://www.npmjs.com/package/{}", package_name), + format!("📋 Install: npm install {}", package_name), + format!("📋 Or: yarn add {}", package_name), + ]; + Ok(ChatOpResult::Block(info)) + } else { + Ok(ChatOpResult::Message(format!("📦 Package '{}' not found on NPM", package_name))) + } + } else { + Ok(ChatOpResult::Message(format!("📦 Failed to fetch info for package '{}'", package_name))) + } + } + Err(_) => { + Ok(ChatOpResult::Message(format!("📦 Check package manually: https://www.npmjs.com/package/{}", package_name))) + } + } + } +} + +/// Python PyPI package information +pub struct PipCommand; + +impl ChatCommand for PipCommand { + fn name(&self) -> &'static str { "pip" } + fn description(&self) -> &'static str { "Get Python package information from PyPI" } + fn usage(&self) -> &'static str { "/pip <package_name>" } + fn aliases(&self) -> Vec<&'static str> { vec!["pypi"] } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a package name".to_string())); + } + + let package_name = &args[0]; + + // Try to fetch from PyPI API + match Command::new("curl") + .args(&["-s", &format!("https://pypi.org/pypi/{}/json", package_name)]) + .output() + { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + if result.contains("\"info\"") && !result.contains("\"message\": \"Not Found\"") { + let info = vec![ + format!("🐍 **Python Package: {}**", package_name), + format!("🔗 PyPI: https://pypi.org/project/{}/", package_name), + format!("📋 Install: pip install {}", package_name), + format!("📋 Or: python -m pip install {}", package_name), + ]; + Ok(ChatOpResult::Block(info)) + } else { + Ok(ChatOpResult::Message(format!("🐍 Package '{}' not found on PyPI", package_name))) + } + } else { + Ok(ChatOpResult::Message(format!("🐍 Failed to fetch info for package '{}'", package_name))) + } + } + Err(_) => { + Ok(ChatOpResult::Message(format!("🐍 Check package manually: https://pypi.org/project/{}/", package_name))) + } + } + } +} diff --git a/src/chatops/commands/misc.rs b/src/chatops/commands/misc.rs @@ -0,0 +1,189 @@ +use crate::chatops::{ChatCommand, CommandContext, ChatOpResult, ChatOpError}; +use std::process::Command; + +/// ASCII art generation +pub struct AsciiCommand; + +impl ChatCommand for AsciiCommand { + fn name(&self) -> &'static str { "ascii" } + fn description(&self) -> &'static str { "Generate ASCII art text" } + fn usage(&self) -> &'static str { "/ascii <text>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify text to convert".to_string())); + } + + let text = args.join(" "); + + // Try using figlet if available + match Command::new("figlet") + .arg(&text) + .output() + { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + Ok(ChatOpResult::CodeBlock(result.to_string(), Some("text".to_string()))) + } else { + Ok(self.simple_ascii_art(&text)) + } + } + Err(_) => { + // Fallback to simple ASCII art + Ok(self.simple_ascii_art(&text)) + } + } + } +} + +impl AsciiCommand { + fn simple_ascii_art(&self, text: &str) -> ChatOpResult { + // Simple block letters fallback + let mut result = String::new(); + + for ch in text.to_uppercase().chars() { + match ch { + 'A' => result.push_str(" █████ \n██ ██\n███████\n██ ██\n██ ██\n"), + 'B' => result.push_str("██████ \n██ ██\n██████ \n██ ██\n██████ \n"), + 'C' => result.push_str(" ██████\n██ \n██ \n██ \n ██████\n"), + 'D' => result.push_str("██████ \n██ ██\n██ ██\n██ ██\n██████ \n"), + 'E' => result.push_str("███████\n██ \n█████ \n██ \n███████\n"), + ' ' => result.push_str(" \n \n \n \n \n"), + _ => result.push_str("██ ██\n██ ██\n██ ██\n██ ██\n██ ██\n"), + } + } + + ChatOpResult::CodeBlock(result, Some("text".to_string())) + } +} + +/// Fortune command +pub struct FortuneCommand; + +impl ChatCommand for FortuneCommand { + fn name(&self) -> &'static str { "fortune" } + fn description(&self) -> &'static str { "Get a random fortune cookie" } + fn usage(&self) -> &'static str { "/fortune" } + + fn execute(&self, _args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + match Command::new("fortune").output() { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + Ok(ChatOpResult::Message(format!("🥠 {}", result.trim()))) + } else { + Ok(self.fallback_fortune()) + } + } + Err(_) => Ok(self.fallback_fortune()), + } + } +} + +impl FortuneCommand { + fn fallback_fortune(&self) -> ChatOpResult { + let fortunes = vec![ + "The best way to predict the future is to invent it.", + "Programs must be written for people to read, and only incidentally for machines to execute.", + "Any fool can write code that a computer can understand. Good programmers write code that humans can understand.", + "First, solve the problem. Then, write the code.", + "Experience is the name everyone gives to their mistakes.", + "In order to be irreplaceable, one must always be different.", + "Java is to JavaScript what car is to Carpet.", + "There are only two hard things in Computer Science: cache invalidation and naming things.", + "Code is like humor. When you have to explain it, it's bad.", + "Programming today is a race between software engineers striving to build bigger and better idiot-proof programs, and the Universe trying to produce bigger and better idiots. So far, the Universe is winning.", + ]; + + use rand::seq::SliceRandom; + let mut rng = rand::thread_rng(); + let fortune = fortunes.choose(&mut rng).unwrap_or(&fortunes[0]); + + ChatOpResult::Message(format!("🥠 {}", fortune)) + } +} + +/// Message of the day +pub struct MotdCommand; + +impl ChatCommand for MotdCommand { + fn name(&self) -> &'static str { "motd" } + fn description(&self) -> &'static str { "Show message of the day" } + fn usage(&self) -> &'static str { "/motd" } + + fn execute(&self, _args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + let motd = vec![ + "📢 **Message of the Day**".to_string(), + "".to_string(), + "🚀 Welcome to BHCLI with ChatOps!".to_string(), + "💡 Type `/help` to see all available developer commands".to_string(), + "🔧 Use `/commands` to list commands available to your role".to_string(), + "🤖 Try `/explain <concept>` to learn about programming topics".to_string(), + "📦 Check packages with `/crates`, `/npm`, or `/pip`".to_string(), + "🌐 Test networks with `/ping`, `/dig`, or `/whois`".to_string(), + "".to_string(), + "Happy hacking! 🎯".to_string(), + ]; + + Ok(ChatOpResult::Block(motd)) + } +} + +/// AFK (Away From Keyboard) status +pub struct AfkCommand; + +impl ChatCommand for AfkCommand { + fn name(&self) -> &'static str { "afk" } + fn description(&self) -> &'static str { "Set yourself as away from keyboard" } + fn usage(&self) -> &'static str { "/afk [message]" } + + fn execute(&self, args: Vec<String>, context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + let message = if args.is_empty() { + "Away from keyboard".to_string() + } else { + args.join(" ") + }; + + // In a real implementation, you'd store this in user state + Ok(ChatOpResult::Message(format!("💤 {} is now AFK: {}", context.username, message))) + } +} + +/// User alias management +pub struct AliasCommand; + +impl ChatCommand for AliasCommand { + fn name(&self) -> &'static str { "alias" } + fn description(&self) -> &'static str { "Create personal command aliases" } + fn usage(&self) -> &'static str { "/alias <name> <command> OR /alias list OR /alias remove <name>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify alias operation".to_string())); + } + + match args[0].as_str() { + "list" => { + // In a real implementation, you'd load user's aliases from storage + Ok(ChatOpResult::Message(format!("📝 Your aliases: (feature requires persistent storage implementation)"))) + } + "remove" | "rm" => { + if args.len() < 2 { + return Err(ChatOpError::MissingArguments("Please specify alias name to remove".to_string())); + } + let alias_name = &args[1]; + Ok(ChatOpResult::Message(format!("🗑️ Removed alias '{}' (feature requires persistent storage implementation)", alias_name))) + } + _ => { + if args.len() < 2 { + return Err(ChatOpError::MissingArguments("Please specify alias name and command".to_string())); + } + let alias_name = &args[0]; + let command = args[1..].join(" "); + + Ok(ChatOpResult::Message(format!("✅ Created alias '{}' -> '{}' (feature requires persistent storage implementation)", alias_name, command))) + } + } + } +} diff --git a/src/chatops/commands/mod.rs b/src/chatops/commands/mod.rs @@ -0,0 +1,7 @@ +pub mod doc; +pub mod tools; +pub mod chat; +pub mod ai; +pub mod github; +pub mod network; +pub mod misc; diff --git a/src/chatops/commands/network.rs b/src/chatops/commands/network.rs @@ -0,0 +1,338 @@ +use crate::chatops::{ChatCommand, CommandContext, ChatOpResult, ChatOpError}; +use std::process::Command; + +/// Ping command +pub struct PingCommand; + +impl ChatCommand for PingCommand { + fn name(&self) -> &'static str { "ping" } + fn description(&self) -> &'static str { "Ping a host" } + fn usage(&self) -> &'static str { "/ping <host>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a host to ping".to_string())); + } + + let host = &args[0]; + + match Command::new("ping") + .args(&["-c", "3", host]) // 3 packets on Unix + .output() + { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + let lines: Vec<String> = result + .lines() + .filter(|line| line.contains("time=") || line.contains("packet loss") || line.contains("min/avg/max")) + .take(5) + .map(|line| format!("🏓 {}", line.trim())) + .collect(); + + if lines.is_empty() { + Ok(ChatOpResult::Message(format!("🏓 Ping to {} completed", host))) + } else { + Ok(ChatOpResult::Block(lines)) + } + } else { + Ok(ChatOpResult::Message(format!("🏓 Ping to {} failed", host))) + } + } + Err(_) => Ok(ChatOpResult::Message(format!("🏓 Ping command not available"))), + } + } +} + +/// Traceroute command +pub struct TraceCommand; + +impl ChatCommand for TraceCommand { + fn name(&self) -> &'static str { "trace" } + fn description(&self) -> &'static str { "Trace route to host" } + fn usage(&self) -> &'static str { "/trace <host>" } + fn aliases(&self) -> Vec<&'static str> { vec!["traceroute"] } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a host to trace".to_string())); + } + + let host = &args[0]; + + match Command::new("traceroute") + .args(&["-m", "10", host]) // Max 10 hops + .output() + { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + let lines: Vec<String> = result + .lines() + .take(12) // Limit output + .map(|line| format!("📍 {}", line.trim())) + .collect(); + + if lines.is_empty() { + Ok(ChatOpResult::Message(format!("📍 Traceroute to {} completed", host))) + } else { + Ok(ChatOpResult::Block(lines)) + } + } else { + Ok(ChatOpResult::Message(format!("📍 Traceroute to {} failed", host))) + } + } + Err(_) => { + // Try with tracepath as alternative + match Command::new("tracepath").arg(host).output() { + Ok(output) if output.status.success() => { + let result = String::from_utf8_lossy(&output.stdout); + let lines: Vec<String> = result + .lines() + .take(12) + .map(|line| format!("📍 {}", line.trim())) + .collect(); + Ok(ChatOpResult::Block(lines)) + } + _ => Ok(ChatOpResult::Message("📍 Traceroute command not available".to_string())), + } + } + } + } +} + +/// Port scan command +pub struct PortScanCommand; + +impl ChatCommand for PortScanCommand { + fn name(&self) -> &'static str { "portscan" } + fn description(&self) -> &'static str { "Simple port scan (TCP connect)" } + fn usage(&self) -> &'static str { "/portscan <host> [ports]" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a host to scan".to_string())); + } + + let host = &args[0]; + let ports = args.get(1).unwrap_or(&"22,80,443".to_string()).clone(); + + // Simple TCP connect scan using netcat if available + match Command::new("nc") + .args(&["-z", "-v", "-w", "2", host, &ports]) + .output() + { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let result = format!("{}{}", stdout, stderr); + + let lines: Vec<String> = result + .lines() + .filter(|line| !line.trim().is_empty()) + .take(10) + .map(|line| format!("🔍 {}", line.trim())) + .collect(); + + if lines.is_empty() { + Ok(ChatOpResult::Message(format!("🔍 Port scan of {} completed (no open ports found)", host))) + } else { + Ok(ChatOpResult::Block(lines)) + } + } + Err(_) => { + // Try with nmap if available + match Command::new("nmap") + .args(&["-p", &ports, host]) + .output() + { + Ok(output) if output.status.success() => { + let result = String::from_utf8_lossy(&output.stdout); + let lines: Vec<String> = result + .lines() + .filter(|line| line.contains("/tcp") || line.contains("Nmap scan report")) + .take(10) + .map(|line| format!("🔍 {}", line.trim())) + .collect(); + Ok(ChatOpResult::Block(lines)) + } + _ => Ok(ChatOpResult::Message("🔍 Port scanning tools not available (nc/nmap)".to_string())), + } + } + } + } +} + +/// HTTP headers command +pub struct HeadersCommand; + +impl ChatCommand for HeadersCommand { + fn name(&self) -> &'static str { "headers" } + fn description(&self) -> &'static str { "Show HTTP response headers" } + fn usage(&self) -> &'static str { "/headers <url>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a URL".to_string())); + } + + let url = &args[0]; + + match Command::new("curl") + .args(&["-I", "-s", url]) + .output() + { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + let lines: Vec<String> = result + .lines() + .filter(|line| !line.trim().is_empty()) + .take(15) // Limit headers + .map(|line| format!("📡 {}", line.trim())) + .collect(); + + if lines.is_empty() { + Ok(ChatOpResult::Message(format!("📡 No headers received from {}", url))) + } else { + Ok(ChatOpResult::Block(lines)) + } + } else { + Ok(ChatOpResult::Message(format!("📡 Failed to fetch headers from {}", url))) + } + } + Err(_) => Ok(ChatOpResult::Message("📡 curl command not available".to_string())), + } + } +} + +/// Simple HTTP request command +pub struct CurlCommand; + +impl ChatCommand for CurlCommand { + fn name(&self) -> &'static str { "curl" } + fn description(&self) -> &'static str { "Make HTTP request and show response" } + fn usage(&self) -> &'static str { "/curl <url>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a URL".to_string())); + } + + let url = &args[0]; + + match Command::new("curl") + .args(&["-s", "-L", "--max-time", "10", url]) + .output() + { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + let lines: Vec<String> = result + .lines() + .take(20) // Limit response body + .map(|line| line.to_string()) + .collect(); + + if lines.is_empty() { + Ok(ChatOpResult::Message(format!("🌐 Empty response from {}", url))) + } else { + Ok(ChatOpResult::CodeBlock(lines.join("\n"), Some("text".to_string()))) + } + } else { + Ok(ChatOpResult::Message(format!("🌐 Failed to fetch {}", url))) + } + } + Err(_) => Ok(ChatOpResult::Message("🌐 curl command not available".to_string())), + } + } +} + +/// SSL certificate information +pub struct SslCommand; + +impl ChatCommand for SslCommand { + fn name(&self) -> &'static str { "ssl" } + fn description(&self) -> &'static str { "Show SSL certificate information" } + fn usage(&self) -> &'static str { "/ssl <domain>" } + fn aliases(&self) -> Vec<&'static str> { vec!["cert"] } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a domain".to_string())); + } + + let domain = &args[0]; + + match Command::new("openssl") + .args(&["s_client", "-connect", &format!("{}:443", domain), "-servername", domain]) + .stdin(std::process::Stdio::null()) + .output() + { + Ok(output) => { + let result = String::from_utf8_lossy(&output.stdout); + let lines: Vec<String> = result + .lines() + .filter(|line| { + line.contains("subject=") || + line.contains("issuer=") || + line.contains("notBefore=") || + line.contains("notAfter=") || + line.contains("Verification:") + }) + .take(8) + .map(|line| format!("🔒 {}", line.trim())) + .collect(); + + if lines.is_empty() { + Ok(ChatOpResult::Message(format!("🔒 Could not retrieve SSL certificate for {}", domain))) + } else { + Ok(ChatOpResult::Block(lines)) + } + } + Err(_) => Ok(ChatOpResult::Message(format!("🔒 SSL check not available. Try: https://www.ssllabs.com/ssltest/analyze.html?d={}", domain))), + } + } +} + +/// Tor exit node checker +pub struct TorCheckCommand; + +impl ChatCommand for TorCheckCommand { + fn name(&self) -> &'static str { "torcheck" } + fn description(&self) -> &'static str { "Check if IP is a Tor exit node" } + fn usage(&self) -> &'static str { "/torcheck <ip>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify an IP address".to_string())); + } + + let ip = &args[0]; + + // Basic IP validation + if !ip.chars().all(|c| c.is_ascii_digit() || c == '.') || ip.split('.').count() != 4 { + return Err(ChatOpError::InvalidSyntax("Please provide a valid IPv4 address".to_string())); + } + + // Try checking with a Tor exit list service + match Command::new("curl") + .args(&["-s", "--max-time", "5", &format!("https://check.torproject.org/torbulkexitlist?ip={}", ip)]) + .output() + { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + if result.contains(ip) { + Ok(ChatOpResult::Message(format!("🧅 {} is a Tor exit node", ip))) + } else { + Ok(ChatOpResult::Message(format!("🧅 {} is not a known Tor exit node", ip))) + } + } else { + Ok(ChatOpResult::Message(format!("🧅 Could not check Tor status for {}", ip))) + } + } + Err(_) => Ok(ChatOpResult::Message(format!("🧅 Tor check not available. Try: https://metrics.torproject.org/exonerator.html?ip={}", ip))), + } + } +} diff --git a/src/chatops/commands/tools.rs b/src/chatops/commands/tools.rs @@ -0,0 +1,355 @@ +use crate::chatops::{ChatCommand, CommandContext, ChatOpResult, ChatOpError}; +use std::process::Command; +use uuid::Uuid; +use base64::{Engine as _, engine::general_purpose}; +use regex::Regex; +use std::time::{SystemTime, UNIX_EPOCH}; +use chrono::{DateTime, Utc}; +use rand::Rng; + +/// Hash generation command +pub struct HashCommand; + +impl ChatCommand for HashCommand { + fn name(&self) -> &'static str { "hash" } + fn description(&self) -> &'static str { "Generate hash of text using various algorithms" } + fn usage(&self) -> &'static str { "/hash <algorithm> <text>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.len() < 2 { + return Err(ChatOpError::MissingArguments("Please specify algorithm and text".to_string())); + } + + let algorithm = args[0].to_lowercase(); + let text = args[1..].join(" "); + + let hash_result = match algorithm.as_str() { + "md5" => { + let digest = md5::compute(text.as_bytes()); + format!("{:x}", digest) + } + "sha1" => { + use sha1::{Sha1, Digest}; + let mut hasher = Sha1::new(); + hasher.update(text.as_bytes()); + format!("{:x}", hasher.finalize()) + } + "sha256" => { + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(text.as_bytes()); + format!("{:x}", hasher.finalize()) + } + "sha512" => { + use sha2::{Sha512, Digest}; + let mut hasher = Sha512::new(); + hasher.update(text.as_bytes()); + format!("{:x}", hasher.finalize()) + } + _ => return Err(ChatOpError::InvalidSyntax( + "Supported algorithms: md5, sha1, sha256, sha512".to_string() + )), + }; + + Ok(ChatOpResult::Message(format!("🔐 {} hash: `{}`", algorithm.to_uppercase(), hash_result))) + } +} + +/// UUID generation command +pub struct UuidCommand; + +impl ChatCommand for UuidCommand { + fn name(&self) -> &'static str { "uuid" } + fn description(&self) -> &'static str { "Generate a random UUID" } + fn usage(&self) -> &'static str { "/uuid" } + + fn execute(&self, _args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + let uuid = Uuid::new_v4(); + Ok(ChatOpResult::Message(format!("🆔 Generated UUID: `{}`", uuid))) + } +} + +/// Base64 encoding/decoding command +pub struct Base64Command; + +impl ChatCommand for Base64Command { + fn name(&self) -> &'static str { "base64" } + fn description(&self) -> &'static str { "Encode or decode Base64 text" } + fn usage(&self) -> &'static str { "/base64 <encode|decode> <text>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.len() < 2 { + return Err(ChatOpError::MissingArguments("Please specify operation (encode/decode) and text".to_string())); + } + + let operation = args[0].to_lowercase(); + let text = args[1..].join(" "); + + match operation.as_str() { + "encode" | "enc" => { + let encoded = general_purpose::STANDARD.encode(text.as_bytes()); + Ok(ChatOpResult::Message(format!("🔤 Base64 encoded: `{}`", encoded))) + } + "decode" | "dec" => { + match general_purpose::STANDARD.decode(&text) { + Ok(decoded_bytes) => { + match String::from_utf8(decoded_bytes) { + Ok(decoded_text) => Ok(ChatOpResult::Message(format!("🔤 Base64 decoded: `{}`", decoded_text))), + Err(_) => Ok(ChatOpResult::Message("🔤 Base64 decoded to binary data (not valid UTF-8)".to_string())), + } + } + Err(_) => Err(ChatOpError::InvalidSyntax("Invalid Base64 input".to_string())), + } + } + _ => Err(ChatOpError::InvalidSyntax("Operation must be 'encode' or 'decode'".to_string())), + } + } +} + +/// Regex testing command +pub struct RegexCommand; + +impl ChatCommand for RegexCommand { + fn name(&self) -> &'static str { "regex" } + fn description(&self) -> &'static str { "Test regex pattern against text" } + fn usage(&self) -> &'static str { "/regex <pattern> <text>" } + fn aliases(&self) -> Vec<&'static str> { vec!["re"] } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.len() < 2 { + return Err(ChatOpError::MissingArguments("Please specify pattern and text".to_string())); + } + + let pattern = &args[0]; + let text = args[1..].join(" "); + + match Regex::new(pattern) { + Ok(re) => { + let matches: Vec<_> = re.find_iter(&text).collect(); + + if matches.is_empty() { + Ok(ChatOpResult::Message("🔍 No matches found".to_string())) + } else { + let mut result = vec![ + format!("🔍 Found {} match(es):", matches.len()), + ]; + + for (i, m) in matches.iter().enumerate().take(5) { // Limit to 5 matches + result.push(format!(" {}. \"{}\" at position {}-{}", + i + 1, m.as_str(), m.start(), m.end())); + } + + if matches.len() > 5 { + result.push(format!(" ... and {} more", matches.len() - 5)); + } + + Ok(ChatOpResult::Block(result)) + } + } + Err(e) => Err(ChatOpError::InvalidSyntax(format!("Invalid regex pattern: {}", e))), + } + } +} + +/// WHOIS lookup command +pub struct WhoisCommand; + +impl ChatCommand for WhoisCommand { + fn name(&self) -> &'static str { "whois" } + fn description(&self) -> &'static str { "Perform WHOIS lookup on a domain" } + fn usage(&self) -> &'static str { "/whois <domain>" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a domain".to_string())); + } + + let domain = &args[0]; + + match Command::new("whois").arg(domain).output() { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + let lines: Vec<String> = result + .lines() + .filter(|line| !line.trim().is_empty() && !line.starts_with('%')) + .take(10) // Limit output + .map(|line| line.trim().to_string()) + .collect(); + + if lines.is_empty() { + Ok(ChatOpResult::Message(format!("🌐 No WHOIS data found for '{}'", domain))) + } else { + Ok(ChatOpResult::Block(lines)) + } + } else { + Ok(ChatOpResult::Message(format!("🌐 WHOIS lookup failed for '{}'", domain))) + } + } + Err(_) => Ok(ChatOpResult::Message(format!("🌐 WHOIS command not available. Try: https://whois.net/whois/{}", domain))), + } + } +} + +/// DNS lookup command +pub struct DigCommand; + +impl ChatCommand for DigCommand { + fn name(&self) -> &'static str { "dig" } + fn description(&self) -> &'static str { "Perform DNS lookup" } + fn usage(&self) -> &'static str { "/dig <domain> [record_type]" } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify a domain".to_string())); + } + + let domain = &args[0]; + let record_type = args.get(1).map(|s| s.as_str()).unwrap_or("A"); + + match Command::new("dig") + .args(&["+short", domain, record_type]) + .output() + { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + let lines: Vec<String> = result + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| format!("🌐 {} {} {}", domain, record_type, line.trim())) + .collect(); + + if lines.is_empty() { + Ok(ChatOpResult::Message(format!("🌐 No {} records found for '{}'", record_type, domain))) + } else { + Ok(ChatOpResult::Block(lines)) + } + } else { + Ok(ChatOpResult::Message(format!("🌐 DNS lookup failed for '{}'", domain))) + } + } + Err(_) => Ok(ChatOpResult::Message(format!("🌐 dig command not available. Try: https://www.nslookup.io/domains/{}/dns-records/", domain))), + } + } +} + +/// IP information lookup command +pub struct IpInfoCommand; + +impl ChatCommand for IpInfoCommand { + fn name(&self) -> &'static str { "ipinfo" } + fn description(&self) -> &'static str { "Get IP address information" } + fn usage(&self) -> &'static str { "/ipinfo <ip_address>" } + fn aliases(&self) -> Vec<&'static str> { vec!["ip"] } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + if args.is_empty() { + return Err(ChatOpError::MissingArguments("Please specify an IP address".to_string())); + } + + let ip = &args[0]; + + // Basic IP validation + if !ip.chars().all(|c| c.is_ascii_digit() || c == '.') || ip.split('.').count() != 4 { + return Err(ChatOpError::InvalidSyntax("Please provide a valid IPv4 address".to_string())); + } + + // Try to get info from a free service + match Command::new("curl") + .args(&["-s", &format!("https://ipinfo.io/{}/json", ip)]) + .output() + { + Ok(output) => { + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout); + // Simple parsing - in a real implementation you'd use serde_json + if result.contains("\"ip\"") { + Ok(ChatOpResult::CodeBlock(result.to_string(), Some("json".to_string()))) + } else { + Ok(ChatOpResult::Message(format!("🌍 No information found for IP: {}", ip))) + } + } else { + Ok(ChatOpResult::Message(format!("🌍 IP lookup failed for '{}'", ip))) + } + } + Err(_) => Ok(ChatOpResult::Message(format!("🌍 IP lookup not available. Try: https://ipinfo.io/{}", ip))), + } + } +} + +/// Random number generator command +pub struct RandCommand; + +impl ChatCommand for RandCommand { + fn name(&self) -> &'static str { "rand" } + fn description(&self) -> &'static str { "Generate random number" } + fn usage(&self) -> &'static str { "/rand [min] [max]" } + fn aliases(&self) -> Vec<&'static str> { vec!["random", "rng"] } + + fn execute(&self, args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + let mut rng = rand::thread_rng(); + + match args.len() { + 0 => { + // Random float between 0 and 1 + let num: f64 = rng.gen(); + Ok(ChatOpResult::Message(format!("🎲 Random: {:.6}", num))) + } + 1 => { + // Random int between 0 and max + match args[0].parse::<i32>() { + Ok(max) if max > 0 => { + let num = rng.gen_range(0..=max); + Ok(ChatOpResult::Message(format!("🎲 Random (0-{}): {}", max, num))) + } + _ => Err(ChatOpError::InvalidSyntax("Max must be a positive integer".to_string())), + } + } + 2 => { + // Random int between min and max + match (args[0].parse::<i32>(), args[1].parse::<i32>()) { + (Ok(min), Ok(max)) if min < max => { + let num = rng.gen_range(min..=max); + Ok(ChatOpResult::Message(format!("🎲 Random ({}-{}): {}", min, max, num))) + } + _ => Err(ChatOpError::InvalidSyntax("Min and max must be integers with min < max".to_string())), + } + } + _ => Err(ChatOpError::InvalidSyntax("Too many arguments".to_string())), + } + } +} + +/// Time display command +pub struct TimeCommand; + +impl ChatCommand for TimeCommand { + fn name(&self) -> &'static str { "time" } + fn description(&self) -> &'static str { "Show current time in UTC and local" } + fn usage(&self) -> &'static str { "/time" } + + fn execute(&self, _args: Vec<String>, _context: &CommandContext) -> Result<ChatOpResult, ChatOpError> { + let now = SystemTime::now(); + let utc: DateTime<Utc> = now.into(); + + // Get local time zone if possible + let local_info = match Command::new("date").output() { + Ok(output) => String::from_utf8_lossy(&output.stdout).trim().to_string(), + Err(_) => "Local time unavailable".to_string(), + }; + + let unix_timestamp = now.duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let result = vec![ + "🕐 **Current Time:**".to_string(), + format!("UTC: {}", utc.format("%Y-%m-%d %H:%M:%S UTC")), + format!("Local: {}", local_info), + format!("Unix Timestamp: {}", unix_timestamp), + ]; + + Ok(ChatOpResult::Block(result)) + } +} diff --git a/src/chatops/mod.rs b/src/chatops/mod.rs @@ -0,0 +1,102 @@ +//! ChatOps - Developer-focused slash commands for BHCLI +//! +//! This module provides a flexible and extensible slash command system +//! to support advanced developer-focused features. + +pub mod command_router; +pub mod commands; +pub mod registry; +pub mod result; + +pub use command_router::ChatOpsRouter; +pub use registry::CommandRegistry; +pub use result::{ChatOpResult, ChatOpError}; + +/// Context provided to commands during execution +#[derive(Clone)] +pub struct CommandContext { + pub username: String, + #[allow(dead_code)] + pub room: Option<String>, + pub role: UserRole, +} + +/// User role for permission checking +#[derive(Clone, Debug, PartialEq)] +pub enum UserRole { + Guest, + Member, + Staff, + Admin, +} + +/// Main ChatOps handler trait +pub trait ChatCommand: Send + Sync { + fn name(&self) -> &'static str; + fn description(&self) -> &'static str; + fn usage(&self) -> &'static str; + fn execute(&self, args: Vec<String>, context: &CommandContext) -> Result<ChatOpResult, ChatOpError>; + fn aliases(&self) -> Vec<&'static str> { vec![] } + fn required_role(&self) -> UserRole { UserRole::Guest } +} + +/// Initialize the ChatOps system with default commands +pub fn init_chatops() -> CommandRegistry { + let mut registry = CommandRegistry::new(); + + // Documentation & Lookup + registry.register(Box::new(commands::doc::ManCommand)); + registry.register(Box::new(commands::doc::DocCommand)); + registry.register(Box::new(commands::doc::ExplainCommand)); + registry.register(Box::new(commands::doc::CheatCommand)); + registry.register(Box::new(commands::doc::StackOverflowCommand)); + registry.register(Box::new(commands::doc::RefCommand)); + + // Tooling & Utilities + registry.register(Box::new(commands::tools::HashCommand)); + registry.register(Box::new(commands::tools::UuidCommand)); + registry.register(Box::new(commands::tools::Base64Command)); + registry.register(Box::new(commands::tools::RegexCommand)); + registry.register(Box::new(commands::tools::WhoisCommand)); + registry.register(Box::new(commands::tools::DigCommand)); + registry.register(Box::new(commands::tools::IpInfoCommand)); + registry.register(Box::new(commands::tools::RandCommand)); + registry.register(Box::new(commands::tools::TimeCommand)); + + // Chat Linking & Session Intelligence + registry.register(Box::new(commands::chat::ChatLinkCommand)); + registry.register(Box::new(commands::chat::QuoteCommand)); + registry.register(Box::new(commands::chat::RoomsCommand)); + registry.register(Box::new(commands::chat::WhereIsCommand)); + + // AI Integration (if available) + registry.register(Box::new(commands::ai::SummarizeCommand)); + registry.register(Box::new(commands::ai::TranslateCommand)); + registry.register(Box::new(commands::ai::FixCommand)); + registry.register(Box::new(commands::ai::ReviewCommand)); + + // GitHub and Git Integration + registry.register(Box::new(commands::github::GitHubCommand)); + registry.register(Box::new(commands::github::GistCommand)); + registry.register(Box::new(commands::github::CratesCommand)); + registry.register(Box::new(commands::github::NpmCommand)); + registry.register(Box::new(commands::github::PipCommand)); + + // Network & Protocol Diagnostics + registry.register(Box::new(commands::network::PingCommand)); + registry.register(Box::new(commands::network::TraceCommand)); + registry.register(Box::new(commands::network::PortScanCommand)); + registry.register(Box::new(commands::network::HeadersCommand)); + registry.register(Box::new(commands::network::CurlCommand)); + registry.register(Box::new(commands::network::SslCommand)); + registry.register(Box::new(commands::network::TorCheckCommand)); + + // Fun & Misc + registry.register(Box::new(commands::misc::AsciiCommand)); + registry.register(Box::new(commands::misc::FortuneCommand)); + registry.register(Box::new(commands::misc::MotdCommand)); + registry.register(Box::new(commands::misc::AfkCommand)); + registry.register(Box::new(commands::misc::AliasCommand)); + + registry +} diff --git a/src/chatops/registry.rs b/src/chatops/registry.rs @@ -0,0 +1,115 @@ +use std::collections::HashMap; +use crate::chatops::{ChatCommand, CommandContext, UserRole, ChatOpResult, ChatOpError}; + +/// Registry for managing ChatOps commands +pub struct CommandRegistry { + commands: HashMap<String, Box<dyn ChatCommand>>, + aliases: HashMap<String, String>, +} + +impl CommandRegistry { + pub fn new() -> Self { + Self { + commands: HashMap::new(), + aliases: HashMap::new(), + } + } + + /// Register a new command + pub fn register(&mut self, command: Box<dyn ChatCommand>) { + let name = command.name().to_string(); + + // Register aliases + for alias in command.aliases() { + self.aliases.insert(alias.to_string(), name.clone()); + } + + self.commands.insert(name, command); + } + + /// Get command by name or alias + pub fn get_command(&self, name: &str) -> Option<&dyn ChatCommand> { + let actual_name = name.to_string(); + let command_name = self.aliases.get(name).unwrap_or(&actual_name); + self.commands.get(command_name).map(|cmd| cmd.as_ref()) + } + + /// Execute a command with arguments + pub fn execute_command( + &self, + name: &str, + args: Vec<String>, + context: &CommandContext, + ) -> Result<ChatOpResult, ChatOpError> { + match self.get_command(name) { + Some(command) => { + // Check permissions + if !self.check_permission(&context.role, &command.required_role()) { + return Err(ChatOpError::PermissionDenied( + format!("Command '{}' requires {:?} role or higher", name, command.required_role()) + )); + } + + command.execute(args, context) + } + None => Err(ChatOpError::Generic(format!("Unknown command: {}", name))), + } + } + + /// List all available commands for a user role + pub fn list_commands(&self, role: &UserRole) -> Vec<(&str, &str)> { + self.commands + .values() + .filter(|cmd| self.check_permission(role, &cmd.required_role())) + .map(|cmd| (cmd.name(), cmd.description())) + .collect() + } + + /// Get help for a specific command + pub fn get_help(&self, name: &str) -> Option<String> { + self.get_command(name).map(|cmd| { + format!( + "**{}** - {}\n\nUsage: {}\n\nAliases: {}", + cmd.name(), + cmd.description(), + cmd.usage(), + if cmd.aliases().is_empty() { + "none".to_string() + } else { + cmd.aliases().join(", ") + } + ) + }) + } + + /// Check if user role has permission for required role + fn check_permission(&self, user_role: &UserRole, required_role: &UserRole) -> bool { + let user_level = self.role_level(user_role); + let required_level = self.role_level(required_role); + user_level >= required_level + } + + /// Convert role to numeric level for comparison + fn role_level(&self, role: &UserRole) -> u8 { + match role { + UserRole::Guest => 0, + UserRole::Member => 1, + UserRole::Staff => 2, + UserRole::Admin => 3, + } + } + + /// Register a user alias for a command + #[allow(dead_code)] + pub fn register_alias(&mut self, alias: String, target: String) { + if self.commands.contains_key(&target) { + self.aliases.insert(alias, target); + } + } + + /// Remove a user alias + #[allow(dead_code)] + pub fn remove_alias(&mut self, alias: &str) { + self.aliases.remove(alias); + } +} diff --git a/src/chatops/result.rs b/src/chatops/result.rs @@ -0,0 +1,94 @@ +use std::fmt; + +/// Result type for ChatOps command execution +#[derive(Debug, Clone)] +pub enum ChatOpResult { + /// Single message to send to chat + Message(String), + /// Multiple lines/blocks to send (paginated if needed) + Block(Vec<String>), + /// No output (silent success) + #[allow(dead_code)] + Silent, + /// Formatted code block + CodeBlock(String, Option<String>), // content, language + /// Error message + Error(String), +} + +impl ChatOpResult { + /// Convert to strings for chat output + pub fn to_messages(&self) -> Vec<String> { + match self { + ChatOpResult::Message(msg) => vec![msg.clone()], + ChatOpResult::Block(lines) => lines.clone(), + ChatOpResult::Silent => vec![], + ChatOpResult::CodeBlock(content, lang) => { + let lang_str = lang.as_deref().unwrap_or("text"); + vec![format!("```{}\n{}\n```", lang_str, content)] + } + ChatOpResult::Error(err) => vec![format!("❌ Error: {}", err)], + } + } + + /// Check if result should be truncated for chat + pub fn should_truncate(&self, max_lines: usize) -> bool { + self.to_messages().len() > max_lines + } + + /// Truncate result for chat output + pub fn truncate(&self, max_lines: usize) -> ChatOpResult { + let messages = self.to_messages(); + if messages.len() <= max_lines { + return self.clone(); + } + + let message_count = messages.len(); + let mut truncated = messages.into_iter().take(max_lines - 1).collect::<Vec<_>>(); + truncated.push(format!("... ({} more lines truncated)", message_count - max_lines + 1)); + ChatOpResult::Block(truncated) + } +} + +/// Error type for ChatOps commands +#[derive(Debug, Clone)] +pub enum ChatOpError { + /// Invalid command syntax + InvalidSyntax(String), + /// Missing required arguments + MissingArguments(String), + /// Permission denied + PermissionDenied(String), + /// External tool/service error + #[allow(dead_code)] + ExternalError(String), + /// Network/connectivity error + NetworkError(String), + /// File system error + #[allow(dead_code)] + FileSystemError(String), + /// Generic error with message + Generic(String), +} + +impl fmt::Display for ChatOpError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ChatOpError::InvalidSyntax(msg) => write!(f, "Invalid syntax: {}", msg), + ChatOpError::MissingArguments(msg) => write!(f, "Missing arguments: {}", msg), + ChatOpError::PermissionDenied(msg) => write!(f, "Permission denied: {}", msg), + ChatOpError::ExternalError(msg) => write!(f, "External error: {}", msg), + ChatOpError::NetworkError(msg) => write!(f, "Network error: {}", msg), + ChatOpError::FileSystemError(msg) => write!(f, "File system error: {}", msg), + ChatOpError::Generic(msg) => write!(f, "{}", msg), + } + } +} + +impl std::error::Error for ChatOpError {} + +impl From<ChatOpError> for ChatOpResult { + fn from(error: ChatOpError) -> Self { + ChatOpResult::Error(error.to_string()) + } +} diff --git a/src/main.rs b/src/main.rs @@ -2,8 +2,10 @@ mod bhc; mod harm; mod lechatphp; mod util; +mod chatops; use crate::lechatphp::LoginErr; +use crate::chatops::{ChatOpsRouter, UserRole}; use anyhow::{anyhow, Context}; use async_openai::{ config::OpenAIConfig, @@ -27,7 +29,7 @@ use crossterm::event::{MouseEvent, MouseEventKind}; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, KeyModifiers}, execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, Clear, ClearType}, }; use harm::{action_from_score, score_message, Action}; use lazy_static::lazy_static; @@ -63,7 +65,7 @@ use tui::{ layout::{Constraint, Direction, Layout}, style::{Modifier, Style}, text::{Span, Spans, Text}, - widgets::{Block, Borders, List, ListItem, Paragraph}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, Frame, Terminal, }; use unicode_width::UnicodeWidthStr; @@ -188,7 +190,7 @@ struct Opts { manual_captcha: bool, #[arg(short, long, env = "BHC_GUEST_COLOR")] guest_color: Option<String>, - #[arg(short, long, env = "BHC_REFRESH_RATE", default_value = "5")] + #[arg(short, long, env = "BHC_REFRESH_RATE", default_value = "1")] refresh_rate: u64, #[arg(long, env = "BHC_MAX_LOGIN_RETRY", default_value = "5")] max_login_retry: isize, @@ -310,6 +312,9 @@ struct LeChatPHPClient { mod_logs_enabled: Arc<Mutex<bool>>, openai_client: Option<async_openai::Client<async_openai::config::OpenAIConfig>>, ai_conversation_memory: Arc<Mutex<std::collections::HashMap<String, Vec<(String, String)>>>>, // user -> (role, message) history + + // ChatOps system + chatops_router: ChatOpsRouter, } impl LeChatPHPClient { @@ -480,55 +485,57 @@ impl LeChatPHPClient { let moderation_strictness = self.moderation_strictness.clone(); let mod_logs_enabled = Arc::clone(&self.mod_logs_enabled); let ai_conversation_memory = Arc::clone(&self.ai_conversation_memory); - thread::spawn(move || loop { + thread::spawn(move || { let (_stream, stream_handle) = OutputStream::try_default().unwrap(); - let source = Decoder::new_mp3(Cursor::new(SOUND1)).unwrap(); - let mut should_notify = false; - - if let Err(err) = get_msgs( - &client, - &base_url, - &page_php, - &session, - &username, - &users, - &sig, - &messages_updated_tx, - &members_tag, - &staffs_tag, - &datetime_fmt, - &messages, - &mut should_notify, - &tx, - &bad_usernames, - &bad_exact_usernames, - &bad_messages, - &allowlist, - alt_account.as_deref(), - master_account.as_deref(), - &alt_forwarding_enabled, - &ai_enabled, - &ai_mode, - &openai_client, - &system_intel, - &moderation_strictness, - &mod_logs_enabled, - &ai_conversation_memory, - ) { - log::error!("{}", err); - }; - - let muted = { *is_muted.lock().unwrap() }; - if should_notify && !muted { - if let Err(err) = stream_handle.play_raw(source.convert_samples()) { + loop { + let mut should_notify = false; + + if let Err(err) = get_msgs( + &client, + &base_url, + &page_php, + &session, + &username, + &users, + &sig, + &messages_updated_tx, + &members_tag, + &staffs_tag, + &datetime_fmt, + &messages, + &mut should_notify, + &tx, + &bad_usernames, + &bad_exact_usernames, + &bad_messages, + &allowlist, + alt_account.as_deref(), + master_account.as_deref(), + &alt_forwarding_enabled, + &ai_enabled, + &ai_mode, + &openai_client, + &system_intel, + &moderation_strictness, + &mod_logs_enabled, + &ai_conversation_memory, + ) { log::error!("{}", err); + }; + + let muted = { *is_muted.lock().unwrap() }; + if should_notify && !muted { + let source = Decoder::new_mp3(Cursor::new(SOUND1)).unwrap(); + if let Err(err) = stream_handle.play_raw(source.convert_samples()) { + log::error!("{}", err); + } } - } - let timeout = after(Duration::from_secs(refresh_rate)); - select! { - recv(&exit_rx) -> _ => return, - recv(&timeout) -> _ => {}, + let timeout = after(Duration::from_secs(refresh_rate)); + select! { + recv(&exit_rx) -> _ => return, + recv(&timeout) -> _ => {}, + } } }) } @@ -827,7 +834,45 @@ impl LeChatPHPClient { } } + /// Determine user role based on current client state + fn determine_user_role(&self) -> UserRole { + // This is a simplified role determination - in a real implementation, + // you'd check the user's actual permissions from the server + if self.master_account.is_some() { + UserRole::Admin + } else if self.alt_account.is_some() { + UserRole::Staff + } else if !self.display_guest_view { + UserRole::Member + } else { + UserRole::Guest + } + } + fn process_command(&mut self, input: &str, app: &mut App, users: &Arc<Mutex<Users>>) -> bool { + self.process_command_with_target(input, app, users, None) + } + + fn process_command_with_target(&mut self, input: &str, app: &mut App, users: &Arc<Mutex<Users>>, target: Option<String>) -> bool { + // First, try ChatOps commands + let user_role = self.determine_user_role(); + if let Some(chatops_result) = self.chatops_router.process_command(input, &self.base_client.username, user_role) { + // Convert ChatOps result to chat messages + let messages = chatops_result.to_messages(); + for message in messages { + // Special case: /help command should always go to @0 (user 0) + let message_target = if input.trim() == "/help" { + Some("0".to_owned()) + } else { + // Use the provided target, or None for main chat + target.clone() + }; + self.post_msg(PostType::Post(message, message_target)).unwrap(); + } + return true; + } + + // Continue with existing commands if input == "/dl" { self.post_msg(PostType::DeleteLast).unwrap(); } else if let Some(captures) = DLX_RGX.captures(input) { @@ -1207,6 +1252,34 @@ impl LeChatPHPClient { Chat Commands: /pm <user> <message> - Send private message to user /m <message> - Send message to members only +/s <message> - Send message to staff only + +ChatOps Developer Commands (30+ tools available): +/man <command> - Manual pages for system commands +/doc <lang> <term> - Language-specific documentation +/github <user/repo> - GitHub repository information +/crates <crate> - Rust crate information from crates.io +/npm <package> - NPM package information +/hash <algo> <text> - Generate cryptographic hashes +/uuid - Generate UUID v4 +/base64 <encode|decode> - Base64 encoding/decoding +/regex <pattern> <text> - Test regular expressions +/whois <domain> - Domain WHOIS lookup +/dig <domain> - DNS record lookup +/ping <host> - Test network connectivity +/time - Current timestamp info +/explain <concept> - AI explanations of concepts +/translate <lang> <text> - Translate text between languages +... and 15+ more tools + +Use '/commands' to see all ChatOps commands for your role. +Use '/help <command>' for detailed help on ChatOps commands. + +ChatOps Command Prefixes: +/pm <user> /command - Send ChatOps result as PM to user +/m /command - Send ChatOps result to members channel +/s /command - Send ChatOps result to staff channel +(no prefix) - Send ChatOps result to main chat AI Commands: /ai off - Completely disable AI (no moderation, no replies) @@ -1263,6 +1336,18 @@ Note: Some commands require appropriate permissions."#; self.post_msg(PostType::Post(help_text.to_string(), Some("0".to_owned()))) .unwrap(); + } else if input == "/commands" { + // List all ChatOps commands available to user + let user_role = self.determine_user_role(); + if let Some(chatops_result) = self.chatops_router.process_command("/list", &self.base_client.username, user_role) { + let messages = chatops_result.to_messages(); + for message in messages { + self.post_msg(PostType::Post(message, Some("0".to_owned()))).unwrap(); + } + } else { + let msg = "ChatOps commands not available.".to_string(); + self.post_msg(PostType::Post(msg, Some("0".to_owned()))).unwrap(); + } } else if input == "/status" { let ai_enabled = *self.ai_enabled.lock().unwrap(); let ai_mode = self.ai_mode.lock().unwrap().clone(); @@ -1413,6 +1498,9 @@ Connection: InputMode::Editing | InputMode::EditingErr => { self.handle_editing_mode_key_event(app, key_event, users) } + InputMode::MultilineEditing => { + self.handle_multiline_editing_mode_key_event(app, key_event, users) + } } } @@ -1743,6 +1831,26 @@ Connection: .. } => self.handle_editing_mode_key_event_ctrl_v(app), KeyEvent { + code: KeyCode::Char('.'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_editing_mode_key_event_external_editor(app, users)?, + KeyEvent { + code: KeyCode::Char('x'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_editing_mode_key_event_external_editor(app, users)?, + KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_editing_mode_key_event_external_editor(app, users)?, + KeyEvent { + code: KeyCode::Char('l'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_editing_mode_key_event_toggle_multiline(app), + KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::NONE, .. @@ -1758,6 +1866,11 @@ Connection: .. } => self.handle_editing_mode_key_event_down(app), KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + .. + } => self.handle_editing_mode_key_event_up(app), + KeyEvent { code: KeyCode::Char(c), modifiers: KeyModifiers::NONE, .. @@ -2349,6 +2462,11 @@ Connection: input = replace_newline_escape(&input); app.input_idx = 0; + // Add to history if not empty + if !input.trim().is_empty() { + app.add_to_history(input.clone()); + } + // Iterate over commands and execute associated actions for (command, action) in &app.commands.commands { // log::error!("command :{} action :{}", command, action); @@ -2363,17 +2481,50 @@ Connection: let mut cmd_input = input.clone(); let mut members_prefix = false; - if cmd_input.starts_with("/m ") { + let mut staffs_prefix = false; + let mut pm_target: Option<String> = None; + + // Check for /pm prefix first + if let Some(captures) = PM_RGX.captures(&cmd_input) { + let username = captures[1].to_string(); + let remaining = captures[2].to_string(); + if remaining.starts_with('/') { + // This is a ChatOps command with PM target + pm_target = Some(username); + cmd_input = remaining; + } + } else if cmd_input.starts_with("/m ") { members_prefix = true; if remove_prefix(&cmd_input, "/m ").starts_with('/') { cmd_input = remove_prefix(&cmd_input, "/m ").to_owned(); } + } else if cmd_input.starts_with("/s ") { + staffs_prefix = true; + if remove_prefix(&cmd_input, "/s ").starts_with('/') { + cmd_input = remove_prefix(&cmd_input, "/s ").to_owned(); + } } - if self.process_command(&cmd_input, app, users) { + // Determine target for ChatOps commands + let chatops_target = if let Some(user) = pm_target.clone() { + Some(user) + } else if members_prefix { + Some(SEND_TO_MEMBERS.to_owned()) + } else if staffs_prefix { + Some(SEND_TO_STAFFS.to_owned()) + } else { + None + }; + + if self.process_command_with_target(&cmd_input, app, users, chatops_target) { if members_prefix { app.input = "/m ".to_owned(); app.input_idx = app.input.width(); + } else if staffs_prefix { + app.input = "/s ".to_owned(); + app.input_idx = app.input.width(); + } else if pm_target.is_some() { + // Don't reset input for PM - let user continue the conversation } return Ok(()); } @@ -2384,6 +2535,23 @@ Connection: self.post_msg(PostType::Post(msg, to)).unwrap(); app.input = "/m ".to_owned(); app.input_idx = app.input.width(); + } else if staffs_prefix { + let msg = remove_prefix(&input, "/s ").to_owned(); + let to = Some(SEND_TO_STAFFS.to_owned()); + self.post_msg(PostType::Post(msg, to)).unwrap(); + app.input = "/s ".to_owned(); + app.input_idx = app.input.width(); + } else if let Some(user) = pm_target { + // Handle PM that wasn't a ChatOps command + let msg = if let Some(captures) = PM_RGX.captures(&input) { + captures[2].to_string() + } else { + input.clone() + }; + let to = Some(user.clone()); + self.post_msg(PostType::Post(msg, to)).unwrap(); + app.input = format!("/pm {} ", user); + app.input_idx = app.input.width(); } else if input.starts_with("/a ") { let msg = remove_prefix(&input, "/a ").to_owned(); let to = Some(SEND_TO_ADMINS.to_owned()); @@ -2403,6 +2571,8 @@ Connection: app.input_mode = InputMode::EditingErr; } else { self.post_msg(PostType::Post(input, None)).unwrap(); + // Reset input mode to Normal after sending message + app.input_mode = InputMode::Normal; } } Ok(()) @@ -2495,6 +2665,190 @@ Connection: } } + fn handle_editing_mode_key_event_external_editor(&mut self, app: &mut App, users: &Arc<Mutex<Users>>) -> Result<(), ExitSignal> { + use std::fs; + use std::process::{Command, Stdio}; + use tempfile::NamedTempFile; + use std::io::{stdout, Write}; + + // Create a temporary file + let mut temp_file = match NamedTempFile::new() { + Ok(file) => file, + Err(e) => { + log::error!("Failed to create temp file: {}", e); + return Ok(()); + } + }; + + // Write current input content to the temp file + if !app.input.is_empty() { + if let Err(e) = temp_file.write_all(app.input.as_bytes()) { + log::error!("Failed to write to temp file: {}", e); + return Ok(()); + } + if let Err(e) = temp_file.flush() { + log::error!("Failed to flush temp file: {}", e); + return Ok(()); + } + } + + // Get the temp file path + let temp_path = match temp_file.path().to_str() { + Some(path) => path.to_string(), + None => { + log::error!("Failed to get temp file path"); + return Ok(()); + } + }; + + // Save the current input to restore if editor fails + let original_input = app.input.clone(); + let original_input_idx = app.input_idx; + + // Clear input to show editor is active + app.input.clear(); + app.input_idx = 0; + + // Properly shut down the terminal UI + let _ = disable_raw_mode(); + let _ = execute!(stdout(), LeaveAlternateScreen, Clear(ClearType::All)); + let _ = stdout().flush(); + + // Determine which editor to use + let editor = std::env::var("EDITOR").unwrap_or_else(|_| { + for editor in &["nvim", "vim", "nano", "vi"] { + if Command::new(editor).arg("--version").output().is_ok() { + return editor.to_string(); + } + } + "vi".to_string() + }); + + // Launch the editor with proper stdio inheritance + let status = Command::new(&editor) + .arg(&temp_path) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status(); + + // Immediately restore terminal UI regardless of editor result + let _ = enable_raw_mode(); + let _ = execute!(stdout(), EnterAlternateScreen, Clear(ClearType::All)); + let _ = stdout().flush(); + + match status { + Ok(exit_status) if exit_status.success() => { + // Read and process the edited content + match fs::read_to_string(&temp_path) { + Ok(content) => { + let content = content.trim_end().to_string(); + + if !content.is_empty() { + // Add to history if not empty + app.add_to_history(content.clone()); + + // Process and send the message directly, similar to handle_editing_mode_key_event_enter + let mut processed_content = replace_newline_escape(&content); + + // Check for commands and execute them + for (command, action) in &app.commands.commands { + let expected_input = format!("!{}", command); + if processed_content == expected_input { + if let Err(e) = self.post_msg(PostType::Post(action.clone(), None)) { + log::error!("Failed to send command from editor: {}", e); + } + return Ok(()); + } + } + + // Handle member/admin/staff prefixes + let mut members_prefix = false; + if processed_content.starts_with("/m ") { + members_prefix = true; + if remove_prefix(&processed_content, "/m ").starts_with('/') { + processed_content = remove_prefix(&processed_content, "/m ").to_owned(); + } + } + + // Process commands + if self.process_command(&processed_content, app, users) { + if members_prefix { + app.input = "/m ".to_owned(); + app.input_idx = app.input.width(); + } + return Ok(()); + } + + // Send the message + if members_prefix { + let msg = remove_prefix(&content, "/m ").to_owned(); + if let Err(e) = self.post_msg(PostType::Post(msg, Some(SEND_TO_MEMBERS.to_owned()))) { + log::error!("Failed to send message to members: {}", e); + } + app.input = "/m ".to_owned(); + app.input_idx = app.input.width(); + } else if processed_content.starts_with("/a ") { + let msg = remove_prefix(&processed_content, "/a ").to_owned(); + if let Err(e) = self.post_msg(PostType::Post(msg, Some(SEND_TO_ADMINS.to_owned()))) { + log::error!("Failed to send message to admins: {}", e); + } + app.input = "/a ".to_owned(); + app.input_idx = app.input.width(); + } else if processed_content.starts_with("/s ") { + let msg = remove_prefix(&processed_content, "/s ").to_owned(); + if let Err(e) = self.post_msg(PostType::Post(msg, Some(SEND_TO_STAFFS.to_owned()))) { + log::error!("Failed to send message to staffs: {}", e); + } + app.input = "/s ".to_owned(); + app.input_idx = app.input.width(); + } else { + if processed_content.starts_with("/") && !processed_content.starts_with("/me ") { + // Invalid command - put it back in input with error state + app.input = processed_content; + app.input_idx = app.input.chars().count(); + app.input_mode = InputMode::EditingErr; + } else { + // Send as regular message + if let Err(e) = self.post_msg(PostType::Post(processed_content, None)) { + log::error!("Failed to send message from editor: {}", e); + } + app.input.clear(); + app.input_idx = 0; + app.input_mode = InputMode::Normal; + } + } + } else { + // Empty content - just go back to normal mode + app.input.clear(); + app.input_idx = 0; + app.input_mode = InputMode::Normal; + } + } + Err(e) => { + log::error!("Failed to read edited file: {}", e); + // Restore original input on read error + app.input = original_input; + app.input_idx = original_input_idx; + } + } + } + Ok(_) => { + // Editor was cancelled/failed - restore original input + app.input = original_input; + app.input_idx = original_input_idx; + } + Err(e) => { + log::error!("Failed to launch editor {}: {}", editor, e); + // Restore original input on launch error + app.input = original_input; + app.input_idx = original_input_idx; + } + } + + Ok(()) + } + fn handle_editing_mode_key_event_newline(&mut self, app: &mut App) { let byte_position = byte_pos(&app.input, app.input_idx).unwrap(); app.input.insert(byte_position, '\n'); @@ -2513,12 +2867,8 @@ Connection: } } - fn handle_editing_mode_key_event_down(&mut self, app: &mut App) { - app.input_mode = InputMode::Normal; - app.items.next(); - } - fn handle_editing_mode_key_event_shift_c(&mut self, app: &mut App, c: char) { + app.reset_history_navigation(); let byte_position = byte_pos(&app.input, app.input_idx).unwrap(); app.input.insert(byte_position, c); @@ -2527,6 +2877,7 @@ Connection: } fn handle_editing_mode_key_event_backspace(&mut self, app: &mut App) { + app.reset_history_navigation(); if app.input_idx > 0 { app.input_idx -= 1; app.input = remove_at(&app.input, app.input_idx); @@ -2535,6 +2886,7 @@ Connection: } fn handle_editing_mode_key_event_delete(&mut self, app: &mut App) { + app.reset_history_navigation(); if app.input_idx > 0 && app.input_idx == app.input.width() { app.input_idx -= 1; } @@ -2544,6 +2896,284 @@ Connection: fn handle_editing_mode_key_event_esc(&mut self, app: &mut App) { app.input_mode = InputMode::Normal; + app.reset_history_navigation(); + } + + fn handle_editing_mode_key_event_up(&mut self, app: &mut App) { + // In multiline mode, handle cursor navigation first + if app.input_mode == InputMode::MultilineEditing { + let input = &app.input; + let lines: Vec<&str> = input.split('\n').collect(); + + // Calculate which line the cursor is on + let mut current_pos = 0; + let mut cursor_line = 0; + let mut chars_in_line = 0; + + for (line_idx, line) in lines.iter().enumerate() { + let line_len = line.chars().count(); + if current_pos + line_len >= app.input_idx { + cursor_line = line_idx; + chars_in_line = app.input_idx - current_pos; + break; + } + current_pos += line_len + 1; // +1 for newline + } + + // Try to move cursor to previous line + if cursor_line > 0 { + let prev_line = lines[cursor_line - 1]; + let prev_line_len = prev_line.chars().count(); + let new_pos_in_line = chars_in_line.min(prev_line_len); + + // Calculate new cursor position + let mut new_cursor_pos = 0; + for i in 0..(cursor_line - 1) { + new_cursor_pos += lines[i].chars().count(); + if i < cursor_line - 1 { + new_cursor_pos += 1; // for newline + } + } + if cursor_line > 1 { + new_cursor_pos += 1; // for newline before previous line + } + new_cursor_pos += new_pos_in_line; + + app.input_idx = new_cursor_pos; + } else { + // At first line, try history navigation + app.navigate_history_up(); + } + } else { + // Regular single-line mode, use history navigation + app.navigate_history_up(); + } + } + + fn handle_editing_mode_key_event_down(&mut self, app: &mut App) { + app.navigate_history_down(); + } + + fn handle_editing_mode_key_event_toggle_multiline(&mut self, app: &mut App) { + match app.input_mode { + InputMode::Editing | InputMode::EditingErr => { + app.input_mode = InputMode::MultilineEditing; + } + InputMode::MultilineEditing => { + app.input_mode = InputMode::Editing; + } + _ => {} + } + } + + fn handle_multiline_editing_mode_key_event( + &mut self, + app: &mut App, + key_event: KeyEvent, + users: &Arc<Mutex<Users>>, + ) -> Result<(), ExitSignal> { + match key_event { + // Send message on Ctrl+Enter in multiline mode + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_multiline_editing_mode_key_event_send(app, users)?, + // Add newline on Enter in multiline mode + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => self.handle_editing_mode_key_event_newline(app), + // Toggle back to single-line mode OR send if already in multiline + KeyEvent { + code: KeyCode::Char('l'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_multiline_editing_mode_key_event_ctrl_l(app, users)?, + // History navigation + KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + .. + } => self.handle_editing_mode_key_event_up(app), + KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::NONE, + .. + } => self.handle_multiline_editing_mode_key_event_down(app), + // All other editing keys work the same + KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + .. + } => self.handle_editing_mode_key_event_tab(app, users), + KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_multiline_editing_mode_key_event_ctrl_c(app), + KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_editing_mode_key_event_ctrl_a(app), + KeyEvent { + code: KeyCode::Char('e'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_editing_mode_key_event_ctrl_e(app), + KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_editing_mode_key_event_ctrl_f(app), + KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_editing_mode_key_event_ctrl_b(app), + KeyEvent { + code: KeyCode::Char('v'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_editing_mode_key_event_ctrl_v(app), + KeyEvent { + code: KeyCode::Char('.'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_editing_mode_key_event_external_editor(app, users)?, + KeyEvent { + code: KeyCode::Char('x'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_editing_mode_key_event_external_editor(app, users)?, + KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_editing_mode_key_event_external_editor(app, users)?, + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } => self.handle_editing_mode_key_event_left(app), + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + .. + } => self.handle_editing_mode_key_event_right(app), + KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::NONE, + .. + } + | KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::SHIFT, + .. + } => self.handle_multiline_editing_mode_key_event_shift_c(app, c), + KeyEvent { + code: KeyCode::Backspace, + modifiers: KeyModifiers::NONE, + .. + } => self.handle_editing_mode_key_event_backspace(app), + KeyEvent { + code: KeyCode::Delete, + modifiers: KeyModifiers::NONE, + .. + } => self.handle_editing_mode_key_event_delete(app), + KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + .. + } => self.handle_multiline_editing_mode_key_event_esc(app), + _ => {} + } + Ok(()) + } + + fn handle_multiline_editing_mode_key_event_send( + &mut self, + app: &mut App, + users: &Arc<Mutex<Users>>, + ) -> Result<(), ExitSignal> { + // Same logic as regular enter, but add to history first + if !app.input.trim().is_empty() { + app.add_to_history(app.input.clone()); + } + self.handle_editing_mode_key_event_enter(app, users) + } + + fn handle_multiline_editing_mode_key_event_ctrl_l( + &mut self, + app: &mut App, + users: &Arc<Mutex<Users>>, + ) -> Result<(), ExitSignal> { + // In multiline mode, Ctrl+L sends the message (like Ctrl+Enter) + self.handle_multiline_editing_mode_key_event_send(app, users) + } + + fn handle_multiline_editing_mode_key_event_down(&mut self, app: &mut App) { + // Handle cursor navigation in multiline content + let input = &app.input; + let lines: Vec<&str> = input.split('\n').collect(); + + // Calculate which line the cursor is on + let mut current_pos = 0; + let mut cursor_line = 0; + let mut chars_in_line = 0; + + for (line_idx, line) in lines.iter().enumerate() { + let line_len = line.chars().count(); + if current_pos + line_len >= app.input_idx { + cursor_line = line_idx; + chars_in_line = app.input_idx - current_pos; + break; + } + current_pos += line_len + 1; // +1 for newline + } + + // Try to move cursor to next line + if cursor_line + 1 < lines.len() { + let next_line = lines[cursor_line + 1]; + let next_line_len = next_line.chars().count(); + let new_pos_in_line = chars_in_line.min(next_line_len); + + // Calculate new cursor position + let mut new_cursor_pos = 0; + for i in 0..=cursor_line { + new_cursor_pos += lines[i].chars().count(); + if i < cursor_line { + new_cursor_pos += 1; // for newline + } + } + new_cursor_pos += 1; // for the newline between current and next line + new_cursor_pos += new_pos_in_line; + + app.input_idx = new_cursor_pos.min(input.chars().count()); + } else { + // At last line, try history navigation + app.navigate_history_down(); + } + } + + fn handle_multiline_editing_mode_key_event_shift_c(&mut self, app: &mut App, c: char) { + app.reset_history_navigation(); + self.handle_editing_mode_key_event_shift_c(app, c); + } + + fn handle_multiline_editing_mode_key_event_ctrl_c(&mut self, app: &mut App) { + app.reset_history_navigation(); + app.input_mode = InputMode::Normal; + app.clear_filter(); + app.input = "".to_owned(); + app.input_idx = 0; + } + + fn handle_multiline_editing_mode_key_event_esc(&mut self, app: &mut App) { + app.input_mode = InputMode::Normal; + app.reset_history_navigation(); } fn handle_mouse_event( @@ -3866,6 +4496,7 @@ fn new_default_le_chat_php_client(params: Params) -> LeChatPHPClient { mod_logs_enabled: Arc::new(Mutex::new(mod_logs_enabled)), openai_client, ai_conversation_memory: Arc::new(Mutex::new(std::collections::HashMap::new())), + chatops_router: ChatOpsRouter::new(), } } @@ -4028,9 +4659,9 @@ fn start_dkf_notifier(client: &Client, dkf_api_key: &str) { let client = client.clone(); let dkf_api_key = dkf_api_key.to_owned(); let mut last_known_date = Utc::now(); - thread::spawn(move || loop { + thread::spawn(move || { let (_stream, stream_handle) = OutputStream::try_default().unwrap(); - let source = Decoder::new_mp3(Cursor::new(SOUND1)).unwrap(); + loop { let params: Vec<(&str, String)> = vec![( "last_known_date", @@ -4046,6 +4677,7 @@ fn start_dkf_notifier(client: &Client, dkf_api_key: &str) { if let Ok(txt) = resp.text() { if let Ok(v) = serde_json::from_str::<DkfNotifierResp>(&txt) { if v.pm_sound || v.tagged_sound { + let source = Decoder::new_mp3(Cursor::new(SOUND1)).unwrap(); stream_handle.play_raw(source.convert_samples()).unwrap(); } last_known_date = DateTime::parse_from_rfc3339(&v.last_message_created_at) @@ -4055,6 +4687,7 @@ fn start_dkf_notifier(client: &Client, dkf_api_key: &str) { } } thread::sleep(Duration::from_secs(5)); + } }); } @@ -4065,29 +4698,30 @@ fn start_dnmx_mail_notifier(client: &Client, username: &str, password: &str) { client.post(login_url).form(&params).send().unwrap(); let client_clone = client.clone(); - thread::spawn(move || loop { + thread::spawn(move || { let (_stream, stream_handle) = OutputStream::try_default().unwrap(); - let source = Decoder::new_mp3(Cursor::new(SOUND1)).unwrap(); - - let right_url = format!("{}/src/right_main.php", DNMX_URL); - if let Ok(resp) = client_clone.get(right_url).send() { - let mut nb_mails = 0; - let doc = Document::from(resp.text().unwrap().as_str()); - if let Some(table) = doc.find(Name("table")).nth(7) { - table.find(Name("tr")).skip(1).for_each(|n| { - if let Some(td) = n.find(Name("td")).nth(2) { - if td.find(Name("b")).nth(0).is_some() { - nb_mails += 1; + loop { + let right_url = format!("{}/src/right_main.php", DNMX_URL); + if let Ok(resp) = client_clone.get(right_url).send() { + let mut nb_mails = 0; + let doc = Document::from(resp.text().unwrap().as_str()); + if let Some(table) = doc.find(Name("table")).nth(7) { + table.find(Name("tr")).skip(1).for_each(|n| { + if let Some(td) = n.find(Name("td")).nth(2) { + if td.find(Name("b")).nth(0).is_some() { + nb_mails += 1; + } } - } - }); - } - if nb_mails > 0 { - log::error!("{} new mails", nb_mails); - stream_handle.play_raw(source.convert_samples()).unwrap(); + }); + } + if nb_mails > 0 { + log::error!("{} new mails", nb_mails); + let source = Decoder::new_mp3(Cursor::new(SOUND1)).unwrap(); + stream_handle.play_raw(source.convert_samples()).unwrap(); + } } + thread::sleep(Duration::from_secs(60)); } - thread::sleep(Duration::from_secs(60)); }); } @@ -4640,12 +5274,18 @@ fn draw_terminal_frame( .split(f.size()); { + // Determine textbox height based on input mode + let textbox_height = match app.input_mode { + InputMode::MultilineEditing => 8, // Larger height for multiline mode + _ => 3, // Default height for single-line modes + }; + let chunks = Layout::default() .direction(Direction::Vertical) .constraints( [ Constraint::Length(1), - Constraint::Length(3), + Constraint::Length(textbox_height), Constraint::Min(1), ] .as_ref(), @@ -4797,6 +5437,16 @@ fn render_help_txt( Style::default(), ), InputMode::LongMessage => (vec![], Style::default()), + InputMode::MultilineEditing => ( + vec![ + Span::raw("Press "), + Span::styled("Ctrl+L", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to exit multiline mode, "), + Span::styled("Ctrl+Enter", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to send"), + ], + Style::default(), + ), }; msg.extend(vec![Span::raw(format!(" | {}", curr_user))]); if app.is_muted { @@ -4859,35 +5509,116 @@ fn render_help_txt( fn render_textbox(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &mut App, r: Rect) { let w = (r.width - 3) as usize; let str = app.input.clone(); - let mut input_str = str.as_str(); - let mut overflow = 0; - if app.input_idx >= w { - overflow = std::cmp::max(app.input.width() - w, 0); - input_str = &str[overflow..]; - } - let input = Paragraph::new(input_str) - .style(match app.input_mode { - InputMode::LongMessage => Style::default(), - InputMode::Normal => Style::default(), - InputMode::Editing => Style::default().fg(tuiColor::Yellow), - InputMode::EditingErr => Style::default().fg(tuiColor::Red), - }) - .block(Block::default().borders(Borders::ALL).title("Input")); - f.render_widget(input, r); + + // Handle multiline vs single line display differently + let (input_widget, cursor_x, cursor_y) = match app.input_mode { + InputMode::MultilineEditing => { + // For multiline, we need to properly handle line wrapping and newlines + let lines: Vec<&str> = str.split('\n').collect(); + let text_width = (r.width - 3) as usize; // Account for borders + let available_height = (r.height - 2) as usize; // Account for borders + + // Calculate total visual lines (including wrapped lines) + let mut total_visual_lines = 0; + let mut line_visual_counts = Vec::new(); + for line in &lines { + let line_len = line.chars().count(); + let visual_count = if line_len == 0 { 1 } else { (line_len + text_width - 1) / text_width }; + line_visual_counts.push(visual_count); + total_visual_lines += visual_count; + } + + // Calculate which line the cursor is on and position within that line + let mut cursor_line = 0; + let mut chars_before_cursor = 0; + let mut current_pos = 0; + let mut cursor_visual_line = 0; // Track visual lines including wrapping + + for (line_idx, line) in lines.iter().enumerate() { + let line_len = line.chars().count(); + if current_pos + line_len >= app.input_idx { + cursor_line = line_idx; + chars_before_cursor = app.input_idx - current_pos; + + // Calculate how many visual lines this cursor position creates due to wrapping + let chars_in_current_line = chars_before_cursor; + let wrapped_lines_before = chars_in_current_line / text_width; + cursor_visual_line += wrapped_lines_before; + chars_before_cursor = chars_in_current_line % text_width; + break; + } + current_pos += line_len + 1; // +1 for the newline character + cursor_visual_line += line_visual_counts[line_idx]; + } + + // Ensure cursor is within bounds + if cursor_line < lines.len() { + let current_line_len = lines[cursor_line].chars().count(); + chars_before_cursor = chars_before_cursor.min(current_line_len % text_width); + } + + // Auto-scroll to keep cursor visible + if cursor_visual_line < app.multiline_scroll_offset { + app.multiline_scroll_offset = cursor_visual_line; + } else if cursor_visual_line >= app.multiline_scroll_offset + available_height { + app.multiline_scroll_offset = cursor_visual_line - available_height + 1; + } + + // Ensure scroll offset doesn't exceed content + if total_visual_lines <= available_height { + app.multiline_scroll_offset = 0; + } else { + app.multiline_scroll_offset = app.multiline_scroll_offset.min(total_visual_lines - available_height); + } + + // Create the paragraph with proper line breaks and scrolling + let input = Paragraph::new(str.as_str()) + .style(Style::default().fg(tuiColor::Cyan)) + .block(Block::default().borders(Borders::ALL).title("Input (Multiline)")) + .wrap(Wrap { trim: false }) + .scroll((app.multiline_scroll_offset as u16, 0)); + + // Calculate cursor position accounting for wrapping and scrolling + let cursor_x = r.x + 1 + chars_before_cursor as u16; + let cursor_y = r.y + 1 + (cursor_visual_line - app.multiline_scroll_offset) as u16; + + (input, cursor_x, cursor_y) + } + _ => { + // Single line handling (existing logic) + let mut input_str = str.as_str(); + let mut overflow = 0; + if app.input_idx >= w { + overflow = std::cmp::max(app.input.width() - w, 0); + input_str = &str[overflow..]; + } + + let input = Paragraph::new(input_str) + .style(match app.input_mode { + InputMode::LongMessage => Style::default(), + InputMode::Normal => Style::default(), + InputMode::Editing => Style::default().fg(tuiColor::Yellow), + InputMode::EditingErr => Style::default().fg(tuiColor::Red), + InputMode::MultilineEditing => Style::default().fg(tuiColor::Cyan), + }) + .block(Block::default().borders(Borders::ALL).title("Input")); + + let cursor_x = r.x + app.input_idx as u16 - overflow as u16 + 1; + let cursor_y = r.y + 1; + + (input, cursor_x, cursor_y) + } + }; + + f.render_widget(input_widget, r); + + // Set cursor position based on input mode match app.input_mode { InputMode::LongMessage => {} - InputMode::Normal => - // Hide the cursor. `Frame` does this by default, so we don't need to do anything here - {} - - InputMode::Editing | InputMode::EditingErr => { - // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering - f.set_cursor( - // Put cursor past the end of the input text - r.x + app.input_idx as u16 - overflow as u16 + 1, - // Move one line down, from the border to the input line - r.y + 1, - ) + InputMode::Normal => {} + InputMode::Editing | InputMode::EditingErr | InputMode::MultilineEditing => { + // Make the cursor visible and position it correctly + f.set_cursor(cursor_x, cursor_y); } } } @@ -5055,6 +5786,7 @@ enum InputMode { Normal, Editing, EditingErr, + MultilineEditing, } /// App holds the state of the application @@ -5064,6 +5796,10 @@ struct App { input_idx: usize, /// Current input mode input_mode: InputMode, + /// Command history for up/down arrow navigation + command_history: Vec<String>, + command_history_index: Option<usize>, + temp_input: String, // Stores current input when browsing history is_muted: bool, show_sys: bool, display_guest_view: bool, @@ -5082,6 +5818,9 @@ struct App { display_staff_view: bool, display_master_pm_view: bool, clean_mode: bool, + + // Multiline input scrolling + multiline_scroll_offset: usize, } impl Default for App { @@ -5120,6 +5859,9 @@ impl Default for App { input: String::new(), input_idx: 0, input_mode: InputMode::Normal, + command_history: Vec::new(), + command_history_index: None, + temp_input: String::new(), is_muted: false, show_sys: false, display_guest_view: false, @@ -5137,6 +5879,7 @@ impl Default for App { display_staff_view: false, display_master_pm_view: false, clean_mode: false, + multiline_scroll_offset: 0, } } } @@ -5156,6 +5899,113 @@ impl App { self.input_idx = 0; } } + + fn add_to_history(&mut self, command: String) { + if !command.is_empty() && !command.trim().is_empty() { + // Remove duplicate if it exists + if let Some(pos) = self.command_history.iter().position(|x| *x == command) { + self.command_history.remove(pos); + } + // Add to the end (most recent) + self.command_history.push(command); + // Keep only last 100 commands + if self.command_history.len() > 100 { + self.command_history.remove(0); + } + } + // Reset history navigation + self.command_history_index = None; + self.temp_input.clear(); + } + + fn navigate_history_up(&mut self) { + if self.command_history.is_empty() { + return; + } + + let current_input = self.input.clone(); + + match self.command_history_index { + None => { + // First time navigating history, save current input + self.temp_input = current_input.clone(); + // Find the most recent command that starts with current input + let matching_commands: Vec<(usize, &String)> = self.command_history + .iter() + .enumerate() + .rev() + .filter(|(_, cmd)| { + if current_input.is_empty() { + true + } else { + cmd.starts_with(&current_input) + } + }) + .collect(); + + if let Some((idx, cmd)) = matching_commands.first() { + self.command_history_index = Some(*idx); + self.input = cmd.to_string(); + self.input_idx = self.input.chars().count(); + } + } + Some(current_idx) => { + // Find next older matching command + let matching_commands: Vec<(usize, &String)> = self.command_history + .iter() + .enumerate() + .rev() + .filter(|(idx, cmd)| { + *idx < current_idx && (self.temp_input.is_empty() || cmd.starts_with(&self.temp_input)) + }) + .collect(); + + if let Some((idx, cmd)) = matching_commands.first() { + self.command_history_index = Some(*idx); + self.input = cmd.to_string(); + self.input_idx = self.input.chars().count(); + } + } + } + } + + fn navigate_history_down(&mut self) { + if self.command_history.is_empty() { + return; + } + + match self.command_history_index { + None => { + // Not currently navigating history, do nothing + } + Some(current_idx) => { + // Find next newer matching command + let matching_commands: Vec<(usize, &String)> = self.command_history + .iter() + .enumerate() + .filter(|(idx, cmd)| { + *idx > current_idx && (self.temp_input.is_empty() || cmd.starts_with(&self.temp_input)) + }) + .collect(); + + if let Some((idx, cmd)) = matching_commands.first() { + self.command_history_index = Some(*idx); + self.input = cmd.to_string(); + self.input_idx = self.input.chars().count(); + } else { + // No newer commands, go back to original input + self.command_history_index = None; + self.input = self.temp_input.clone(); + self.input_idx = self.input.chars().count(); + } + } + } + } + + fn reset_history_navigation(&mut self) { + self.command_history_index = None; + self.temp_input.clear(); + } } pub enum Event<I> {