1use super::*;
2
3#[derive(Debug, Clone)]
4pub struct StoredMessage {
5 pub id: String,
6 pub chat_id: i64,
7 pub sender_name: String,
8 pub content: String,
9 pub is_from_bot: bool,
10 pub timestamp: String,
11}
12
13#[derive(Debug, Clone)]
14pub struct ChatSummary {
15 pub chat_id: i64,
16 pub chat_title: Option<String>,
17 pub session_label: Option<String>,
18 pub chat_type: String,
19 pub last_message_time: String,
20 pub last_message_preview: Option<String>,
21}
22
23impl Database {
24 pub fn upsert_chat(
25 &self,
26 chat_id: i64,
27 chat_title: Option<&str>,
28 chat_type: &str,
29 ) -> Result<(), MicroClawError> {
30 let conn = self.lock_conn();
31 let now = chrono::Utc::now().to_rfc3339();
32 conn.execute(
33 "INSERT INTO chats (chat_id, chat_title, chat_type, last_message_time, channel, external_chat_id)
34 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
35 ON CONFLICT(chat_id) DO UPDATE SET
36 chat_title = COALESCE(?2, chat_title),
37 chat_type = ?3,
38 last_message_time = ?4,
39 channel = COALESCE(channel, ?5),
40 external_chat_id = COALESCE(external_chat_id, ?6)",
41 params![
42 chat_id,
43 chat_title,
44 chat_type,
45 now,
46 infer_channel_from_chat_type(chat_type),
47 chat_id.to_string()
48 ],
49 )?;
50 Ok(())
51 }
52
53 pub fn resolve_or_create_chat_id(
54 &self,
55 channel: &str,
56 external_chat_id: &str,
57 chat_title: Option<&str>,
58 chat_type: &str,
59 ) -> Result<i64, MicroClawError> {
60 let conn = self.lock_conn();
61 let now = chrono::Utc::now().to_rfc3339();
62
63 if let Some(chat_id) = conn
64 .query_row(
65 "SELECT chat_id FROM chats WHERE channel = ?1 AND external_chat_id = ?2 LIMIT 1",
66 params![channel, external_chat_id],
67 |row| row.get::<_, i64>(0),
68 )
69 .optional()?
70 {
71 conn.execute(
72 "UPDATE chats
73 SET chat_title = COALESCE(?2, chat_title),
74 chat_type = ?3,
75 last_message_time = ?4
76 WHERE chat_id = ?1",
77 params![chat_id, chat_title, chat_type, now],
78 )?;
79 return Ok(chat_id);
80 }
81
82 let preferred_chat_id = external_chat_id.parse::<i64>().ok();
83 if let Some(cid) = preferred_chat_id {
84 let occupied = conn
85 .query_row(
86 "SELECT 1 FROM chats WHERE chat_id = ?1 LIMIT 1",
87 params![cid],
88 |_| Ok(()),
89 )
90 .optional()?
91 .is_some();
92 if !occupied {
93 conn.execute(
94 "INSERT INTO chats(chat_id, chat_title, chat_type, last_message_time, channel, external_chat_id)
95 VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
96 params![cid, chat_title, chat_type, now, channel, external_chat_id],
97 )?;
98 return Ok(cid);
99 }
100 }
101
102 conn.execute(
103 "INSERT INTO chats(chat_title, chat_type, last_message_time, channel, external_chat_id)
104 VALUES(?1, ?2, ?3, ?4, ?5)",
105 params![chat_title, chat_type, now, channel, external_chat_id],
106 )?;
107 Ok(conn.last_insert_rowid())
108 }
109
110 pub fn find_chat_id(
111 &self,
112 channel: &str,
113 external_chat_id: &str,
114 ) -> Result<Option<i64>, MicroClawError> {
115 let conn = self.lock_conn();
116 conn.query_row(
117 "SELECT chat_id FROM chats WHERE channel = ?1 AND external_chat_id = ?2 LIMIT 1",
118 params![channel, external_chat_id],
119 |row| row.get::<_, i64>(0),
120 )
121 .optional()
122 .map_err(Into::into)
123 }
124
125 pub fn store_message(&self, msg: &StoredMessage) -> Result<(), MicroClawError> {
126 let conn = self.lock_conn();
127 conn.execute(
128 "INSERT OR REPLACE INTO messages (id, chat_id, sender_name, content, is_from_bot, timestamp)
129 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
130 params![
131 msg.id,
132 msg.chat_id,
133 msg.sender_name,
134 msg.content,
135 msg.is_from_bot as i32,
136 msg.timestamp,
137 ],
138 )?;
139 Ok(())
140 }
141
142 pub fn store_message_if_new(&self, msg: &StoredMessage) -> Result<bool, MicroClawError> {
143 let conn = self.lock_conn();
144 let affected = conn.execute(
145 "INSERT OR IGNORE INTO messages (id, chat_id, sender_name, content, is_from_bot, timestamp)
146 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
147 params![
148 msg.id,
149 msg.chat_id,
150 msg.sender_name,
151 msg.content,
152 msg.is_from_bot as i32,
153 msg.timestamp,
154 ],
155 )?;
156 Ok(affected > 0)
157 }
158
159 pub fn message_exists(&self, chat_id: i64, message_id: &str) -> Result<bool, MicroClawError> {
160 let conn = self.lock_conn();
161 let exists = conn
162 .query_row(
163 "SELECT 1 FROM messages WHERE chat_id = ?1 AND id = ?2 LIMIT 1",
164 params![chat_id, message_id],
165 |_| Ok(()),
166 )
167 .optional()?
168 .is_some();
169 Ok(exists)
170 }
171
172 pub fn get_recent_messages(
173 &self,
174 chat_id: i64,
175 limit: usize,
176 ) -> Result<Vec<StoredMessage>, MicroClawError> {
177 let conn = self.lock_conn();
178 let mut stmt = conn.prepare(
179 "SELECT id, chat_id, sender_name, content, is_from_bot, timestamp
180 FROM messages
181 WHERE chat_id = ?1
182 ORDER BY timestamp DESC
183 LIMIT ?2",
184 )?;
185
186 let messages = stmt
187 .query_map(params![chat_id, limit as i64], |row| {
188 Ok(StoredMessage {
189 id: row.get(0)?,
190 chat_id: row.get(1)?,
191 sender_name: row.get(2)?,
192 content: row.get(3)?,
193 is_from_bot: row.get::<_, i32>(4)? != 0,
194 timestamp: row.get(5)?,
195 })
196 })?
197 .collect::<Result<Vec<_>, _>>()?;
198
199 let mut messages = messages;
201 messages.reverse();
202 Ok(messages)
203 }
204
205 pub fn get_all_messages(&self, chat_id: i64) -> Result<Vec<StoredMessage>, MicroClawError> {
206 let conn = self.lock_conn();
207 let mut stmt = conn.prepare(
208 "SELECT id, chat_id, sender_name, content, is_from_bot, timestamp
209 FROM messages
210 WHERE chat_id = ?1
211 ORDER BY timestamp ASC",
212 )?;
213 let messages = stmt
214 .query_map(params![chat_id], |row| {
215 Ok(StoredMessage {
216 id: row.get(0)?,
217 chat_id: row.get(1)?,
218 sender_name: row.get(2)?,
219 content: row.get(3)?,
220 is_from_bot: row.get::<_, i32>(4)? != 0,
221 timestamp: row.get(5)?,
222 })
223 })?
224 .collect::<Result<Vec<_>, _>>()?;
225 Ok(messages)
226 }
227
228 pub fn get_chats_by_type(
229 &self,
230 chat_type: &str,
231 limit: usize,
232 ) -> Result<Vec<ChatSummary>, MicroClawError> {
233 let conn = self.lock_conn();
234 let mut stmt = conn.prepare(
235 "SELECT
236 c.chat_id,
237 c.chat_title,
238 s.label,
239 c.chat_type,
240 c.last_message_time,
241 (
242 SELECT m.content
243 FROM messages m
244 WHERE m.chat_id = c.chat_id
245 ORDER BY m.timestamp DESC
246 LIMIT 1
247 ) AS last_message_preview
248 FROM chats c
249 LEFT JOIN sessions s ON s.chat_id = c.chat_id
250 WHERE c.chat_type = ?1
251 ORDER BY c.last_message_time DESC
252 LIMIT ?2",
253 )?;
254 let chats = stmt
255 .query_map(params![chat_type, limit as i64], |row| {
256 Ok(ChatSummary {
257 chat_id: row.get(0)?,
258 chat_title: row.get(1)?,
259 session_label: row.get(2)?,
260 chat_type: row.get(3)?,
261 last_message_time: row.get(4)?,
262 last_message_preview: row.get(5)?,
263 })
264 })?
265 .collect::<Result<Vec<_>, _>>()?;
266 Ok(chats)
267 }
268
269 pub fn get_recent_chats(&self, limit: usize) -> Result<Vec<ChatSummary>, MicroClawError> {
270 let conn = self.lock_conn();
271 let mut stmt = conn.prepare(
272 "SELECT
273 c.chat_id,
274 c.chat_title,
275 s.label,
276 c.chat_type,
277 c.last_message_time,
278 (
279 SELECT m.content
280 FROM messages m
281 WHERE m.chat_id = c.chat_id
282 ORDER BY m.timestamp DESC
283 LIMIT 1
284 ) AS last_message_preview
285 FROM chats c
286 LEFT JOIN sessions s ON s.chat_id = c.chat_id
287 ORDER BY c.last_message_time DESC
288 LIMIT ?1",
289 )?;
290 let chats = stmt
291 .query_map(params![limit as i64], |row| {
292 Ok(ChatSummary {
293 chat_id: row.get(0)?,
294 chat_title: row.get(1)?,
295 session_label: row.get(2)?,
296 chat_type: row.get(3)?,
297 last_message_time: row.get(4)?,
298 last_message_preview: row.get(5)?,
299 })
300 })?
301 .collect::<Result<Vec<_>, _>>()?;
302 Ok(chats)
303 }
304
305 pub fn get_chat_type(&self, chat_id: i64) -> Result<Option<String>, MicroClawError> {
306 let conn = self.lock_conn();
307 let result = conn.query_row(
308 "SELECT chat_type FROM chats WHERE chat_id = ?1",
309 params![chat_id],
310 |row| row.get::<_, String>(0),
311 );
312 match result {
313 Ok(v) => Ok(Some(v)),
314 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
315 Err(e) => Err(e.into()),
316 }
317 }
318
319 pub fn get_chat_id_by_channel_and_title(
320 &self,
321 channel: &str,
322 chat_title: &str,
323 ) -> Result<Option<i64>, MicroClawError> {
324 let conn = self.lock_conn();
325 let result = conn.query_row(
326 "SELECT chat_id
327 FROM chats
328 WHERE channel = ?1 AND chat_title = ?2
329 ORDER BY last_message_time DESC
330 LIMIT 1",
331 params![channel, chat_title],
332 |row| row.get::<_, i64>(0),
333 );
334 match result {
335 Ok(v) => Ok(Some(v)),
336 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
337 Err(e) => Err(e.into()),
338 }
339 }
340
341 pub fn get_chat_channel(&self, chat_id: i64) -> Result<Option<String>, MicroClawError> {
342 let conn = self.lock_conn();
343 let result = conn.query_row(
344 "SELECT channel FROM chats WHERE chat_id = ?1",
345 params![chat_id],
346 |row| row.get::<_, Option<String>>(0),
347 );
348 match result {
349 Ok(v) => Ok(v),
350 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
351 Err(e) => Err(e.into()),
352 }
353 }
354
355 pub fn get_chat_external_id(&self, chat_id: i64) -> Result<Option<String>, MicroClawError> {
356 let conn = self.lock_conn();
357 let result = conn.query_row(
358 "SELECT external_chat_id FROM chats WHERE chat_id = ?1",
359 params![chat_id],
360 |row| row.get::<_, Option<String>>(0),
361 );
362 match result {
363 Ok(v) => Ok(v),
364 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
365 Err(e) => Err(e.into()),
366 }
367 }
368
369 pub fn get_messages_since_last_bot_response(
372 &self,
373 chat_id: i64,
374 max: usize,
375 fallback: usize,
376 ) -> Result<Vec<StoredMessage>, MicroClawError> {
377 let conn = self.lock_conn();
378
379 let last_bot_ts: Option<String> = conn
381 .query_row(
382 "SELECT timestamp FROM messages
383 WHERE chat_id = ?1 AND is_from_bot = 1
384 ORDER BY timestamp DESC LIMIT 1",
385 params![chat_id],
386 |row| row.get(0),
387 )
388 .ok();
389
390 let mut messages = if let Some(ts) = last_bot_ts {
391 let mut stmt = conn.prepare(
392 "SELECT id, chat_id, sender_name, content, is_from_bot, timestamp
393 FROM messages
394 WHERE chat_id = ?1 AND timestamp >= ?2
395 ORDER BY timestamp DESC
396 LIMIT ?3",
397 )?;
398 let rows = stmt
399 .query_map(params![chat_id, ts, max as i64], |row| {
400 Ok(StoredMessage {
401 id: row.get(0)?,
402 chat_id: row.get(1)?,
403 sender_name: row.get(2)?,
404 content: row.get(3)?,
405 is_from_bot: row.get::<_, i32>(4)? != 0,
406 timestamp: row.get(5)?,
407 })
408 })?
409 .collect::<Result<Vec<_>, _>>()?;
410 rows
411 } else {
412 let mut stmt = conn.prepare(
413 "SELECT id, chat_id, sender_name, content, is_from_bot, timestamp
414 FROM messages
415 WHERE chat_id = ?1
416 ORDER BY timestamp DESC
417 LIMIT ?2",
418 )?;
419 let rows = stmt
420 .query_map(params![chat_id, fallback as i64], |row| {
421 Ok(StoredMessage {
422 id: row.get(0)?,
423 chat_id: row.get(1)?,
424 sender_name: row.get(2)?,
425 content: row.get(3)?,
426 is_from_bot: row.get::<_, i32>(4)? != 0,
427 timestamp: row.get(5)?,
428 })
429 })?
430 .collect::<Result<Vec<_>, _>>()?;
431 rows
432 };
433
434 messages.reverse();
435 Ok(messages)
436 }
437
438 pub fn clear_chat_context(&self, chat_id: i64) -> Result<bool, MicroClawError> {
443 let conn = self.lock_conn();
444 let tx = conn.unchecked_transaction()?;
445 let mut affected = 0usize;
446 affected += tx.execute(
447 "DELETE FROM task_run_logs WHERE chat_id = ?1",
448 params![chat_id],
449 )?;
450 affected += tx.execute(
451 "DELETE FROM scheduled_task_dlq WHERE chat_id = ?1",
452 params![chat_id],
453 )?;
454 affected += tx.execute(
455 "DELETE FROM scheduled_tasks WHERE chat_id = ?1",
456 params![chat_id],
457 )?;
458 affected += tx.execute("DELETE FROM sessions WHERE chat_id = ?1", params![chat_id])?;
459 affected += tx.execute("DELETE FROM messages WHERE chat_id = ?1", params![chat_id])?;
460 tx.commit()?;
461 Ok(affected > 0)
462 }
463
464 pub fn clear_chat_conversation(&self, chat_id: i64) -> Result<bool, MicroClawError> {
467 let conn = self.lock_conn();
468 let tx = conn.unchecked_transaction()?;
469 let mut affected = 0usize;
470 affected += tx.execute("DELETE FROM sessions WHERE chat_id = ?1", params![chat_id])?;
471 affected += tx.execute("DELETE FROM messages WHERE chat_id = ?1", params![chat_id])?;
472 tx.commit()?;
473 Ok(affected > 0)
474 }
475
476 pub fn delete_chat_data(&self, chat_id: i64) -> Result<bool, MicroClawError> {
477 let conn = self.lock_conn();
478 let tx = conn.unchecked_transaction()?;
479 let mut affected = 0usize;
480
481 affected += erase_chat_learning(&tx, chat_id)?;
482 affected += tx.execute(
483 "DELETE FROM llm_usage_logs WHERE chat_id = ?1",
484 params![chat_id],
485 )?;
486 affected += tx.execute("DELETE FROM sessions WHERE chat_id = ?1", params![chat_id])?;
487 affected += tx.execute("DELETE FROM messages WHERE chat_id = ?1", params![chat_id])?;
488 affected += tx.execute(
489 "DELETE FROM scheduled_tasks WHERE chat_id = ?1",
490 params![chat_id],
491 )?;
492 affected += tx.execute(
493 "DELETE FROM memory_reflector_state WHERE chat_id = ?1",
494 params![chat_id],
495 )?;
496 affected += tx.execute(
497 "DELETE FROM memory_reflector_runs WHERE chat_id = ?1",
498 params![chat_id],
499 )?;
500 affected += tx.execute(
501 "DELETE FROM memory_injection_logs WHERE chat_id = ?1",
502 params![chat_id],
503 )?;
504 affected += tx.execute(
505 "DELETE FROM memory_supersede_edges
506 WHERE from_memory_id IN (SELECT id FROM memories WHERE chat_id = ?1)
507 OR to_memory_id IN (SELECT id FROM memories WHERE chat_id = ?1)",
508 params![chat_id],
509 )?;
510 affected += tx.execute("DELETE FROM memories WHERE chat_id = ?1", params![chat_id])?;
511 affected += tx.execute("DELETE FROM chats WHERE chat_id = ?1", params![chat_id])?;
512
513 tx.commit()?;
514 Ok(affected > 0)
515 }
516
517 pub fn get_new_user_messages_since(
520 &self,
521 chat_id: i64,
522 since: &str,
523 ) -> Result<Vec<StoredMessage>, MicroClawError> {
524 let conn = self.lock_conn();
525 let mut stmt = conn.prepare(
526 "SELECT id, chat_id, sender_name, content, is_from_bot, timestamp
527 FROM messages
528 WHERE chat_id = ?1 AND timestamp > ?2 AND is_from_bot = 0
529 ORDER BY timestamp ASC",
530 )?;
531 let messages = stmt
532 .query_map(params![chat_id, since], |row| {
533 Ok(StoredMessage {
534 id: row.get(0)?,
535 chat_id: row.get(1)?,
536 sender_name: row.get(2)?,
537 content: row.get(3)?,
538 is_from_bot: row.get::<_, i32>(4)? != 0,
539 timestamp: row.get(5)?,
540 })
541 })?
542 .collect::<Result<Vec<_>, _>>()?;
543 Ok(messages)
544 }
545
546 pub fn get_messages_since(
547 &self,
548 chat_id: i64,
549 since: &str,
550 limit: usize,
551 ) -> Result<Vec<StoredMessage>, MicroClawError> {
552 let conn = self.lock_conn();
553 let mut stmt = conn.prepare(
554 "SELECT id, chat_id, sender_name, content, is_from_bot, timestamp
555 FROM messages
556 WHERE chat_id = ?1 AND timestamp > ?2
557 ORDER BY timestamp ASC
558 LIMIT ?3",
559 )?;
560 let messages = stmt
561 .query_map(params![chat_id, since, limit as i64], |row| {
562 Ok(StoredMessage {
563 id: row.get(0)?,
564 chat_id: row.get(1)?,
565 sender_name: row.get(2)?,
566 content: row.get(3)?,
567 is_from_bot: row.get::<_, i32>(4)? != 0,
568 timestamp: row.get(5)?,
569 })
570 })?
571 .collect::<Result<Vec<_>, _>>()?;
572 Ok(messages)
573 }
574
575 pub fn search_messages_fts(
584 &self,
585 query: &str,
586 chat_id: Option<i64>,
587 since: Option<&str>,
588 limit: usize,
589 ) -> Result<Vec<StoredMessage>, MicroClawError> {
590 if query.trim().is_empty() {
591 return Ok(Vec::new());
592 }
593 let limit = limit.clamp(1, 200) as i64;
594 let conn = self.lock_conn();
595
596 let mut sql = String::from(
597 "SELECT m.id, m.chat_id, m.sender_name, m.content, m.is_from_bot, m.timestamp
598 FROM messages_fts f
599 JOIN messages m ON m.id = f.message_id AND m.chat_id = f.chat_id
600 WHERE f.content MATCH ?1",
601 );
602 let mut binds: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(query.to_string())];
603 if let Some(cid) = chat_id {
604 sql.push_str(" AND m.chat_id = ?");
605 sql.push_str(&(binds.len() + 1).to_string());
606 binds.push(Box::new(cid));
607 }
608 if let Some(ts) = since {
609 sql.push_str(" AND m.timestamp >= ?");
610 sql.push_str(&(binds.len() + 1).to_string());
611 binds.push(Box::new(ts.to_string()));
612 }
613 sql.push_str(" ORDER BY bm25(messages_fts), m.timestamp DESC LIMIT ?");
614 sql.push_str(&(binds.len() + 1).to_string());
615 binds.push(Box::new(limit));
616
617 let mut stmt = conn.prepare(&sql)?;
618 let bind_refs: Vec<&dyn rusqlite::ToSql> = binds.iter().map(|b| b.as_ref()).collect();
619 let messages = stmt
620 .query_map(
621 rusqlite::params_from_iter(bind_refs.iter().copied()),
622 |row| {
623 Ok(StoredMessage {
624 id: row.get(0)?,
625 chat_id: row.get(1)?,
626 sender_name: row.get(2)?,
627 content: row.get(3)?,
628 is_from_bot: row.get::<_, i32>(4)? != 0,
629 timestamp: row.get(5)?,
630 })
631 },
632 )?
633 .collect::<Result<Vec<_>, _>>()?;
634 Ok(messages)
635 }
636
637 pub fn count_messages_for_chat(&self, chat_id: i64) -> Result<i64, MicroClawError> {
640 let conn = self.lock_conn();
641 conn.query_row(
642 "SELECT COUNT(*) FROM messages WHERE chat_id = ?1",
643 params![chat_id],
644 |row| row.get(0),
645 )
646 .map_err(Into::into)
647 }
648
649 pub fn get_active_chat_ids_since(&self, since: &str) -> Result<Vec<i64>, MicroClawError> {
650 let conn = self.lock_conn();
651 let mut stmt = conn.prepare(
652 "SELECT DISTINCT chat_id FROM messages WHERE timestamp > ?1 AND is_from_bot = 0",
653 )?;
654 let ids = stmt
655 .query_map(params![since], |row| row.get::<_, i64>(0))?
656 .collect::<Result<Vec<_>, _>>()?;
657 Ok(ids)
658 }
659
660 pub fn list_idle_chats(&self, cutoff: &str, limit: usize) -> Result<Vec<i64>, MicroClawError> {
663 let conn = self.lock_conn();
664 let mut stmt = conn.prepare(
665 "SELECT chat_id FROM messages
666 GROUP BY chat_id
667 HAVING MAX(timestamp) < ?1
668 LIMIT ?2",
669 )?;
670 let ids = stmt
671 .query_map(params![cutoff, limit.max(1) as i64], |row| {
672 row.get::<_, i64>(0)
673 })?
674 .collect::<Result<Vec<_>, _>>()?;
675 Ok(ids)
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use crate::internal::storage::db::test_support::*;
683
684 #[test]
685 fn test_new_database_creates_tables() {
686 let (db, dir) = test_db();
687 let msgs = db.get_recent_messages(1, 10).unwrap();
689 assert!(msgs.is_empty());
690 let tasks = db.get_due_tasks("2099-01-01T00:00:00Z").unwrap();
691 assert!(tasks.is_empty());
692 cleanup(&dir);
693 }
694
695 #[test]
696 fn test_upsert_chat_insert_and_update() {
697 let (db, dir) = test_db();
698 db.upsert_chat(100, Some("Test Chat"), "group").unwrap();
699 db.upsert_chat(100, Some("New Title"), "group").unwrap();
701 db.upsert_chat(200, None, "private").unwrap();
703 cleanup(&dir);
704 }
705
706 #[test]
707 fn test_store_and_retrieve_message() {
708 let (db, dir) = test_db();
709 let msg = StoredMessage {
710 id: "msg1".into(),
711 chat_id: 100,
712 sender_name: "alice".into(),
713 content: "hello".into(),
714 is_from_bot: false,
715 timestamp: "2024-01-01T00:00:00Z".into(),
716 };
717 db.store_message(&msg).unwrap();
718
719 let messages = db.get_recent_messages(100, 10).unwrap();
720 assert_eq!(messages.len(), 1);
721 assert_eq!(messages[0].id, "msg1");
722 assert_eq!(messages[0].sender_name, "alice");
723 assert_eq!(messages[0].content, "hello");
724 assert!(!messages[0].is_from_bot);
725 cleanup(&dir);
726 }
727
728 #[test]
729 fn test_search_messages_fts_basic() {
730 let (db, dir) = test_db();
731 let now = chrono::Utc::now();
732 let messages = [
733 ("chat-1", 101, "alice", "Rust async futures are awesome"),
734 ("chat-2", 101, "bot", "I agree, tokio makes them easy"),
735 (
736 "chat-3",
737 101,
738 "alice",
739 "Let's talk about JavaScript promises instead",
740 ),
741 ("chat-4", 202, "bob", "Discussing Rust borrow checker"),
742 ];
743 for (i, (id, chat, sender, content)) in messages.iter().enumerate() {
744 let msg = StoredMessage {
745 id: (*id).into(),
746 chat_id: *chat,
747 sender_name: (*sender).into(),
748 content: (*content).into(),
749 is_from_bot: *sender == "bot",
750 timestamp: (now + chrono::Duration::seconds(i as i64)).to_rfc3339(),
751 };
752 db.store_message(&msg).unwrap();
753 }
754
755 let rust_hits = db.search_messages_fts("rust", None, None, 10).unwrap();
756 assert!(rust_hits.len() >= 2, "expected at least 2 rust matches");
757 assert!(rust_hits
758 .iter()
759 .all(|m| m.content.to_lowercase().contains("rust")));
760
761 let scoped = db.search_messages_fts("rust", Some(101), None, 10).unwrap();
762 assert!(scoped.iter().all(|m| m.chat_id == 101));
763
764 let empty = db.search_messages_fts("", None, None, 10).unwrap();
765 assert!(empty.is_empty());
766
767 let no_match = db
768 .search_messages_fts("nothingmatchesthis", None, None, 10)
769 .unwrap();
770 assert!(no_match.is_empty());
771
772 {
774 let conn = db.lock_conn();
775 conn.execute(
776 "DELETE FROM messages WHERE id = ?1 AND chat_id = ?2",
777 params!["chat-1", 101i64],
778 )
779 .unwrap();
780 }
781 let after_delete = db.search_messages_fts("awesome", None, None, 10).unwrap();
782 assert!(after_delete.is_empty());
783
784 cleanup(&dir);
785 }
786
787 #[test]
788 fn test_store_message_upsert() {
789 let (db, dir) = test_db();
790 let msg = StoredMessage {
791 id: "msg1".into(),
792 chat_id: 100,
793 sender_name: "alice".into(),
794 content: "original".into(),
795 is_from_bot: false,
796 timestamp: "2024-01-01T00:00:00Z".into(),
797 };
798 db.store_message(&msg).unwrap();
799
800 let msg2 = StoredMessage {
802 id: "msg1".into(),
803 chat_id: 100,
804 sender_name: "alice".into(),
805 content: "updated".into(),
806 is_from_bot: false,
807 timestamp: "2024-01-01T00:00:01Z".into(),
808 };
809 db.store_message(&msg2).unwrap();
810
811 let messages = db.get_recent_messages(100, 10).unwrap();
812 assert_eq!(messages.len(), 1);
813 assert_eq!(messages[0].content, "updated");
814 cleanup(&dir);
815 }
816
817 #[test]
818 fn test_message_exists() {
819 let (db, dir) = test_db();
820 assert!(!db.message_exists(100, "msg1").unwrap());
821
822 db.store_message(&StoredMessage {
823 id: "msg1".into(),
824 chat_id: 100,
825 sender_name: "alice".into(),
826 content: "hello".into(),
827 is_from_bot: false,
828 timestamp: "2024-01-01T00:00:00Z".into(),
829 })
830 .unwrap();
831
832 assert!(db.message_exists(100, "msg1").unwrap());
833 assert!(!db.message_exists(100, "msg2").unwrap());
834 assert!(!db.message_exists(200, "msg1").unwrap());
835 cleanup(&dir);
836 }
837
838 #[test]
839 fn test_store_message_if_new() {
840 let (db, dir) = test_db();
841 let msg = StoredMessage {
842 id: "msg-new".into(),
843 chat_id: 100,
844 sender_name: "alice".into(),
845 content: "hello".into(),
846 is_from_bot: false,
847 timestamp: "2024-01-01T00:00:00Z".into(),
848 };
849 assert!(db.store_message_if_new(&msg).unwrap());
850 assert!(!db.store_message_if_new(&msg).unwrap());
851 cleanup(&dir);
852 }
853
854 #[test]
855 fn test_get_recent_messages_ordering_and_limit() {
856 let (db, dir) = test_db();
857 for i in 0..5 {
858 let msg = StoredMessage {
859 id: format!("msg{i}"),
860 chat_id: 100,
861 sender_name: "alice".into(),
862 content: format!("message {i}"),
863 is_from_bot: false,
864 timestamp: format!("2024-01-01T00:00:0{i}Z"),
865 };
866 db.store_message(&msg).unwrap();
867 }
868
869 let messages = db.get_recent_messages(100, 3).unwrap();
871 assert_eq!(messages.len(), 3);
872 assert_eq!(messages[0].content, "message 2"); assert_eq!(messages[1].content, "message 3");
874 assert_eq!(messages[2].content, "message 4"); let messages = db.get_recent_messages(200, 10).unwrap();
878 assert!(messages.is_empty());
879 cleanup(&dir);
880 }
881
882 #[test]
883 fn test_get_messages_since_last_bot_response_with_bot_msg() {
884 let (db, dir) = test_db();
885
886 db.store_message(&StoredMessage {
888 id: "m1".into(),
889 chat_id: 100,
890 sender_name: "alice".into(),
891 content: "hi".into(),
892 is_from_bot: false,
893 timestamp: "2024-01-01T00:00:01Z".into(),
894 })
895 .unwrap();
896
897 db.store_message(&StoredMessage {
899 id: "m2".into(),
900 chat_id: 100,
901 sender_name: "bot".into(),
902 content: "hello!".into(),
903 is_from_bot: true,
904 timestamp: "2024-01-01T00:00:02Z".into(),
905 })
906 .unwrap();
907
908 db.store_message(&StoredMessage {
910 id: "m3".into(),
911 chat_id: 100,
912 sender_name: "alice".into(),
913 content: "how are you?".into(),
914 is_from_bot: false,
915 timestamp: "2024-01-01T00:00:03Z".into(),
916 })
917 .unwrap();
918
919 db.store_message(&StoredMessage {
921 id: "m4".into(),
922 chat_id: 100,
923 sender_name: "bob".into(),
924 content: "me too".into(),
925 is_from_bot: false,
926 timestamp: "2024-01-01T00:00:04Z".into(),
927 })
928 .unwrap();
929
930 let messages = db
931 .get_messages_since_last_bot_response(100, 50, 10)
932 .unwrap();
933 assert!(messages.len() >= 2);
935 assert_eq!(messages[0].id, "m2"); assert_eq!(messages[1].id, "m3");
938 assert_eq!(messages[2].id, "m4");
939 cleanup(&dir);
940 }
941
942 #[test]
943 fn test_get_messages_since_last_bot_response_no_bot_msg() {
944 let (db, dir) = test_db();
945
946 for i in 0..5 {
947 db.store_message(&StoredMessage {
948 id: format!("m{i}"),
949 chat_id: 100,
950 sender_name: "alice".into(),
951 content: format!("msg {i}"),
952 is_from_bot: false,
953 timestamp: format!("2024-01-01T00:00:0{i}Z"),
954 })
955 .unwrap();
956 }
957
958 let messages = db.get_messages_since_last_bot_response(100, 50, 3).unwrap();
960 assert_eq!(messages.len(), 3);
961 assert_eq!(messages[0].content, "msg 2");
962 assert_eq!(messages[2].content, "msg 4");
963 cleanup(&dir);
964 }
965
966 #[test]
967 fn test_get_all_messages() {
968 let (db, dir) = test_db();
969 for i in 0..5 {
970 db.store_message(&StoredMessage {
971 id: format!("msg{i}"),
972 chat_id: 100,
973 sender_name: "alice".into(),
974 content: format!("message {i}"),
975 is_from_bot: false,
976 timestamp: format!("2024-01-01T00:00:0{i}Z"),
977 })
978 .unwrap();
979 }
980
981 let messages = db.get_all_messages(100).unwrap();
982 assert_eq!(messages.len(), 5);
983 assert_eq!(messages[0].content, "message 0");
984 assert_eq!(messages[4].content, "message 4");
985
986 assert!(db.get_all_messages(200).unwrap().is_empty());
988 cleanup(&dir);
989 }
990
991 #[test]
992 fn test_clear_chat_memory_removes_memories_but_keeps_conversation() {
993 let (db, dir) = test_db();
994 db.upsert_chat(100, Some("chat-100"), "private").unwrap();
995 db.save_session(100, r#"[{"role":"user","content":"hi"}]"#)
996 .unwrap();
997 db.store_message(&StoredMessage {
998 id: "m1".into(),
999 chat_id: 100,
1000 sender_name: "alice".into(),
1001 content: "hello".into(),
1002 is_from_bot: false,
1003 timestamp: "2024-01-01T00:00:01Z".into(),
1004 })
1005 .unwrap();
1006 db.insert_memory(Some(100), "User likes Rust", "PROFILE")
1007 .unwrap();
1008
1009 assert!(db.clear_chat_memory(100).unwrap());
1010 assert!(db.search_memories(100, "Rust", 10).unwrap().is_empty());
1011 assert!(db.load_session(100).unwrap().is_some());
1012 assert!(!db.get_recent_messages(100, 10).unwrap().is_empty());
1013 assert!(db.get_chat_type(100).unwrap().is_some());
1014
1015 cleanup(&dir);
1016 }
1017
1018 #[test]
1019 fn test_get_new_user_messages_since() {
1020 let (db, dir) = test_db();
1021
1022 db.store_message(&StoredMessage {
1024 id: "m1".into(),
1025 chat_id: 100,
1026 sender_name: "alice".into(),
1027 content: "old msg".into(),
1028 is_from_bot: false,
1029 timestamp: "2024-01-01T00:00:01Z".into(),
1030 })
1031 .unwrap();
1032
1033 db.store_message(&StoredMessage {
1035 id: "m2".into(),
1036 chat_id: 100,
1037 sender_name: "bot".into(),
1038 content: "response".into(),
1039 is_from_bot: true,
1040 timestamp: "2024-01-01T00:00:02Z".into(),
1041 })
1042 .unwrap();
1043
1044 db.store_message(&StoredMessage {
1046 id: "m3".into(),
1047 chat_id: 100,
1048 sender_name: "alice".into(),
1049 content: "new msg 1".into(),
1050 is_from_bot: false,
1051 timestamp: "2024-01-01T00:00:03Z".into(),
1052 })
1053 .unwrap();
1054
1055 db.store_message(&StoredMessage {
1056 id: "m4".into(),
1057 chat_id: 100,
1058 sender_name: "bob".into(),
1059 content: "new msg 2".into(),
1060 is_from_bot: false,
1061 timestamp: "2024-01-01T00:00:04Z".into(),
1062 })
1063 .unwrap();
1064
1065 db.store_message(&StoredMessage {
1067 id: "m5".into(),
1068 chat_id: 100,
1069 sender_name: "bot".into(),
1070 content: "bot again".into(),
1071 is_from_bot: true,
1072 timestamp: "2024-01-01T00:00:05Z".into(),
1073 })
1074 .unwrap();
1075
1076 let msgs = db
1077 .get_new_user_messages_since(100, "2024-01-01T00:00:02Z")
1078 .unwrap();
1079 assert_eq!(msgs.len(), 2);
1080 assert_eq!(msgs[0].content, "new msg 1");
1081 assert_eq!(msgs[1].content, "new msg 2");
1082
1083 cleanup(&dir);
1084 }
1085
1086 #[test]
1087 fn test_get_messages_since_includes_user_and_bot() {
1088 let (db, dir) = test_db();
1089 db.store_message(&StoredMessage {
1090 id: "m1".into(),
1091 chat_id: 100,
1092 sender_name: "alice".into(),
1093 content: "old".into(),
1094 is_from_bot: false,
1095 timestamp: "2024-01-01T00:00:01Z".into(),
1096 })
1097 .unwrap();
1098 db.store_message(&StoredMessage {
1099 id: "m2".into(),
1100 chat_id: 100,
1101 sender_name: "bot".into(),
1102 content: "bot".into(),
1103 is_from_bot: true,
1104 timestamp: "2024-01-01T00:00:02Z".into(),
1105 })
1106 .unwrap();
1107 db.store_message(&StoredMessage {
1108 id: "m3".into(),
1109 chat_id: 100,
1110 sender_name: "alice".into(),
1111 content: "new".into(),
1112 is_from_bot: false,
1113 timestamp: "2024-01-01T00:00:03Z".into(),
1114 })
1115 .unwrap();
1116
1117 let msgs = db
1118 .get_messages_since(100, "2024-01-01T00:00:01Z", 10)
1119 .unwrap();
1120 assert_eq!(msgs.len(), 2);
1121 assert_eq!(msgs[0].id, "m2");
1122 assert_eq!(msgs[1].id, "m3");
1123
1124 cleanup(&dir);
1125 }
1126
1127 #[test]
1128 fn test_resolve_or_create_chat_id_channel_scoped() {
1129 let (db, dir) = test_db();
1130
1131 let tg = db
1132 .resolve_or_create_chat_id(
1133 "telegram",
1134 "12345",
1135 Some("telegram-12345"),
1136 "telegram_private",
1137 )
1138 .unwrap();
1139 let tg_again = db
1140 .resolve_or_create_chat_id(
1141 "telegram",
1142 "12345",
1143 Some("telegram-12345"),
1144 "telegram_private",
1145 )
1146 .unwrap();
1147 assert_eq!(tg, tg_again);
1148
1149 let discord = db
1150 .resolve_or_create_chat_id("discord", "12345", Some("discord-12345"), "discord")
1151 .unwrap();
1152 assert_ne!(tg, discord);
1153 assert_eq!(db.find_chat_id("telegram", "12345").unwrap(), Some(tg));
1154 assert_eq!(db.find_chat_id("discord", "12345").unwrap(), Some(discord));
1155 assert_eq!(db.find_chat_id("work", "12345").unwrap(), None);
1156 assert_eq!(
1157 db.get_chat_external_id(discord).unwrap().as_deref(),
1158 Some("12345")
1159 );
1160
1161 cleanup(&dir);
1162 }
1163
1164 #[test]
1165 fn test_upsert_chat_preserves_existing_channel_identity() {
1166 let (db, dir) = test_db();
1167
1168 let scoped_chat_id = db
1169 .resolve_or_create_chat_id(
1170 "telegram.btcpos",
1171 "12345",
1172 Some("telegram-12345"),
1173 "telegram_private",
1174 )
1175 .unwrap();
1176
1177 db.upsert_chat(scoped_chat_id, Some("Updated title"), "telegram_private")
1178 .unwrap();
1179
1180 assert_eq!(
1181 db.get_chat_channel(scoped_chat_id).unwrap().as_deref(),
1182 Some("telegram.btcpos")
1183 );
1184 assert_eq!(
1185 db.get_chat_external_id(scoped_chat_id).unwrap().as_deref(),
1186 Some("12345")
1187 );
1188
1189 let scoped_again = db
1190 .resolve_or_create_chat_id(
1191 "telegram.btcpos",
1192 "12345",
1193 Some("telegram-12345"),
1194 "telegram_private",
1195 )
1196 .unwrap();
1197 assert_eq!(scoped_chat_id, scoped_again);
1198
1199 cleanup(&dir);
1200 }
1201
1202 #[test]
1203 fn test_resolve_or_create_chat_id_scopes_same_external_id_by_channel() {
1204 let (db, dir) = test_db();
1205
1206 let default_tg = db
1207 .resolve_or_create_chat_id(
1208 "telegram",
1209 "12345",
1210 Some("telegram-12345"),
1211 "telegram_private",
1212 )
1213 .unwrap();
1214 let scoped_tg = db
1215 .resolve_or_create_chat_id(
1216 "telegram.btcpos",
1217 "12345",
1218 Some("telegram-12345"),
1219 "telegram_private",
1220 )
1221 .unwrap();
1222
1223 assert_ne!(default_tg, scoped_tg);
1224
1225 let default_tg_again = db
1226 .resolve_or_create_chat_id(
1227 "telegram",
1228 "12345",
1229 Some("telegram-12345"),
1230 "telegram_private",
1231 )
1232 .unwrap();
1233 let scoped_tg_again = db
1234 .resolve_or_create_chat_id(
1235 "telegram.btcpos",
1236 "12345",
1237 Some("telegram-12345"),
1238 "telegram_private",
1239 )
1240 .unwrap();
1241
1242 assert_eq!(default_tg, default_tg_again);
1243 assert_eq!(scoped_tg, scoped_tg_again);
1244
1245 cleanup(&dir);
1246 }
1247
1248 #[test]
1249 fn test_get_chat_id_by_channel_and_title_finds_non_recent_chat() {
1250 let (db, dir) = test_db();
1251
1252 for i in 0..5000 {
1253 db.resolve_or_create_chat_id(
1254 "web",
1255 &format!("ext-{i}"),
1256 Some(&format!("title-{i}")),
1257 "web",
1258 )
1259 .unwrap();
1260 }
1261 let target = db
1262 .resolve_or_create_chat_id("web", "legacy-ext", Some("legacy-session"), "web")
1263 .unwrap();
1264 for i in 5000..9300 {
1265 db.resolve_or_create_chat_id(
1266 "web",
1267 &format!("ext-{i}"),
1268 Some(&format!("title-{i}")),
1269 "web",
1270 )
1271 .unwrap();
1272 }
1273
1274 let found = db
1275 .get_chat_id_by_channel_and_title("web", "legacy-session")
1276 .unwrap();
1277 assert_eq!(found, Some(target));
1278
1279 cleanup(&dir);
1280 }
1281
1282 #[test]
1283 fn test_migration_backfills_chat_identity_columns() {
1284 let dir = std::env::temp_dir().join(format!(
1285 "microclaw_migration_chat_identity_{}",
1286 uuid::Uuid::new_v4()
1287 ));
1288 std::fs::create_dir_all(&dir).unwrap();
1289 let db_path = dir.join("microclaw.db");
1290 let conn = Connection::open(&db_path).unwrap();
1291 conn.execute_batch(
1292 "CREATE TABLE chats (
1293 chat_id INTEGER PRIMARY KEY,
1294 chat_title TEXT,
1295 chat_type TEXT NOT NULL DEFAULT 'private',
1296 last_message_time TEXT NOT NULL
1297 );
1298 INSERT INTO chats(chat_id, chat_title, chat_type, last_message_time)
1299 VALUES (100, 'legacy tg', 'telegram_private', '2026-01-01T00:00:00Z');",
1300 )
1301 .unwrap();
1302 drop(conn);
1303
1304 let db = Database::new(dir.to_str().unwrap()).unwrap();
1305 let conn = db.lock_conn();
1306 let (channel, external): (Option<String>, Option<String>) = conn
1307 .query_row(
1308 "SELECT channel, external_chat_id FROM chats WHERE chat_id = 100",
1309 [],
1310 |row| Ok((row.get(0)?, row.get(1)?)),
1311 )
1312 .unwrap();
1313 assert_eq!(channel.as_deref(), Some("telegram"));
1314 assert_eq!(external.as_deref(), Some("100"));
1315 drop(conn);
1316
1317 cleanup(&dir);
1318 }
1319
1320 #[test]
1321 fn test_migration_backfills_memory_identity_columns() {
1322 let dir = std::env::temp_dir().join(format!(
1323 "microclaw_migration_memory_identity_{}",
1324 uuid::Uuid::new_v4()
1325 ));
1326 std::fs::create_dir_all(&dir).unwrap();
1327 let db_path = dir.join("microclaw.db");
1328 let conn = Connection::open(&db_path).unwrap();
1329 conn.execute_batch(
1330 "CREATE TABLE chats (
1331 chat_id INTEGER PRIMARY KEY,
1332 chat_title TEXT,
1333 chat_type TEXT NOT NULL DEFAULT 'private',
1334 last_message_time TEXT NOT NULL
1335 );
1336 INSERT INTO chats(chat_id, chat_title, chat_type, last_message_time)
1337 VALUES (200, 'legacy discord', 'discord', '2026-01-01T00:00:00Z');
1338
1339 CREATE TABLE memories (
1340 id INTEGER PRIMARY KEY AUTOINCREMENT,
1341 chat_id INTEGER,
1342 content TEXT NOT NULL,
1343 category TEXT NOT NULL,
1344 created_at TEXT NOT NULL,
1345 updated_at TEXT NOT NULL,
1346 embedding_model TEXT
1347 );
1348 INSERT INTO memories(chat_id, content, category, created_at, updated_at, embedding_model)
1349 VALUES (200, 'legacy memory', 'KNOWLEDGE', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);",
1350 )
1351 .unwrap();
1352 drop(conn);
1353
1354 let db = Database::new(dir.to_str().unwrap()).unwrap();
1355 let conn = db.lock_conn();
1356 let (chat_channel, external): (Option<String>, Option<String>) = conn
1357 .query_row(
1358 "SELECT chat_channel, external_chat_id FROM memories WHERE chat_id = 200",
1359 [],
1360 |row| Ok((row.get(0)?, row.get(1)?)),
1361 )
1362 .unwrap();
1363 assert_eq!(chat_channel.as_deref(), Some("discord"));
1364 assert_eq!(external.as_deref(), Some("200"));
1365 drop(conn);
1366
1367 cleanup(&dir);
1368 }
1369
1370 #[test]
1371 fn test_get_active_chat_ids_since() {
1372 let (db, dir) = test_db();
1373 db.store_message(&StoredMessage {
1374 id: "m1".into(),
1375 chat_id: 100,
1376 sender_name: "alice".into(),
1377 content: "hello".into(),
1378 is_from_bot: false,
1379 timestamp: "2024-06-01T00:00:01Z".into(),
1380 })
1381 .unwrap();
1382 db.store_message(&StoredMessage {
1383 id: "m2".into(),
1384 chat_id: 200,
1385 sender_name: "bob".into(),
1386 content: "hi".into(),
1387 is_from_bot: false,
1388 timestamp: "2024-06-01T00:00:02Z".into(),
1389 })
1390 .unwrap();
1391 db.store_message(&StoredMessage {
1393 id: "m3".into(),
1394 chat_id: 300,
1395 sender_name: "bot".into(),
1396 content: "bot msg".into(),
1397 is_from_bot: true,
1398 timestamp: "2024-06-01T00:00:03Z".into(),
1399 })
1400 .unwrap();
1401
1402 let ids = db
1403 .get_active_chat_ids_since("2024-06-01T00:00:00Z")
1404 .unwrap();
1405 assert_eq!(ids.len(), 2);
1406 assert!(ids.contains(&100));
1407 assert!(ids.contains(&200));
1408 assert!(!ids.contains(&300));
1409
1410 let ids_empty = db
1412 .get_active_chat_ids_since("2025-01-01T00:00:00Z")
1413 .unwrap();
1414 assert!(ids_empty.is_empty());
1415
1416 cleanup(&dir);
1417 }
1418
1419 #[test]
1420 fn test_list_idle_chats() {
1421 let (db, dir) = test_db();
1422 db.store_message(&StoredMessage {
1424 id: "old".into(),
1425 chat_id: 1,
1426 sender_name: "u".into(),
1427 content: "hi".into(),
1428 is_from_bot: false,
1429 timestamp: "2020-01-01T00:00:00Z".into(),
1430 })
1431 .unwrap();
1432 db.store_message(&StoredMessage {
1433 id: "new".into(),
1434 chat_id: 2,
1435 sender_name: "u".into(),
1436 content: "hi".into(),
1437 is_from_bot: false,
1438 timestamp: "2099-01-01T00:00:00Z".into(),
1439 })
1440 .unwrap();
1441 let idle = db.list_idle_chats("2030-01-01T00:00:00Z", 50).unwrap();
1442 assert!(idle.contains(&1));
1443 assert!(!idle.contains(&2));
1444
1445 assert_eq!(db.count_messages_for_chat(1).unwrap(), 1);
1447 assert_eq!(db.count_messages_for_chat(2).unwrap(), 1);
1448 assert_eq!(db.count_messages_for_chat(999).unwrap(), 0);
1449 cleanup(&dir);
1450 }
1451}