mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 23:03:22 +00:00
ChatStore = groups + append-only messages + per-member cursors; agent posts wake mentions only, user posts wake everyone; post_chat(record_on_item) also lands the answer as an item comment. Leads name workers (the callname is the handle everywhere); worker digests auto-carry the roster; gate checkbox is the user's call; canceling an assigned item now interrupts an in-flight worker.
216 lines
8.2 KiB
Python
216 lines
8.2 KiB
Python
"""The chat store — group chat as its own abstraction (eighth pass, 2026-08-16).
|
|
|
|
A GROUP is `{group_id, name, members[]}` plus an append-only message log and
|
|
per-member unread cursors. One group per team in v1 (created at the staffing gate
|
|
when chat is enabled), but nothing here knows about boards or teams — groups can
|
|
later serve non-team chats and the external-chat dialect.
|
|
|
|
Wake semantics live in the read side: an agent post is "for" exactly its @mentioned
|
|
members; a USER post is for every member ([User] outranks — posting to the channel
|
|
is rare and deliberate). Un-mentioned agent chatter wakes nobody, which is what
|
|
keeps chat an exception channel structurally.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sqlite3
|
|
import threading
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from .model import BoardError
|
|
|
|
|
|
class ChatStore:
|
|
def __init__(self, db_path: str | Path) -> None:
|
|
self.db_path = str(db_path)
|
|
if self.db_path != ":memory:":
|
|
Path(self.db_path).expanduser().parent.mkdir(parents=True, exist_ok=True)
|
|
self._lock = threading.RLock()
|
|
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
|
self._conn.row_factory = sqlite3.Row
|
|
self._conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS chat_groups (
|
|
group_id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
members TEXT NOT NULL,
|
|
created_ts TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS chat_messages (
|
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
group_id TEXT NOT NULL,
|
|
ts TEXT NOT NULL,
|
|
author TEXT NOT NULL,
|
|
author_role TEXT NOT NULL,
|
|
text TEXT NOT NULL,
|
|
mentions TEXT NOT NULL DEFAULT '[]'
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_chat_group ON chat_messages (group_id, seq);
|
|
CREATE TABLE IF NOT EXISTS chat_cursors (
|
|
cursor_key TEXT PRIMARY KEY,
|
|
read_seq INTEGER NOT NULL
|
|
);
|
|
""")
|
|
self._conn.commit()
|
|
|
|
# ---------------------------------------------------------------------- groups
|
|
|
|
def create_group(self, name: str, members: list[dict[str, Any]]) -> dict[str, Any]:
|
|
"""`members`: [{name, persona, role}] — `name` is the member's handle
|
|
(@mention target). The user participates implicitly and is not a member row."""
|
|
handles = [str(m.get("name", "")).strip() for m in members]
|
|
if not name.strip():
|
|
raise BoardError("group name is required")
|
|
if not all(handles) or len(set(handles)) != len(handles):
|
|
raise BoardError("every member needs a unique name")
|
|
group = {
|
|
"group_id": uuid.uuid4().hex[:12],
|
|
"name": name.strip(),
|
|
"members": [
|
|
{
|
|
"name": str(m.get("name")),
|
|
"persona": str(m.get("persona", "")),
|
|
"role": str(m.get("role", "worker")),
|
|
}
|
|
for m in members
|
|
],
|
|
"created_ts": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
with self._lock:
|
|
self._conn.execute(
|
|
"INSERT INTO chat_groups (group_id, name, members, created_ts)"
|
|
" VALUES (?, ?, ?, ?)",
|
|
(
|
|
group["group_id"],
|
|
group["name"],
|
|
json.dumps(group["members"]),
|
|
group["created_ts"],
|
|
),
|
|
)
|
|
self._conn.commit()
|
|
return group
|
|
|
|
def get_group(self, group_id: str) -> Optional[dict[str, Any]]:
|
|
with self._lock:
|
|
row = self._conn.execute(
|
|
"SELECT * FROM chat_groups WHERE group_id = ?", (group_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
group = dict(row)
|
|
group["members"] = json.loads(group.pop("members") or "[]")
|
|
return group
|
|
|
|
# -------------------------------------------------------------------- messages
|
|
|
|
def post(
|
|
self, group_id: str, author: str, text: str, *, author_role: str = "worker"
|
|
) -> dict[str, Any]:
|
|
"""Append one message. Mentions are parsed against member handles —
|
|
`@name` anywhere in the text — so tagging needs no separate parameter."""
|
|
group = self.get_group(group_id)
|
|
if group is None:
|
|
raise BoardError(f"no chat group '{group_id}'")
|
|
if not (text or "").strip():
|
|
raise BoardError("message text is required")
|
|
handles = {m["name"] for m in group["members"]}
|
|
mentions = sorted(
|
|
{
|
|
m.group(1)
|
|
for m in re.finditer(r"@([\w.-]+)", text)
|
|
if m.group(1) in handles
|
|
}
|
|
)
|
|
message = {
|
|
"group_id": group_id,
|
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
"author": author,
|
|
"author_role": author_role,
|
|
"text": text,
|
|
"mentions": mentions,
|
|
}
|
|
with self._lock:
|
|
cursor = self._conn.execute(
|
|
"INSERT INTO chat_messages"
|
|
" (group_id, ts, author, author_role, text, mentions)"
|
|
" VALUES (?, ?, ?, ?, ?, ?)",
|
|
(
|
|
group_id,
|
|
message["ts"],
|
|
author,
|
|
author_role,
|
|
text,
|
|
json.dumps(mentions),
|
|
),
|
|
)
|
|
self._conn.commit()
|
|
return {**message, "seq": cursor.lastrowid}
|
|
|
|
def messages(
|
|
self, group_id: str, *, since_seq: int = 0, limit: int = 200
|
|
) -> list[dict[str, Any]]:
|
|
with self._lock:
|
|
rows = self._conn.execute(
|
|
"SELECT * FROM chat_messages WHERE group_id = ? AND seq > ?"
|
|
" ORDER BY seq LIMIT ?",
|
|
(group_id, since_seq, max(1, min(int(limit or 200), 2000))),
|
|
).fetchall()
|
|
return [_row_to_message(row) for row in rows]
|
|
|
|
# ------------------------------------------------------- unread / wake reads
|
|
|
|
def unread_for(self, group_id: str, member: str) -> list[dict[str, Any]]:
|
|
"""Messages this member should be WOKEN for: posts that @mention it, plus
|
|
every user post. Its own posts never count."""
|
|
out = []
|
|
for message in self.messages(group_id, since_seq=self._cursor(group_id, member)):
|
|
if message["author"] == member:
|
|
continue
|
|
if member in message["mentions"] or message["author_role"] == "user":
|
|
out.append(message)
|
|
return out
|
|
|
|
def unread_count(self, group_id: str, member: str) -> int:
|
|
"""Plain unread count (all messages since the member's cursor) — drives the
|
|
sidebar badge for the USER, whose 'member' key is "user"."""
|
|
with self._lock:
|
|
row = self._conn.execute(
|
|
"SELECT COUNT(*) AS n FROM chat_messages WHERE group_id = ?"
|
|
" AND seq > ? AND author != ?",
|
|
(group_id, self._cursor(group_id, member), member),
|
|
).fetchone()
|
|
return int(row["n"])
|
|
|
|
def consume(self, group_id: str, member: str, upto_seq: int) -> None:
|
|
with self._lock:
|
|
self._conn.execute(
|
|
"INSERT INTO chat_cursors (cursor_key, read_seq) VALUES (?, ?)"
|
|
" ON CONFLICT(cursor_key) DO UPDATE SET read_seq ="
|
|
" MAX(read_seq, ?)",
|
|
(f"{group_id}:{member}", int(upto_seq), int(upto_seq)),
|
|
)
|
|
self._conn.commit()
|
|
|
|
def close(self) -> None:
|
|
self._conn.close()
|
|
|
|
def _cursor(self, group_id: str, member: str) -> int:
|
|
row = self._conn.execute(
|
|
"SELECT read_seq FROM chat_cursors WHERE cursor_key = ?",
|
|
(f"{group_id}:{member}",),
|
|
).fetchone()
|
|
return int(row["read_seq"]) if row else 0
|
|
|
|
|
|
def _row_to_message(row: sqlite3.Row) -> dict[str, Any]:
|
|
message = dict(row)
|
|
try:
|
|
message["mentions"] = json.loads(message.get("mentions") or "[]")
|
|
except json.JSONDecodeError:
|
|
message["mentions"] = []
|
|
return message
|