228 lines
9.1 KiB
Python
228 lines
9.1 KiB
Python
"""Tenant-scoped append-only message ledger and exact quote resolver."""
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from contextlib import closing
|
|
from dataclasses import dataclass
|
|
from datetime import timedelta
|
|
import os
|
|
from pathlib import Path
|
|
import sqlite3
|
|
from typing import TypeVar
|
|
|
|
from .tenant_context import (
|
|
CanonicalMessage, ConflictError, ScopeError, ValidationError,
|
|
isoformat, normalize_message_id, utc_now,
|
|
)
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LedgerRecord:
|
|
tenant_id: str
|
|
cell_id: str
|
|
channel: str
|
|
channel_account_id: str
|
|
peer_id: str
|
|
direction: str
|
|
message_id: str
|
|
event_id: str
|
|
body: str
|
|
occurred_at: str
|
|
expires_at: str
|
|
|
|
|
|
class MessageLedger:
|
|
"""Exact-ID ledger with no fuzzy, prefix, or time-nearest fallback."""
|
|
|
|
def __init__(self, database: str | Path, *, ttl_days: int = 7) -> None:
|
|
if not 1 <= ttl_days <= 365:
|
|
raise ValidationError("ttl_days must be between 1 and 365")
|
|
self.database = Path(database)
|
|
self.ttl_days = ttl_days
|
|
self.database.parent.mkdir(parents=True, exist_ok=True)
|
|
self._initialize()
|
|
try:
|
|
os.chmod(self.database, 0o600)
|
|
except OSError:
|
|
pass
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
db = sqlite3.connect(str(self.database), timeout=30)
|
|
db.row_factory = sqlite3.Row
|
|
db.execute("PRAGMA journal_mode=WAL")
|
|
db.execute("PRAGMA synchronous=FULL")
|
|
db.execute("PRAGMA busy_timeout=30000")
|
|
return db
|
|
|
|
def _storage(self, operation: Callable[[], T]) -> T:
|
|
failure: ConflictError | None = None
|
|
try:
|
|
return operation()
|
|
except sqlite3.Error:
|
|
failure = ConflictError("ledger storage unavailable")
|
|
# Raise after the driver exception handler, keeping SQLite details out of
|
|
# both the public exception and its implicit context.
|
|
raise failure
|
|
|
|
def _initialize(self) -> None:
|
|
def initialize() -> None:
|
|
with closing(self._connect()) as connection, connection as db:
|
|
db.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS message_ledger (
|
|
tenant_id TEXT NOT NULL,
|
|
cell_id TEXT NOT NULL,
|
|
channel TEXT NOT NULL,
|
|
channel_account_id TEXT NOT NULL,
|
|
peer_id TEXT NOT NULL,
|
|
direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound','internal')),
|
|
message_id TEXT NOT NULL,
|
|
event_id TEXT NOT NULL,
|
|
body TEXT NOT NULL,
|
|
occurred_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY(
|
|
tenant_id, cell_id, channel, channel_account_id,
|
|
peer_id, direction, message_id
|
|
),
|
|
UNIQUE(tenant_id, cell_id, event_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_message_reply_lookup ON message_ledger(
|
|
tenant_id, cell_id, channel, channel_account_id,
|
|
peer_id, message_id, expires_at
|
|
);
|
|
"""
|
|
)
|
|
self._storage(initialize)
|
|
|
|
def append(self, message: CanonicalMessage) -> LedgerRecord:
|
|
message_id = normalize_message_id(message.message_id)
|
|
now = utc_now()
|
|
expires = isoformat(now + timedelta(days=self.ttl_days))
|
|
key = (
|
|
message.tenant_id, message.cell_id, message.channel,
|
|
message.channel_account_id, message.peer_id, message.direction, message_id,
|
|
)
|
|
|
|
def write() -> LedgerRecord:
|
|
with closing(self._connect()) as connection, connection as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
existing = db.execute(
|
|
"SELECT event_id, body, occurred_at, expires_at FROM message_ledger "
|
|
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
|
|
"AND peer_id=? AND direction=? AND message_id=?", key,
|
|
).fetchone()
|
|
if existing:
|
|
if existing["body"] != message.body:
|
|
db.rollback()
|
|
raise ConflictError("message ID collision with different content")
|
|
db.commit()
|
|
return LedgerRecord(
|
|
*key, existing["event_id"], existing["body"],
|
|
existing["occurred_at"], existing["expires_at"],
|
|
)
|
|
db.execute(
|
|
"INSERT INTO message_ledger VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
(*key, message.event_id, message.body, message.occurred_at,
|
|
expires, isoformat(now)),
|
|
)
|
|
db.commit()
|
|
return LedgerRecord(
|
|
*key, message.event_id, message.body, message.occurred_at, expires
|
|
)
|
|
|
|
return self._storage(write)
|
|
|
|
def remember(self, message: CanonicalMessage) -> LedgerRecord:
|
|
"""Backward-compatible API wrapper outside the runtime write path."""
|
|
return self.append(message)
|
|
|
|
def matches(self, message: CanonicalMessage) -> bool:
|
|
message_id = normalize_message_id(message.message_id)
|
|
|
|
def query() -> bool:
|
|
with closing(self._connect()) as connection, connection as db:
|
|
row = db.execute(
|
|
"SELECT event_id, body FROM message_ledger WHERE tenant_id=? AND "
|
|
"cell_id=? AND channel=? AND channel_account_id=? AND peer_id=? "
|
|
"AND direction=? AND message_id=?",
|
|
(
|
|
message.tenant_id, message.cell_id, message.channel,
|
|
message.channel_account_id, message.peer_id, message.direction,
|
|
message_id,
|
|
),
|
|
).fetchone()
|
|
return bool(
|
|
row and row["event_id"] == message.event_id
|
|
and row["body"] == message.body
|
|
)
|
|
|
|
return self._storage(query)
|
|
|
|
def remove(self, record: LedgerRecord) -> None:
|
|
def delete() -> None:
|
|
with closing(self._connect()) as connection, connection as db:
|
|
db.execute(
|
|
"DELETE FROM message_ledger WHERE tenant_id=? AND cell_id=? AND "
|
|
"channel=? AND channel_account_id=? AND peer_id=? AND direction=? "
|
|
"AND message_id=? AND event_id=?",
|
|
(
|
|
record.tenant_id, record.cell_id, record.channel,
|
|
record.channel_account_id, record.peer_id, record.direction,
|
|
record.message_id, record.event_id,
|
|
),
|
|
)
|
|
self._storage(delete)
|
|
|
|
def resolve_quote(self, message: CanonicalMessage) -> str:
|
|
if message.quoted_body is not None:
|
|
return message.quoted_body
|
|
if message.reply_to_message_id is None:
|
|
raise ScopeError("quoted_text_unavailable")
|
|
reply_id = normalize_message_id(
|
|
message.reply_to_message_id, field_name="reply_to_message_id"
|
|
)
|
|
|
|
def query() -> list[sqlite3.Row]:
|
|
with closing(self._connect()) as connection, connection as db:
|
|
return db.execute(
|
|
"SELECT direction, body, event_id FROM message_ledger "
|
|
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
|
|
"AND peer_id=? AND message_id=? AND expires_at>?",
|
|
(
|
|
message.tenant_id, message.cell_id, message.channel,
|
|
message.channel_account_id, message.peer_id, reply_id, isoformat(),
|
|
),
|
|
).fetchall()
|
|
|
|
rows = self._storage(query)
|
|
if len(rows) == 1:
|
|
return str(rows[0]["body"])
|
|
if len(rows) > 1:
|
|
raise ConflictError("ambiguous reply ID across message directions")
|
|
raise ScopeError("quoted_text_unavailable")
|
|
|
|
def purge_expired(self) -> int:
|
|
def purge() -> int:
|
|
with closing(self._connect()) as connection, connection as db:
|
|
cursor = db.execute(
|
|
"DELETE FROM message_ledger WHERE expires_at<=?", (isoformat(),)
|
|
)
|
|
return cursor.rowcount
|
|
return self._storage(purge)
|
|
|
|
def integrity_check(self) -> str:
|
|
def check() -> str:
|
|
with closing(self._connect()) as connection, connection as db:
|
|
return str(db.execute("PRAGMA integrity_check").fetchone()[0])
|
|
return self._storage(check)
|
|
|
|
def count(self) -> int:
|
|
def query() -> int:
|
|
with closing(self._connect()) as connection, connection as db:
|
|
return int(db.execute("SELECT count(*) FROM message_ledger").fetchone()[0])
|
|
return self._storage(query)
|