hermes-agent/plugins/platforms/discord/recovery.py
HexLab98 953cbc0300 fix(state): refuse WAL on SQLite builds with the WAL-reset bug
On vulnerable SQLite (e.g. 3.50.4), do not enable WAL for fresh/non-WAL
shared databases — prefer DELETE instead. Leave existing on-disk WAL
alone (no live downgrade under concurrent gateway/cron openers). Surface
Python/SQLite version details as a doctor warning (#69784).
2026-07-23 17:32:38 +05:30

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,),
)