use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
use crate::internal::storage::db::Database;
use microclaw_core::llm_types::ToolDefinition;
use super::{auth_context_from_input, authorize_chat_access, schema_object, Tool, ToolResult};
pub struct SessionSearchTool {
db: Arc<Database>,
}
impl SessionSearchTool {
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
}
fn trim_snippet(content: &str, max_chars: usize) -> String {
if content.chars().count() <= max_chars {
return content.to_string();
}
let truncated: String = content.chars().take(max_chars).collect();
format!("{truncated}…")
}
#[async_trait]
impl Tool for SessionSearchTool {
fn name(&self) -> &str {
"session_search"
}
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: self.name().into(),
description: "Search past conversation messages using SQLite FTS5. \
Use when the user references an earlier conversation, asks 'did we \
talk about X?', or you need cross-session recall. Returns ranked \
message snippets with chat metadata. Supports FTS5 syntax — simple \
words, quoted phrases, `NEAR`, `AND/OR/NOT`, and column prefix \
filters (e.g. `sender_name:alice content:refund`). \
By default the search is scoped to the current chat; \
pass `chat_id` to target a different chat the caller can access, \
or `all_chats: true` (control chats only) to search every chat."
.into(),
input_schema: schema_object(
json!({
"query": {
"type": "string",
"description": "FTS5 query string. Plain words are OR-joined; quote phrases for exact match."
},
"chat_id": {
"type": "integer",
"description": "Optional chat scope. Defaults to the caller's chat. Accessing other chats requires either the same caller_chat_id or a control chat."
},
"all_chats": {
"type": "boolean",
"description": "Control-chat-only opt-in to search every chat. Ignored for non-control callers."
},
"since": {
"type": "string",
"description": "Optional RFC3339 timestamp; only messages at or after this time are returned."
},
"max_results": {
"type": "integer",
"description": "Maximum rows to return (default 10, max 50)."
},
"snippet_chars": {
"type": "integer",
"description": "Maximum characters of message content to include per result (default 240)."
}
}),
&["query"],
),
}
}
async fn execute(&self, input: serde_json::Value) -> ToolResult {
let query = match input.get("query").and_then(|v| v.as_str()) {
Some(q) if !q.trim().is_empty() => q.trim().to_string(),
_ => return ToolResult::error("Missing or empty required parameter: query".into()),
};
let Some(auth) = auth_context_from_input(&input) else {
return ToolResult::error(
"session_search requires an auth context (caller_chat_id) on the input".into(),
);
};
let all_chats = input
.get("all_chats")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let explicit_chat_id = input.get("chat_id").and_then(|v| v.as_i64());
let scope_chat_id: Option<i64> = if all_chats {
if !auth.is_control_chat() {
return ToolResult::error(
"all_chats=true is only available for control chats".into(),
);
}
None
} else {
let target = explicit_chat_id.unwrap_or(auth.caller_chat_id);
if let Err(e) = authorize_chat_access(&input, target) {
return ToolResult::error(e);
}
Some(target)
};
let since = input
.get("since")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let max_results = input
.get("max_results")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(10)
.clamp(1, 50);
let snippet_chars = input
.get("snippet_chars")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(240)
.clamp(40, 2_000);
let db = self.db.clone();
let query_for_task = query.clone();
let since_for_task = since.clone();
let search = tokio::task::spawn_blocking(move || {
db.search_messages_fts(
&query_for_task,
scope_chat_id,
since_for_task.as_deref(),
max_results,
)
})
.await;
let results = match search {
Ok(Ok(rows)) => rows,
Ok(Err(e)) => {
return ToolResult::error(format!("session_search failed: {e}"));
}
Err(e) => {
return ToolResult::error(format!("session_search task join error: {e}"));
}
};
let scope_label = match scope_chat_id {
Some(cid) => format!("chat {cid}"),
None => "all chats".to_string(),
};
if results.is_empty() {
return ToolResult::success(format!(
"No messages matched FTS5 query {query:?} in {scope_label}"
));
}
let mut lines = Vec::with_capacity(results.len() + 1);
lines.push(format!(
"{} match(es) for FTS5 query {query:?} in {scope_label}",
results.len()
));
for m in &results {
let role = if m.is_from_bot { "bot" } else { "user" };
lines.push(format!(
"- [chat {chat}] {ts} ({role}={sender}): {snippet}",
chat = m.chat_id,
ts = m.timestamp,
sender = m.sender_name,
snippet = trim_snippet(&m.content, snippet_chars),
));
}
ToolResult::success(lines.join("\n"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trim_snippet_truncates_long_content() {
let long = "a".repeat(1000);
let out = trim_snippet(&long, 50);
assert!(out.ends_with('…'));
assert_eq!(out.chars().count(), 51);
}
#[test]
fn trim_snippet_passes_short_content() {
let text = "hello";
assert_eq!(trim_snippet(text, 100), text);
}
fn auth_input(caller_chat_id: i64, control_chat_ids: Vec<i64>) -> serde_json::Value {
json!({
"query": "anything",
"__microclaw_auth": {
"caller_channel": "telegram",
"caller_chat_id": caller_chat_id,
"control_chat_ids": control_chat_ids,
"env_files": [],
}
})
}
struct ScopedDir(std::path::PathBuf);
impl Drop for ScopedDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn make_tmp_db() -> (Arc<Database>, ScopedDir) {
let dir = std::env::temp_dir().join(format!("microclaw_test_{}", uuid::Uuid::new_v4()));
let db = Database::new(dir.to_str().unwrap()).expect("open db");
(Arc::new(db), ScopedDir(dir))
}
#[tokio::test]
async fn cross_chat_without_permission_is_rejected() {
let (db, _dir) = make_tmp_db();
let tool = SessionSearchTool::new(db);
let mut input = auth_input(100, vec![]);
input["chat_id"] = json!(200);
let result = tool.execute(input).await;
assert!(result.is_error, "expected permission error, got ok");
assert!(
result.content.contains("Permission denied"),
"expected permission-denied message, got: {}",
result.content
);
}
#[tokio::test]
async fn all_chats_denied_for_non_control_caller() {
let (db, _dir) = make_tmp_db();
let tool = SessionSearchTool::new(db);
let mut input = auth_input(100, vec![]);
input["all_chats"] = json!(true);
let result = tool.execute(input).await;
assert!(result.is_error);
assert!(
result.content.contains("control chats"),
"expected control-only message, got: {}",
result.content
);
}
#[tokio::test]
async fn all_chats_allowed_for_control_caller() {
let (db, _dir) = make_tmp_db();
let tool = SessionSearchTool::new(db);
let mut input = auth_input(100, vec![100]);
input["all_chats"] = json!(true);
let result = tool.execute(input).await;
assert!(
!result.is_error,
"expected ok for control chat, got: {}",
result.content
);
}
#[tokio::test]
async fn default_scope_is_caller_chat() {
let (db, _dir) = make_tmp_db();
let tool = SessionSearchTool::new(db);
let input = auth_input(100, vec![]);
let result = tool.execute(input).await;
assert!(!result.is_error, "expected ok, got: {}", result.content);
assert!(
result.content.contains("chat 100") || result.content.contains("No messages matched"),
"expected scope label 'chat 100', got: {}",
result.content
);
}
}