mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Use database.journal_mode as the sole non-secret operator setting, preserve the vulnerable-SQLite safety gate and existing WAL databases, validate explicit DELETE results, document the active config path, and cover real SQLite openers with behavioral tests.
112 lines
3.9 KiB
Python
112 lines
3.9 KiB
Python
"""Durable state for Discord reconnect message recovery."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from hermes_constants import get_hermes_home
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_DB_FILENAME = "discord_message_recovery.db"
|
|
_RETENTION_DAYS = 30
|
|
|
|
|
|
class DiscordRecoveryStore:
|
|
"""Small profile-scoped SQLite ledger for completed Discord messages."""
|
|
|
|
def __init__(self, hermes_home: Path | None = None) -> None:
|
|
self._lock = threading.Lock()
|
|
self._initialized = False
|
|
self._hermes_home = Path(hermes_home or get_hermes_home())
|
|
|
|
def path(self) -> Path:
|
|
directory = self._hermes_home / "gateway"
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
return directory / _DB_FILENAME
|
|
|
|
def call(self, fn: Callable[[sqlite3.Connection], Any], default: Any = None) -> Any:
|
|
try:
|
|
with self._lock:
|
|
path = self.path()
|
|
conn = sqlite3.connect(path, timeout=0.1)
|
|
try:
|
|
if not self._initialized:
|
|
self._initialize(conn)
|
|
self._initialized = True
|
|
with suppress(OSError):
|
|
os.chmod(path, 0o600)
|
|
result = fn(conn)
|
|
conn.commit()
|
|
return result
|
|
finally:
|
|
conn.close()
|
|
except Exception as exc:
|
|
logger.warning("Discord recovery ledger unavailable: %s", exc)
|
|
return default
|
|
|
|
def _initialize(self, conn: sqlite3.Connection) -> None:
|
|
from hermes_state import apply_wal_with_fallback
|
|
|
|
apply_wal_with_fallback(conn, db_label="discord_recovery.db")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS discord_messages (
|
|
message_id TEXT PRIMARY KEY,
|
|
channel_id TEXT,
|
|
thread_id TEXT,
|
|
parent_channel_id TEXT,
|
|
author_id TEXT,
|
|
created_at TEXT,
|
|
status TEXT NOT NULL,
|
|
replied INTEGER NOT NULL DEFAULT 0,
|
|
emoji_ack INTEGER NOT NULL DEFAULT 0,
|
|
outage_response INTEGER NOT NULL DEFAULT 0,
|
|
response_message_id TEXT,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
last_attempt_at TEXT,
|
|
last_error TEXT,
|
|
updated_at TEXT NOT NULL
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS discord_recovery_scans (
|
|
scan_id TEXT PRIMARY KEY,
|
|
started_at TEXT NOT NULL,
|
|
completed_at TEXT,
|
|
status TEXT NOT NULL,
|
|
channels TEXT NOT NULL,
|
|
window_seconds REAL NOT NULL,
|
|
limit_count INTEGER NOT NULL,
|
|
scanned INTEGER NOT NULL DEFAULT 0,
|
|
missed INTEGER NOT NULL DEFAULT 0,
|
|
dispatched INTEGER NOT NULL DEFAULT 0,
|
|
error TEXT
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS discord_recovery_cursors (
|
|
channel_id TEXT PRIMARY KEY,
|
|
last_message_id TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)
|
|
""")
|
|
cutoff = (
|
|
dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=_RETENTION_DAYS)
|
|
).isoformat()
|
|
conn.execute("DELETE FROM discord_messages WHERE updated_at < ?", (cutoff,))
|
|
conn.execute(
|
|
"DELETE FROM discord_recovery_scans "
|
|
"WHERE COALESCE(completed_at, started_at) < ?",
|
|
(cutoff,),
|
|
)
|
|
conn.execute(
|
|
"DELETE FROM discord_recovery_cursors WHERE updated_at < ?",
|
|
(cutoff,),
|
|
)
|