1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4
5use crate::internal::storage::db::Database;
6use microclaw_core::llm_types::ToolDefinition;
7
8use super::{auth_context_from_input, authorize_chat_access, schema_object, Tool, ToolResult};
9
10pub struct SessionSearchTool {
29 db: Arc<Database>,
30}
31
32impl SessionSearchTool {
33 pub fn new(db: Arc<Database>) -> Self {
34 Self { db }
35 }
36}
37
38fn trim_snippet(content: &str, max_chars: usize) -> String {
39 if content.chars().count() <= max_chars {
40 return content.to_string();
41 }
42 let truncated: String = content.chars().take(max_chars).collect();
43 format!("{truncated}…")
44}
45
46#[async_trait]
47impl Tool for SessionSearchTool {
48 fn name(&self) -> &str {
49 "session_search"
50 }
51
52 fn definition(&self) -> ToolDefinition {
53 ToolDefinition {
54 name: self.name().into(),
55 description: "Search past conversation messages using SQLite FTS5. \
56 Use when the user references an earlier conversation, asks 'did we \
57 talk about X?', or you need cross-session recall. Returns ranked \
58 message snippets with chat metadata. Supports FTS5 syntax — simple \
59 words, quoted phrases, `NEAR`, `AND/OR/NOT`, and column prefix \
60 filters (e.g. `sender_name:alice content:refund`). \
61 By default the search is scoped to the current chat; \
62 pass `chat_id` to target a different chat the caller can access, \
63 or `all_chats: true` (control chats only) to search every chat."
64 .into(),
65 input_schema: schema_object(
66 json!({
67 "query": {
68 "type": "string",
69 "description": "FTS5 query string. Plain words are OR-joined; quote phrases for exact match."
70 },
71 "chat_id": {
72 "type": "integer",
73 "description": "Optional chat scope. Defaults to the caller's chat. Accessing other chats requires either the same caller_chat_id or a control chat."
74 },
75 "all_chats": {
76 "type": "boolean",
77 "description": "Control-chat-only opt-in to search every chat. Ignored for non-control callers."
78 },
79 "since": {
80 "type": "string",
81 "description": "Optional RFC3339 timestamp; only messages at or after this time are returned."
82 },
83 "max_results": {
84 "type": "integer",
85 "description": "Maximum rows to return (default 10, max 50)."
86 },
87 "snippet_chars": {
88 "type": "integer",
89 "description": "Maximum characters of message content to include per result (default 240)."
90 }
91 }),
92 &["query"],
93 ),
94 }
95 }
96
97 async fn execute(&self, input: serde_json::Value) -> ToolResult {
98 let query = match input.get("query").and_then(|v| v.as_str()) {
99 Some(q) if !q.trim().is_empty() => q.trim().to_string(),
100 _ => return ToolResult::error("Missing or empty required parameter: query".into()),
101 };
102
103 let Some(auth) = auth_context_from_input(&input) else {
104 return ToolResult::error(
105 "session_search requires an auth context (caller_chat_id) on the input".into(),
106 );
107 };
108
109 let all_chats = input
110 .get("all_chats")
111 .and_then(|v| v.as_bool())
112 .unwrap_or(false);
113 let explicit_chat_id = input.get("chat_id").and_then(|v| v.as_i64());
114
115 let scope_chat_id: Option<i64> = if all_chats {
120 if !auth.is_control_chat() {
121 return ToolResult::error(
122 "all_chats=true is only available for control chats".into(),
123 );
124 }
125 None
126 } else {
127 let target = explicit_chat_id.unwrap_or(auth.caller_chat_id);
128 if let Err(e) = authorize_chat_access(&input, target) {
129 return ToolResult::error(e);
130 }
131 Some(target)
132 };
133
134 let since = input
135 .get("since")
136 .and_then(|v| v.as_str())
137 .map(|s| s.to_string());
138 let max_results = input
139 .get("max_results")
140 .and_then(|v| v.as_u64())
141 .map(|n| n as usize)
142 .unwrap_or(10)
143 .clamp(1, 50);
144 let snippet_chars = input
145 .get("snippet_chars")
146 .and_then(|v| v.as_u64())
147 .map(|n| n as usize)
148 .unwrap_or(240)
149 .clamp(40, 2_000);
150
151 let db = self.db.clone();
152 let query_for_task = query.clone();
153 let since_for_task = since.clone();
154 let search = tokio::task::spawn_blocking(move || {
155 db.search_messages_fts(
156 &query_for_task,
157 scope_chat_id,
158 since_for_task.as_deref(),
159 max_results,
160 )
161 })
162 .await;
163
164 let results = match search {
165 Ok(Ok(rows)) => rows,
166 Ok(Err(e)) => {
167 return ToolResult::error(format!("session_search failed: {e}"));
168 }
169 Err(e) => {
170 return ToolResult::error(format!("session_search task join error: {e}"));
171 }
172 };
173
174 let scope_label = match scope_chat_id {
175 Some(cid) => format!("chat {cid}"),
176 None => "all chats".to_string(),
177 };
178
179 if results.is_empty() {
180 return ToolResult::success(format!(
181 "No messages matched FTS5 query {query:?} in {scope_label}"
182 ));
183 }
184
185 let mut lines = Vec::with_capacity(results.len() + 1);
186 lines.push(format!(
187 "{} match(es) for FTS5 query {query:?} in {scope_label}",
188 results.len()
189 ));
190 for m in &results {
191 let role = if m.is_from_bot { "bot" } else { "user" };
192 lines.push(format!(
193 "- [chat {chat}] {ts} ({role}={sender}): {snippet}",
194 chat = m.chat_id,
195 ts = m.timestamp,
196 sender = m.sender_name,
197 snippet = trim_snippet(&m.content, snippet_chars),
198 ));
199 }
200 ToolResult::success(lines.join("\n"))
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn trim_snippet_truncates_long_content() {
210 let long = "a".repeat(1000);
211 let out = trim_snippet(&long, 50);
212 assert!(out.ends_with('…'));
213 assert_eq!(out.chars().count(), 51);
214 }
215
216 #[test]
217 fn trim_snippet_passes_short_content() {
218 let text = "hello";
219 assert_eq!(trim_snippet(text, 100), text);
220 }
221
222 fn auth_input(caller_chat_id: i64, control_chat_ids: Vec<i64>) -> serde_json::Value {
223 json!({
224 "query": "anything",
225 "__microclaw_auth": {
226 "caller_channel": "telegram",
227 "caller_chat_id": caller_chat_id,
228 "control_chat_ids": control_chat_ids,
229 "env_files": [],
230 }
231 })
232 }
233
234 struct ScopedDir(std::path::PathBuf);
235 impl Drop for ScopedDir {
236 fn drop(&mut self) {
237 let _ = std::fs::remove_dir_all(&self.0);
238 }
239 }
240
241 fn make_tmp_db() -> (Arc<Database>, ScopedDir) {
242 let dir = std::env::temp_dir().join(format!("microclaw_test_{}", uuid::Uuid::new_v4()));
243 let db = Database::new(dir.to_str().unwrap()).expect("open db");
244 (Arc::new(db), ScopedDir(dir))
245 }
246
247 #[tokio::test]
248 async fn cross_chat_without_permission_is_rejected() {
249 let (db, _dir) = make_tmp_db();
250 let tool = SessionSearchTool::new(db);
251 let mut input = auth_input(100, vec![]);
252 input["chat_id"] = json!(200);
253 let result = tool.execute(input).await;
254 assert!(result.is_error, "expected permission error, got ok");
255 assert!(
256 result.content.contains("Permission denied"),
257 "expected permission-denied message, got: {}",
258 result.content
259 );
260 }
261
262 #[tokio::test]
263 async fn all_chats_denied_for_non_control_caller() {
264 let (db, _dir) = make_tmp_db();
265 let tool = SessionSearchTool::new(db);
266 let mut input = auth_input(100, vec![]);
267 input["all_chats"] = json!(true);
268 let result = tool.execute(input).await;
269 assert!(result.is_error);
270 assert!(
271 result.content.contains("control chats"),
272 "expected control-only message, got: {}",
273 result.content
274 );
275 }
276
277 #[tokio::test]
278 async fn all_chats_allowed_for_control_caller() {
279 let (db, _dir) = make_tmp_db();
280 let tool = SessionSearchTool::new(db);
281 let mut input = auth_input(100, vec![100]);
282 input["all_chats"] = json!(true);
283 let result = tool.execute(input).await;
284 assert!(
285 !result.is_error,
286 "expected ok for control chat, got: {}",
287 result.content
288 );
289 }
290
291 #[tokio::test]
292 async fn default_scope_is_caller_chat() {
293 let (db, _dir) = make_tmp_db();
294 let tool = SessionSearchTool::new(db);
295 let input = auth_input(100, vec![]);
296 let result = tool.execute(input).await;
297 assert!(!result.is_error, "expected ok, got: {}", result.content);
298 assert!(
299 result.content.contains("chat 100") || result.content.contains("No messages matched"),
300 "expected scope label 'chat 100', got: {}",
301 result.content
302 );
303 }
304}