use super::error::Result;
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use crate::brain::agent::{ProgressCallback, ProgressEvent};
use crate::config::opencrabs_home;
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;
use qrcode::QrCode;
pub fn render_qr_unicode(data: &str) -> Option<String> {
let code = QrCode::new(data.as_bytes()).ok()?;
let matrix = code.to_colors();
let w = code.width();
let quiet = 4;
let total = w + quiet * 2;
let mut out = String::new();
let color_at = |x: usize, y: usize| -> qrcode::Color {
if x < quiet || x >= quiet + w || y < quiet || y >= quiet + w {
qrcode::Color::Light
} else {
matrix[(y - quiet) * w + (x - quiet)]
}
};
let mut y = 0;
while y < total {
for x in 0..total {
let top = color_at(x, y);
let bot = if y + 1 < total {
color_at(x, y + 1)
} else {
qrcode::Color::Light
};
let ch = match (top, bot) {
(qrcode::Color::Light, qrcode::Color::Light) => '\u{2588}', (qrcode::Color::Dark, qrcode::Color::Dark) => ' ', (qrcode::Color::Light, qrcode::Color::Dark) => '\u{2580}', (qrcode::Color::Dark, qrcode::Color::Light) => '\u{2584}', };
out.push(ch);
}
out.push('\n');
y += 2;
}
Some(out)
}
pub fn render_qr_png(data: &str) -> Option<Vec<u8>> {
let code = QrCode::new(data.as_bytes()).ok()?;
let img = code
.render::<image::Luma<u8>>()
.quiet_zone(true)
.module_dimensions(10, 10)
.build();
let mut bytes = std::io::Cursor::new(Vec::new());
img.write_to(&mut bytes, image::ImageFormat::Png).ok()?;
Some(bytes.into_inner())
}
pub struct WhatsAppConnectHandle {
pub qr_rx: tokio::sync::broadcast::Receiver<String>,
pub connected_rx: tokio::sync::broadcast::Receiver<()>,
pub error_rx: tokio::sync::broadcast::Receiver<String>,
pub wa_state: Arc<crate::channels::whatsapp::WhatsAppState>,
}
pub fn subscribe_whatsapp_pairing(
wa_state: &Arc<crate::channels::whatsapp::WhatsAppState>,
wipe_session: bool,
) -> WhatsAppConnectHandle {
if wipe_session {
let wa_dir = opencrabs_home().join("whatsapp");
let _ = std::fs::remove_file(wa_dir.join("session.db"));
let _ = std::fs::remove_file(wa_dir.join("session.db-wal"));
let _ = std::fs::remove_file(wa_dir.join("session.db-shm"));
wa_state.request_restart();
}
WhatsAppConnectHandle {
qr_rx: wa_state.subscribe_qr(),
connected_rx: wa_state.subscribe_connected(),
error_rx: wa_state.subscribe_error(),
wa_state: wa_state.clone(),
}
}
pub struct WhatsAppConnectTool {
progress: Option<ProgressCallback>,
whatsapp_state: Arc<crate::channels::whatsapp::WhatsAppState>,
}
impl WhatsAppConnectTool {
pub fn new(
progress: Option<ProgressCallback>,
whatsapp_state: Arc<crate::channels::whatsapp::WhatsAppState>,
) -> Self {
Self {
progress,
whatsapp_state,
}
}
}
#[async_trait]
impl Tool for WhatsAppConnectTool {
fn name(&self) -> &str {
"whatsapp_connect"
}
fn description(&self) -> &str {
"Connect WhatsApp to OpenCrabs. Generates a QR code that the user scans with their \
WhatsApp mobile app. Once scanned, WhatsApp messages from allowed phone numbers \
will be routed to the agent. Call this when the user asks to connect or set up WhatsApp."
}
fn input_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"allowed_phones": {
"type": "array",
"items": { "type": "string" },
"description": "Phone numbers to allow (E.164 format, e.g. '+15551234567'). If empty, all messages accepted."
}
}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::Network, ToolCapability::SystemModification]
}
async fn execute(&self, input: Value, context: &ToolExecutionContext) -> Result<ToolResult> {
let tool_phones: Vec<String> = input
.get("allowed_phones")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
if !tool_phones.is_empty()
&& let Err(e) = crate::config::Config::write_array(
"channels.whatsapp",
"allowed_phones",
&tool_phones,
)
{
tracing::error!("Failed to save WhatsApp allowed_phones: {}", e);
}
let wa_dir = opencrabs_home().join("whatsapp");
if let Err(e) = std::fs::create_dir_all(&wa_dir) {
tracing::error!("Failed to create WhatsApp dir: {}", e);
}
for f in ["session.db", "session.db-wal", "session.db-shm"] {
if let Err(e) = std::fs::remove_file(wa_dir.join(f))
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!("Failed to remove WhatsApp {}: {}", f, e);
}
}
self.whatsapp_state.request_restart();
if let Err(e) = crate::config::Config::write_key("channels.whatsapp", "enabled", "true") {
tracing::error!("Failed to enable WhatsApp in config: {}", e);
}
let mut qr_rx = self.whatsapp_state.subscribe_qr();
let mut connected_rx = self.whatsapp_state.subscribe_connected();
let sid = context.session_id;
let qr_displayed = tokio::time::timeout(Duration::from_secs(30), qr_rx.recv()).await;
match qr_displayed {
Ok(Ok(qr_code)) => {
if let Some(ref cb) = self.progress {
let img_marker = render_qr_png(&qr_code)
.and_then(|bytes| {
let dir = opencrabs_home().join("tmp");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("whatsapp_qr.png");
std::fs::write(&path, &bytes).ok().map(|_| path)
})
.map(|p| format!("\n\n<<IMG:{}>>", p.display()))
.unwrap_or_default();
let unicode = render_qr_unicode(&qr_code).unwrap_or_default();
cb(
sid,
ProgressEvent::IntermediateText {
text: format!(
"Scan this QR code with WhatsApp (Linked Devices → Link a \
Device):{img_marker}\n\n{unicode}"
),
reasoning: None,
},
);
}
}
Ok(Err(_)) => {
return Ok(ToolResult::error(
"WhatsApp agent not running. Enable WhatsApp in config and restart."
.to_string(),
));
}
Err(_) => {
if self.whatsapp_state.is_connected().await {
} else {
return Ok(ToolResult::error(
"Timed out waiting for QR code. Is the WhatsApp agent running?".to_string(),
));
}
}
}
match tokio::time::timeout(Duration::from_secs(120), connected_rx.recv()).await {
Ok(Ok(())) => Ok(ToolResult::success(
"WhatsApp connected successfully! Now listening for messages. \
Connection persists across restarts.\n\n\
IMPORTANT: Send a welcome message to the user RIGHT NOW \
using the `whatsapp_send` tool. Be wildly fun — talk like their \
best buddy from childhood. You can curse, be unhinged, hilarious, \
and totally informal. Use your personality, SOUL.md, and any context \
you have about the user. Make it memorable and uniquely yours. \
No generic corporate greetings — surprise them!"
.to_string(),
)),
_ => Ok(ToolResult::error(
"QR code expired or connection timed out (2 minutes). \
Run the tool again to get a new QR code."
.to_string(),
)),
}
}
}