Skip to content

Commit 81193ae

Browse files
0xrinegadeclaude
andcommitted
feat(bbs): Add AI agent integration and mesh message persistence
- Integrate AiService with BBSCommandRouter for /agent command responses - Add mesh_messages table schema for storing Meshtastic radio messages - Create db/mesh_messages.rs with CRUD operations (create, add_response, get_recent, get_commands, get_since, count, prune_old) - Update TUI to save mesh messages to database with response tracking - Add migrations for existing databases to create mesh_messages table The /agent command now queries the AI service and returns responses truncated to fit Meshtastic's 228-byte message limit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 45cac80 commit 81193ae

File tree

7 files changed

+728
-12
lines changed

7 files changed

+728
-12
lines changed

src/commands/bbs_handler.rs

Lines changed: 156 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,8 @@ async fn handle_tui(matches: &ArgMatches) -> Result<()> {
756756
use ratatui::{backend::CrosstermBackend, Terminal};
757757
use std::io;
758758
use std::time::Duration;
759+
use tokio::sync::mpsc;
760+
use crate::utils::bbs::meshtastic::{MeshtasticClient, MeshtasticPacket};
759761

760762
let initial_board = matches.get_one::<String>("board").unwrap();
761763
let mesh_address = matches.get_one::<String>("mesh");
@@ -769,9 +771,29 @@ async fn handle_tui(matches: &ArgMatches) -> Result<()> {
769771
}
770772

771773
// Try to connect to Meshtastic if --mesh flag provided
774+
let mut mesh_rx: Option<mpsc::UnboundedReceiver<MeshtasticPacket>> = None;
772775
if let Some(addr) = mesh_address {
773776
println!("📻 Connecting to Meshtastic @ {}...", addr);
774-
bbs_state.try_connect_meshtastic(Some(addr));
777+
778+
// Try async connection
779+
if let Some(mut client) = MeshtasticClient::from_address(addr) {
780+
match client.connect_and_run().await {
781+
Ok(rx) => {
782+
bbs_state.agent_status.meshtastic_connected = true;
783+
bbs_state.agent_status.meshtastic_node_id = Some(format!("!{:08x}", client.our_node_id()));
784+
bbs_state.status_message = format!("📻 Connected to mesh @ {}", addr);
785+
mesh_rx = Some(rx);
786+
println!("✅ Meshtastic connected!");
787+
}
788+
Err(e) => {
789+
println!("⚠️ Meshtastic connection failed: {}", e);
790+
// Fall back to sync wrapper for status display only
791+
bbs_state.try_connect_meshtastic(Some(addr));
792+
}
793+
}
794+
} else {
795+
bbs_state.try_connect_meshtastic(Some(addr));
796+
}
775797
}
776798

777799
// Find and select the initial board
@@ -789,8 +811,8 @@ async fn handle_tui(matches: &ArgMatches) -> Result<()> {
789811
let backend = CrosstermBackend::new(stdout);
790812
let mut terminal = Terminal::new(backend)?;
791813

792-
// Main event loop
793-
let result = run_bbs_tui(&mut terminal, &mut bbs_state);
814+
// Main event loop with async mesh support
815+
let result = run_bbs_tui_async(&mut terminal, &mut bbs_state, mesh_rx).await;
794816

795817
// Restore terminal
796818
disable_raw_mode()?;
@@ -894,6 +916,137 @@ fn run_bbs_tui<B: ratatui::backend::Backend>(
894916
}
895917
}
896918

919+
/// Async BBS TUI event loop with Meshtastic support
920+
async fn run_bbs_tui_async<B: ratatui::backend::Backend>(
921+
terminal: &mut ratatui::Terminal<B>,
922+
state: &mut crate::utils::bbs::tui_widgets::BBSTuiState,
923+
mut mesh_rx: Option<tokio::sync::mpsc::UnboundedReceiver<crate::utils::bbs::meshtastic::MeshtasticPacket>>,
924+
) -> Result<()> {
925+
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
926+
use std::time::Duration;
927+
use crate::utils::bbs::meshtastic::{MeshtasticPacket, BBSCommandRouter};
928+
929+
loop {
930+
// Draw UI
931+
terminal.draw(|f| {
932+
crate::utils::bbs::tui_widgets::render_bbs_tab(f, f.area(), state);
933+
})?;
934+
935+
// Check for mesh messages (non-blocking)
936+
if let Some(ref mut rx) = mesh_rx {
937+
// Try to receive without blocking
938+
while let Ok(packet) = rx.try_recv() {
939+
match packet {
940+
MeshtasticPacket::TextMessage { from, message, .. } => {
941+
// Check if it's a BBS command
942+
let is_command = message.trim().starts_with('/');
943+
944+
// Add to mesh messages
945+
state.add_mesh_message(from, message.clone(), is_command);
946+
947+
// If it's a command, process it
948+
if is_command {
949+
if let Some(cmd) = BBSCommandRouter::parse_command(&message) {
950+
state.status_message = format!("📻 Cmd from !{:08x}: {:?}", from, cmd);
951+
}
952+
} else {
953+
state.status_message = format!("📻 Msg from !{:08x}: {}", from,
954+
if message.len() > 30 { format!("{}...", &message[..30]) } else { message });
955+
}
956+
}
957+
MeshtasticPacket::NodeInfo { node_id, short_name, long_name } => {
958+
state.update_mesh_node(node_id, short_name.clone());
959+
state.status_message = format!("📻 Node: !{:08x} = {}", node_id, short_name);
960+
}
961+
_ => {}
962+
}
963+
}
964+
}
965+
966+
// Handle keyboard input (with timeout for responsiveness)
967+
if event::poll(Duration::from_millis(50))? {
968+
if let Event::Key(key) = event::read()? {
969+
// Handle input mode first (when input is active, capture all chars)
970+
if state.input_active {
971+
match key.code {
972+
KeyCode::Enter => {
973+
if !state.input_buffer.trim().is_empty() {
974+
if let Err(e) = state.post_message(&state.input_buffer.clone()) {
975+
state.status_message = format!("Error: {}", e);
976+
} else {
977+
state.status_message = "Message posted!".to_string();
978+
let _ = state.load_posts();
979+
}
980+
state.input_buffer.clear();
981+
}
982+
state.input_active = false;
983+
}
984+
KeyCode::Esc => {
985+
state.input_buffer.clear();
986+
state.input_active = false;
987+
}
988+
KeyCode::Backspace => {
989+
state.input_buffer.pop();
990+
}
991+
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
992+
state.input_buffer.push(c);
993+
}
994+
_ => {}
995+
}
996+
continue;
997+
}
998+
999+
// Not in input mode - handle navigation
1000+
match key.code {
1001+
KeyCode::Char('q') | KeyCode::Esc => {
1002+
return Ok(());
1003+
}
1004+
KeyCode::Char('i') => {
1005+
state.input_active = true;
1006+
}
1007+
KeyCode::Char('j') | KeyCode::Down => {
1008+
state.scroll_offset = state.scroll_offset.saturating_add(1);
1009+
}
1010+
KeyCode::Char('k') | KeyCode::Up => {
1011+
state.scroll_offset = state.scroll_offset.saturating_sub(1);
1012+
}
1013+
KeyCode::Char('r') => {
1014+
let _ = state.refresh_boards();
1015+
let _ = state.refresh_agents();
1016+
if state.current_board.is_some() {
1017+
let _ = state.load_posts();
1018+
}
1019+
state.status_message = "Refreshed".to_string();
1020+
}
1021+
KeyCode::Char('m') => {
1022+
// Toggle showing mesh messages in posts view (future feature)
1023+
state.status_message = format!("📻 {} mesh messages", state.mesh_messages.len());
1024+
}
1025+
KeyCode::Char(c @ '1'..='9') => {
1026+
let board_idx = c.to_digit(10).unwrap() as usize - 1;
1027+
if board_idx < state.boards.len() {
1028+
let board_id = state.boards[board_idx].id;
1029+
let board_name = state.boards[board_idx].name.clone();
1030+
state.current_board = Some(board_id);
1031+
state.selected_board_index = Some(board_idx);
1032+
let _ = state.load_posts();
1033+
state.scroll_offset = 0;
1034+
state.status_message = format!("Switched to: {}", board_name);
1035+
}
1036+
}
1037+
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1038+
return Ok(());
1039+
}
1040+
_ => {}
1041+
}
1042+
}
1043+
}
1044+
1045+
// Small yield to allow mesh receiver to process
1046+
tokio::task::yield_now().await;
1047+
}
1048+
}
1049+
8971050
/// Handle stats command
8981051
async fn handle_stats(matches: &ArgMatches) -> Result<()> {
8991052
let mut conn = db_err(db::establish_connection())?;

0 commit comments

Comments
 (0)