feat: chat
This commit is contained in:
@@ -30,6 +30,16 @@ def parse_iso(value: str) -> datetime:
|
||||
return parsed
|
||||
|
||||
|
||||
def unix_to_iso(value: Any) -> str | None:
|
||||
try:
|
||||
timestamp = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if timestamp <= 0:
|
||||
return None
|
||||
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def time_since(value: str, now: datetime | None = None) -> str:
|
||||
then = parse_iso(value)
|
||||
current = now or utc_now()
|
||||
@@ -138,6 +148,56 @@ class MemoryStore:
|
||||
created_at TEXT NOT NULL,
|
||||
delivered_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS negotiation_threads (
|
||||
negotiation_hash TEXT PRIMARY KEY,
|
||||
uex_negotiation_id INTEGER,
|
||||
listing_id INTEGER,
|
||||
listing_slug TEXT,
|
||||
title TEXT,
|
||||
counterparty_username TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
last_message_at TEXT,
|
||||
last_synced_at TEXT NOT NULL,
|
||||
last_notification_id INTEGER,
|
||||
last_notification_at TEXT,
|
||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||
closed_at TEXT,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS negotiation_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
negotiation_hash TEXT NOT NULL,
|
||||
uex_message_id INTEGER,
|
||||
author TEXT,
|
||||
author_username TEXT,
|
||||
is_me INTEGER NOT NULL DEFAULT 0,
|
||||
body TEXT NOT NULL,
|
||||
sent_at TEXT,
|
||||
source TEXT,
|
||||
raw_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS negotiation_ratings (
|
||||
negotiation_hash TEXT PRIMARY KEY,
|
||||
deal_closed INTEGER NOT NULL,
|
||||
deal_value REAL,
|
||||
currency TEXT,
|
||||
clarity_rating INTEGER,
|
||||
speed_rating INTEGER,
|
||||
respect_rating INTEGER,
|
||||
fairness_rating INTEGER,
|
||||
comment TEXT,
|
||||
submitted_at TEXT NOT NULL,
|
||||
raw_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS negotiation_sync_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
self._ensure_column(db, "conversations", "thread_id", "TEXT")
|
||||
@@ -384,6 +444,24 @@ class MemoryStore:
|
||||
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
negotiation_threads = db.execute(
|
||||
"""
|
||||
SELECT negotiation_hash, title, counterparty_username, status, unread_count, last_message_at, last_synced_at
|
||||
FROM negotiation_threads
|
||||
ORDER BY COALESCE(last_message_at, last_synced_at) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
negotiation_messages = db.execute(
|
||||
"""
|
||||
SELECT negotiation_hash, author_username, is_me, body, sent_at
|
||||
FROM negotiation_messages
|
||||
ORDER BY COALESCE(sent_at, '') DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
|
||||
profile = []
|
||||
for row in profile_rows:
|
||||
@@ -402,6 +480,8 @@ class MemoryStore:
|
||||
"profile": profile,
|
||||
"scheduled_jobs": [dict(row) for row in jobs],
|
||||
"outbox": [dict(row) for row in outbox],
|
||||
"negotiation_threads": [dict(row) for row in negotiation_threads],
|
||||
"negotiation_messages": [dict(row) for row in negotiation_messages],
|
||||
}
|
||||
|
||||
def clear(
|
||||
@@ -425,6 +505,10 @@ class MemoryStore:
|
||||
deleted["scheduled_jobs"] = db.execute("DELETE FROM scheduled_jobs").rowcount
|
||||
if include_outbox:
|
||||
deleted["outbox"] = db.execute("DELETE FROM outbox").rowcount
|
||||
deleted["negotiation_threads"] = db.execute("DELETE FROM negotiation_threads").rowcount
|
||||
deleted["negotiation_messages"] = db.execute("DELETE FROM negotiation_messages").rowcount
|
||||
deleted["negotiation_ratings"] = db.execute("DELETE FROM negotiation_ratings").rowcount
|
||||
deleted["negotiation_sync_state"] = db.execute("DELETE FROM negotiation_sync_state").rowcount
|
||||
return deleted
|
||||
|
||||
def set_profile(self, key: str, value: Any) -> None:
|
||||
@@ -555,3 +639,448 @@ class MemoryStore:
|
||||
except json.JSONDecodeError:
|
||||
data["metadata"] = {}
|
||||
return data
|
||||
|
||||
def set_negotiation_sync_state(self, key: str, value: Any) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO negotiation_sync_state(key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
""",
|
||||
(key, json.dumps(value), iso_now()),
|
||||
)
|
||||
|
||||
def get_negotiation_sync_state(self, key: str, default: Any = None) -> Any:
|
||||
with self._connect() as db:
|
||||
row = db.execute(
|
||||
"SELECT value FROM negotiation_sync_state WHERE key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return default
|
||||
try:
|
||||
return json.loads(row["value"])
|
||||
except json.JSONDecodeError:
|
||||
return default
|
||||
|
||||
def upsert_negotiation(
|
||||
self,
|
||||
negotiation_hash: str,
|
||||
*,
|
||||
uex_negotiation_id: int | None = None,
|
||||
listing_id: int | None = None,
|
||||
listing_slug: str | None = None,
|
||||
title: str | None = None,
|
||||
counterparty_username: str | None = None,
|
||||
status: str = "open",
|
||||
last_message_at: str | None = None,
|
||||
last_synced_at: str | None = None,
|
||||
last_notification_id: int | None = None,
|
||||
last_notification_at: str | None = None,
|
||||
unread_count: int | None = None,
|
||||
closed_at: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
if not negotiation_hash.strip():
|
||||
return
|
||||
now = last_synced_at or iso_now()
|
||||
with self._connect() as db:
|
||||
existing = db.execute(
|
||||
"""
|
||||
SELECT unread_count, metadata_json
|
||||
FROM negotiation_threads
|
||||
WHERE negotiation_hash = ?
|
||||
""",
|
||||
(negotiation_hash,),
|
||||
).fetchone()
|
||||
current_unread = int(existing["unread_count"]) if existing else 0
|
||||
merged_metadata = {}
|
||||
if existing:
|
||||
try:
|
||||
merged_metadata = json.loads(existing["metadata_json"])
|
||||
except json.JSONDecodeError:
|
||||
merged_metadata = {}
|
||||
if metadata:
|
||||
merged_metadata.update(metadata)
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO negotiation_threads(
|
||||
negotiation_hash,
|
||||
uex_negotiation_id,
|
||||
listing_id,
|
||||
listing_slug,
|
||||
title,
|
||||
counterparty_username,
|
||||
status,
|
||||
last_message_at,
|
||||
last_synced_at,
|
||||
last_notification_id,
|
||||
last_notification_at,
|
||||
unread_count,
|
||||
closed_at,
|
||||
metadata_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(negotiation_hash) DO UPDATE SET
|
||||
uex_negotiation_id = COALESCE(excluded.uex_negotiation_id, negotiation_threads.uex_negotiation_id),
|
||||
listing_id = COALESCE(excluded.listing_id, negotiation_threads.listing_id),
|
||||
listing_slug = COALESCE(excluded.listing_slug, negotiation_threads.listing_slug),
|
||||
title = COALESCE(excluded.title, negotiation_threads.title),
|
||||
counterparty_username = COALESCE(excluded.counterparty_username, negotiation_threads.counterparty_username),
|
||||
status = COALESCE(excluded.status, negotiation_threads.status),
|
||||
last_message_at = COALESCE(excluded.last_message_at, negotiation_threads.last_message_at),
|
||||
last_synced_at = excluded.last_synced_at,
|
||||
last_notification_id = COALESCE(excluded.last_notification_id, negotiation_threads.last_notification_id),
|
||||
last_notification_at = COALESCE(excluded.last_notification_at, negotiation_threads.last_notification_at),
|
||||
unread_count = COALESCE(excluded.unread_count, negotiation_threads.unread_count),
|
||||
closed_at = COALESCE(excluded.closed_at, negotiation_threads.closed_at),
|
||||
metadata_json = excluded.metadata_json
|
||||
""",
|
||||
(
|
||||
negotiation_hash.strip(),
|
||||
uex_negotiation_id,
|
||||
listing_id,
|
||||
listing_slug,
|
||||
title,
|
||||
counterparty_username,
|
||||
status or "open",
|
||||
last_message_at,
|
||||
now,
|
||||
last_notification_id,
|
||||
last_notification_at,
|
||||
current_unread if unread_count is None else max(0, int(unread_count)),
|
||||
closed_at,
|
||||
json.dumps(merged_metadata),
|
||||
),
|
||||
)
|
||||
|
||||
def replace_negotiation_messages(
|
||||
self,
|
||||
negotiation_hash: str,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
mark_read: bool = False,
|
||||
) -> None:
|
||||
if not negotiation_hash.strip():
|
||||
return
|
||||
normalized = [self._normalize_negotiation_message(negotiation_hash, item) for item in messages]
|
||||
normalized = [item for item in normalized if item]
|
||||
with self._connect() as db:
|
||||
db.execute("DELETE FROM negotiation_messages WHERE negotiation_hash = ?", (negotiation_hash,))
|
||||
for item in normalized:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO negotiation_messages(
|
||||
negotiation_hash,
|
||||
uex_message_id,
|
||||
author,
|
||||
author_username,
|
||||
is_me,
|
||||
body,
|
||||
sent_at,
|
||||
source,
|
||||
raw_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
negotiation_hash,
|
||||
item["uex_message_id"],
|
||||
item["author"],
|
||||
item["author_username"],
|
||||
1 if item["is_me"] else 0,
|
||||
item["body"],
|
||||
item["sent_at"],
|
||||
item["source"],
|
||||
json.dumps(item["raw_json"]),
|
||||
),
|
||||
)
|
||||
last_message_at = normalized[-1]["sent_at"] if normalized else None
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE negotiation_threads
|
||||
SET last_message_at = COALESCE(?, last_message_at),
|
||||
last_synced_at = ?,
|
||||
unread_count = CASE WHEN ? THEN 0 ELSE unread_count END
|
||||
WHERE negotiation_hash = ?
|
||||
""",
|
||||
(last_message_at, iso_now(), 1 if mark_read else 0, negotiation_hash),
|
||||
)
|
||||
|
||||
def mark_negotiation_notified(
|
||||
self,
|
||||
negotiation_hash: str,
|
||||
*,
|
||||
notification_id: int | None = None,
|
||||
notification_at: str | None = None,
|
||||
) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE negotiation_threads
|
||||
SET unread_count = unread_count + 1,
|
||||
last_notification_id = COALESCE(?, last_notification_id),
|
||||
last_notification_at = COALESCE(?, last_notification_at)
|
||||
WHERE negotiation_hash = ?
|
||||
""",
|
||||
(notification_id, notification_at, negotiation_hash),
|
||||
)
|
||||
|
||||
def mark_negotiation_read(self, negotiation_hash: str) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"UPDATE negotiation_threads SET unread_count = 0 WHERE negotiation_hash = ?",
|
||||
(negotiation_hash,),
|
||||
)
|
||||
|
||||
def store_negotiation_rating(self, negotiation_hash: str, payload: dict[str, Any], raw_json: dict[str, Any] | None = None) -> None:
|
||||
now = iso_now()
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO negotiation_ratings(
|
||||
negotiation_hash,
|
||||
deal_closed,
|
||||
deal_value,
|
||||
currency,
|
||||
clarity_rating,
|
||||
speed_rating,
|
||||
respect_rating,
|
||||
fairness_rating,
|
||||
comment,
|
||||
submitted_at,
|
||||
raw_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(negotiation_hash) DO UPDATE SET
|
||||
deal_closed = excluded.deal_closed,
|
||||
deal_value = excluded.deal_value,
|
||||
currency = excluded.currency,
|
||||
clarity_rating = excluded.clarity_rating,
|
||||
speed_rating = excluded.speed_rating,
|
||||
respect_rating = excluded.respect_rating,
|
||||
fairness_rating = excluded.fairness_rating,
|
||||
comment = excluded.comment,
|
||||
submitted_at = excluded.submitted_at,
|
||||
raw_json = excluded.raw_json
|
||||
""",
|
||||
(
|
||||
negotiation_hash,
|
||||
1 if payload.get("deal_closed") else 0,
|
||||
payload.get("deal_value"),
|
||||
payload.get("currency"),
|
||||
payload.get("clarity_rating"),
|
||||
payload.get("speed_rating"),
|
||||
payload.get("respect_rating"),
|
||||
payload.get("fairness_rating"),
|
||||
payload.get("comment"),
|
||||
now,
|
||||
json.dumps(raw_json or payload),
|
||||
),
|
||||
)
|
||||
|
||||
def list_negotiations(
|
||||
self,
|
||||
*,
|
||||
status: str = "all",
|
||||
unread_only: bool = False,
|
||||
search: str = "",
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
status_filter = str(status or "all").strip().casefold()
|
||||
search_filter = f"%{search.strip().casefold()}%" if search.strip() else None
|
||||
clauses = []
|
||||
params: list[Any] = []
|
||||
if status_filter not in {"", "all"}:
|
||||
clauses.append("status = ?")
|
||||
params.append(status_filter)
|
||||
if unread_only:
|
||||
clauses.append("unread_count > 0")
|
||||
if search_filter:
|
||||
clauses.append(
|
||||
"""
|
||||
(
|
||||
lower(COALESCE(title, '')) LIKE ?
|
||||
OR lower(COALESCE(counterparty_username, '')) LIKE ?
|
||||
OR lower(COALESCE(listing_slug, '')) LIKE ?
|
||||
)
|
||||
"""
|
||||
)
|
||||
params.extend([search_filter, search_filter, search_filter])
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
f"""
|
||||
SELECT
|
||||
negotiation_hash,
|
||||
uex_negotiation_id,
|
||||
listing_id,
|
||||
listing_slug,
|
||||
title,
|
||||
counterparty_username,
|
||||
status,
|
||||
last_message_at,
|
||||
last_synced_at,
|
||||
last_notification_id,
|
||||
last_notification_at,
|
||||
unread_count,
|
||||
closed_at,
|
||||
metadata_json
|
||||
FROM negotiation_threads
|
||||
{where}
|
||||
ORDER BY
|
||||
unread_count DESC,
|
||||
COALESCE(last_message_at, last_notification_at, last_synced_at) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(*params, max(1, min(limit, 500))),
|
||||
).fetchall()
|
||||
return [self._negotiation_thread_row(row) for row in rows]
|
||||
|
||||
def get_negotiation(self, negotiation_hash: str) -> dict[str, Any] | None:
|
||||
with self._connect() as db:
|
||||
thread = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
negotiation_hash,
|
||||
uex_negotiation_id,
|
||||
listing_id,
|
||||
listing_slug,
|
||||
title,
|
||||
counterparty_username,
|
||||
status,
|
||||
last_message_at,
|
||||
last_synced_at,
|
||||
last_notification_id,
|
||||
last_notification_at,
|
||||
unread_count,
|
||||
closed_at,
|
||||
metadata_json
|
||||
FROM negotiation_threads
|
||||
WHERE negotiation_hash = ?
|
||||
""",
|
||||
(negotiation_hash,),
|
||||
).fetchone()
|
||||
if not thread:
|
||||
return None
|
||||
messages = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
uex_message_id,
|
||||
author,
|
||||
author_username,
|
||||
is_me,
|
||||
body,
|
||||
sent_at,
|
||||
source,
|
||||
raw_json
|
||||
FROM negotiation_messages
|
||||
WHERE negotiation_hash = ?
|
||||
ORDER BY COALESCE(sent_at, '') ASC, id ASC
|
||||
""",
|
||||
(negotiation_hash,),
|
||||
).fetchall()
|
||||
rating = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
deal_closed,
|
||||
deal_value,
|
||||
currency,
|
||||
clarity_rating,
|
||||
speed_rating,
|
||||
respect_rating,
|
||||
fairness_rating,
|
||||
comment,
|
||||
submitted_at,
|
||||
raw_json
|
||||
FROM negotiation_ratings
|
||||
WHERE negotiation_hash = ?
|
||||
""",
|
||||
(negotiation_hash,),
|
||||
).fetchone()
|
||||
result = self._negotiation_thread_row(thread)
|
||||
result["messages"] = [self._negotiation_message_row(row) for row in messages]
|
||||
result["rating"] = self._negotiation_rating_row(rating) if rating else None
|
||||
return result
|
||||
|
||||
def search_negotiation_messages(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
|
||||
q = query.strip()
|
||||
if not q:
|
||||
return []
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
m.negotiation_hash,
|
||||
t.title,
|
||||
t.counterparty_username,
|
||||
m.author_username,
|
||||
m.is_me,
|
||||
m.body,
|
||||
m.sent_at
|
||||
FROM negotiation_messages m
|
||||
JOIN negotiation_threads t ON t.negotiation_hash = m.negotiation_hash
|
||||
WHERE lower(m.body) LIKE ?
|
||||
ORDER BY COALESCE(m.sent_at, '') DESC, m.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(f"%{q.casefold()}%", max(1, min(limit, 50))),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_negotiation_message(negotiation_hash: str, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
body = str(item.get("body") or item.get("message") or item.get("content") or item.get("text") or item.get("event") or "").strip()
|
||||
if not body:
|
||||
return None
|
||||
return {
|
||||
"negotiation_hash": negotiation_hash,
|
||||
"uex_message_id": MemoryStore._int_or_none(item.get("id") or item.get("id_message")),
|
||||
"author": str(item.get("user_name") or item.get("author") or item.get("sender") or item.get("user_username") or "UEX"),
|
||||
"author_username": str(item.get("user_username") or item.get("author_username") or item.get("username") or "").strip() or None,
|
||||
"is_me": bool(item.get("is_me")),
|
||||
"body": body,
|
||||
"sent_at": unix_to_iso(item.get("date_added")) or str(item.get("sent_at") or "").strip() or None,
|
||||
"source": str(item.get("api_name") or item.get("source") or "uex"),
|
||||
"raw_json": item,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _negotiation_thread_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(row)
|
||||
try:
|
||||
data["metadata"] = json.loads(data.pop("metadata_json"))
|
||||
except (KeyError, json.JSONDecodeError):
|
||||
data["metadata"] = {}
|
||||
data["hash"] = data.pop("negotiation_hash")
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _negotiation_message_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(row)
|
||||
try:
|
||||
data["raw_json"] = json.loads(data["raw_json"])
|
||||
except (KeyError, json.JSONDecodeError):
|
||||
data["raw_json"] = {}
|
||||
data["is_me"] = bool(data.get("is_me"))
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _negotiation_rating_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(row)
|
||||
try:
|
||||
data["raw_json"] = json.loads(data["raw_json"])
|
||||
except (KeyError, json.JSONDecodeError):
|
||||
data["raw_json"] = {}
|
||||
data["deal_closed"] = bool(data.get("deal_closed"))
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _int_or_none(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user