156 lines
5.6 KiB
Python
156 lines
5.6 KiB
Python
"""Complete (hard) deletion of iris messages from the hermes session store.
|
|
|
|
The iris plugin mints its own message ids (``m_<hex>``) that are **not**
|
|
persisted in the hermes session DB (``state.db``), so a delete request cannot
|
|
join on an id. Instead a message is matched to its ``messages`` row by
|
|
(session, role, content, timestamp proximity) and that row is deleted.
|
|
|
|
Deleting a ``messages`` row also drops it from the FTS5 search index via the
|
|
``messages_fts_*_delete`` triggers, so **no search trace survives** and the
|
|
message is not recoverable.
|
|
|
|
Channel / thread deletion wipes the whole lane: every session (and its
|
|
messages) for the chat (a channel) or the specific thread.
|
|
|
|
The session DB is opened read-write with a busy timeout. It is normally held
|
|
by the gateway core in WAL mode, which allows one writer at a time, so a brief
|
|
write from a second connection is safe.
|
|
"""
|
|
|
|
import contextlib
|
|
import logging
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _connect(db_path: Path) -> sqlite3.Connection:
|
|
conn = sqlite3.connect(str(db_path), timeout=10.0)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def _flat_session_id(conn: sqlite3.Connection, chat_id: str) -> str | None:
|
|
"""The flat-lane session (thread_id NULL / empty) for *chat_id*."""
|
|
row = conn.execute(
|
|
"SELECT id FROM sessions WHERE chat_id = ? "
|
|
"AND (thread_id IS NULL OR thread_id = '') LIMIT 1",
|
|
(chat_id,),
|
|
).fetchone()
|
|
return row["id"] if row else None
|
|
|
|
|
|
def _thread_session_id(conn: sqlite3.Connection, chat_id: str, thread_id: str) -> str | None:
|
|
row = conn.execute(
|
|
"SELECT id FROM sessions WHERE chat_id = ? AND thread_id = ? LIMIT 1",
|
|
(chat_id, thread_id),
|
|
).fetchone()
|
|
return row["id"] if row else None
|
|
|
|
|
|
def delete_lane(db_path: Path, chat_id: str, thread_id: str | None = None) -> int:
|
|
"""Hard-delete a whole lane from the session store.
|
|
|
|
* ``thread_id is None`` -> a **channel**: every session under *chat_id*
|
|
(the flat lane plus all of its threads) and all of their messages.
|
|
* ``thread_id`` set -> a **thread**: the single session for
|
|
(chat_id, thread_id) and its messages.
|
|
|
|
Returns the number of message rows removed (0 when the lane is absent or
|
|
the DB is missing). Never raises: any DB error yields 0 (the caller still
|
|
deletes the directory entry + outbox frames, so the lane is gone from the
|
|
app's point of view even if the session-store wipe fails).
|
|
"""
|
|
db_path = Path(db_path)
|
|
if not db_path.exists():
|
|
return 0
|
|
conn = _connect(db_path)
|
|
try:
|
|
if thread_id:
|
|
rows = conn.execute(
|
|
"SELECT id FROM sessions WHERE chat_id = ? AND thread_id = ?",
|
|
(chat_id, thread_id),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute("SELECT id FROM sessions WHERE chat_id = ?", (chat_id,)).fetchall()
|
|
ids = [r["id"] for r in rows]
|
|
if not ids:
|
|
return 0
|
|
# Delete per session with fully parameterized queries (a channel has
|
|
# only a handful of sessions: the flat lane plus its threads). Messages
|
|
# first (foreign_keys is not enforced on this DB), then the session
|
|
# rows. The FTS delete triggers fire on the message deletes.
|
|
n_msgs = 0
|
|
for sid in ids:
|
|
cur = conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
|
n_msgs += max(0, cur.rowcount)
|
|
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
|
|
conn.commit()
|
|
return n_msgs
|
|
except sqlite3.Error as e:
|
|
logger.warning("iris purge: delete_lane failed: %s", e)
|
|
return 0
|
|
finally:
|
|
with contextlib.suppress(Exception):
|
|
conn.close()
|
|
|
|
|
|
def delete_message(
|
|
db_path: Path,
|
|
chat_id: str,
|
|
thread_id: str | None,
|
|
role: str,
|
|
content: str,
|
|
ts_ms: int | None,
|
|
) -> int:
|
|
"""Hard-delete a single message from the session store.
|
|
|
|
Matches the ``messages`` row by (session, role, content) and, when several
|
|
rows share that content, the one whose timestamp is closest to *ts_ms*.
|
|
The content must match exactly (after stripping) -- a mismatch deletes
|
|
nothing rather than the wrong message. Returns 1 when a row was removed,
|
|
0 otherwise. Never raises.
|
|
"""
|
|
db_path = Path(db_path)
|
|
if not db_path.exists() or not content or not role:
|
|
return 0
|
|
conn = _connect(db_path)
|
|
try:
|
|
sid = (
|
|
_thread_session_id(conn, chat_id, thread_id)
|
|
if thread_id
|
|
else _flat_session_id(conn, chat_id)
|
|
)
|
|
if sid is None:
|
|
return 0
|
|
ts_s = (ts_ms or 0) / 1000.0
|
|
want = content.strip()
|
|
rows = conn.execute(
|
|
"SELECT id, timestamp, content FROM messages WHERE session_id = ? AND role = ?",
|
|
(sid, role),
|
|
).fetchall()
|
|
target: int | None = None
|
|
best_dt: float | None = None
|
|
for r in rows:
|
|
if (r["content"] or "").strip() != want:
|
|
continue
|
|
try:
|
|
dt = abs(float(r["timestamp"]) - ts_s)
|
|
except (TypeError, ValueError):
|
|
dt = 0.0
|
|
if best_dt is None or dt < best_dt:
|
|
best_dt = dt
|
|
target = r["id"]
|
|
if target is None:
|
|
return 0
|
|
conn.execute("DELETE FROM messages WHERE id = ?", (target,))
|
|
conn.commit()
|
|
return 1
|
|
except sqlite3.Error as e:
|
|
logger.warning("iris purge: delete_message failed: %s", e)
|
|
return 0
|
|
finally:
|
|
with contextlib.suppress(Exception):
|
|
conn.close()
|