Files
openworker/coworker/teams/chat.py
T
Rohit C Prasad cf436d1069 # team chat: own chat store, named workers, mention wakes, cancel interrupt (OPE-99)
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.
2026-08-16 16:22:23 -07:00

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