Feat v0.5.1 chat core (#31)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #31.
This commit is contained in:
691
packages/chat-core/script.star
Normal file
691
packages/chat-core/script.star
Normal file
@@ -0,0 +1,691 @@
|
||||
# Chat Core — Starlark Backend (v0.1.0)
|
||||
#
|
||||
# Library package providing conversations, messages, participants,
|
||||
# and read cursors. Consumable via lib.require("chat-core").
|
||||
#
|
||||
# Entry points:
|
||||
# on_request(req) → REST API routes
|
||||
# Exported globals → create, send, history, add_participant,
|
||||
# remove_participant, mark_read
|
||||
#
|
||||
# Modules: db, json, realtime
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _resp(status, data):
|
||||
return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
|
||||
|
||||
def _str(v):
|
||||
if v == None:
|
||||
return ""
|
||||
return str(v)
|
||||
|
||||
def _int(v):
|
||||
if v == None:
|
||||
return 0
|
||||
s = str(v)
|
||||
if not s:
|
||||
return 0
|
||||
return int(s)
|
||||
|
||||
def _is_participant(conversation_id, user_id):
|
||||
"""Check if user is a participant in the conversation."""
|
||||
rows = db.query("participants", filters={"conversation_id": conversation_id, "participant_id": user_id}, limit=1)
|
||||
return len(rows or []) > 0
|
||||
|
||||
def _get_participant(conversation_id, user_id):
|
||||
"""Get participant record or None."""
|
||||
rows = db.query("participants", filters={"conversation_id": conversation_id, "participant_id": user_id}, limit=1)
|
||||
if rows and len(rows) > 0:
|
||||
return rows[0]
|
||||
return None
|
||||
|
||||
def _is_admin(conversation_id, user_id):
|
||||
"""Check if user has admin role in the conversation."""
|
||||
p = _get_participant(conversation_id, user_id)
|
||||
if p:
|
||||
return _str(p.get("role", "")) == "admin"
|
||||
return False
|
||||
|
||||
def _get_conversation(conversation_id):
|
||||
"""Get conversation by ID or None."""
|
||||
rows = db.query("conversations", filters={"id": conversation_id}, limit=1)
|
||||
if rows and len(rows) > 0:
|
||||
return rows[0]
|
||||
return None
|
||||
|
||||
def _now():
|
||||
"""Current timestamp placeholder — db auto-populates created_at."""
|
||||
return ""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Exported API (lib.require("chat-core"))
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def create(title, type="group", participants=None, creator_id=""):
|
||||
"""Create a conversation and add initial participants.
|
||||
|
||||
Args:
|
||||
title: conversation title
|
||||
type: "direct" or "group" (default "group")
|
||||
participants: list of dicts with {id, type?, display_name?, role?}
|
||||
creator_id: user ID of the creator (added as admin)
|
||||
|
||||
Returns:
|
||||
dict with conversation data
|
||||
"""
|
||||
if type not in ("direct", "group"):
|
||||
type = "group"
|
||||
|
||||
conv = db.insert("conversations", {
|
||||
"title": _str(title),
|
||||
"type": type,
|
||||
"created_by": _str(creator_id),
|
||||
"updated_at": "",
|
||||
})
|
||||
cid = conv["id"]
|
||||
|
||||
# Add creator as admin participant
|
||||
if creator_id:
|
||||
db.insert("participants", {
|
||||
"conversation_id": cid,
|
||||
"participant_id": _str(creator_id),
|
||||
"participant_type": "user",
|
||||
"display_name": "",
|
||||
"role": "admin",
|
||||
"joined_at": "",
|
||||
})
|
||||
|
||||
# Add initial participants
|
||||
for p in (participants or []):
|
||||
pid = _str(p.get("id", ""))
|
||||
if not pid or pid == _str(creator_id):
|
||||
continue
|
||||
db.insert("participants", {
|
||||
"conversation_id": cid,
|
||||
"participant_id": pid,
|
||||
"participant_type": _str(p.get("type", "user")),
|
||||
"display_name": _str(p.get("display_name", "")),
|
||||
"role": _str(p.get("role", "member")),
|
||||
"joined_at": "",
|
||||
})
|
||||
|
||||
return conv
|
||||
|
||||
|
||||
def send(conversation_id, participant_id, content, content_type="text"):
|
||||
"""Send a message to a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: target conversation
|
||||
participant_id: sender ID
|
||||
content: message content
|
||||
content_type: "text", "system", or "file" (default "text")
|
||||
|
||||
Returns:
|
||||
dict with message data
|
||||
"""
|
||||
if content_type not in ("text", "system", "file"):
|
||||
content_type = "text"
|
||||
|
||||
msg = db.insert("messages", {
|
||||
"conversation_id": _str(conversation_id),
|
||||
"participant_id": _str(participant_id),
|
||||
"content": _str(content),
|
||||
"content_type": content_type,
|
||||
"edited_at": "",
|
||||
})
|
||||
|
||||
# Update conversation timestamp
|
||||
db.update("conversations", _str(conversation_id), {"updated_at": msg.get("created_at", "")})
|
||||
|
||||
# Publish realtime event
|
||||
realtime.publish(
|
||||
"conversation:" + _str(conversation_id),
|
||||
"message",
|
||||
{
|
||||
"id": msg.get("id", ""),
|
||||
"conversation_id": _str(conversation_id),
|
||||
"participant_id": _str(participant_id),
|
||||
"content": _str(content),
|
||||
"content_type": content_type,
|
||||
"created_at": msg.get("created_at", ""),
|
||||
},
|
||||
)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def history(conversation_id, limit=50, cursor=""):
|
||||
"""Get paginated message history for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: target conversation
|
||||
limit: max messages to return (1-100, default 50)
|
||||
cursor: created_at value of last message from previous page
|
||||
|
||||
Returns:
|
||||
dict with {messages, has_more, next_cursor}
|
||||
"""
|
||||
lim = _int(limit)
|
||||
if lim < 1 or lim > 100:
|
||||
lim = 50
|
||||
|
||||
filters = {"conversation_id": _str(conversation_id)}
|
||||
before = {}
|
||||
if cursor:
|
||||
before = {"created_at": _str(cursor)}
|
||||
|
||||
rows = db.query("messages", filters=filters, order="-created_at", limit=lim + 1, before=before)
|
||||
rows = rows or []
|
||||
|
||||
has_more = len(rows) > lim
|
||||
if has_more:
|
||||
rows = rows[:lim]
|
||||
|
||||
next_cursor = ""
|
||||
if has_more and rows:
|
||||
next_cursor = _str(rows[-1].get("created_at", ""))
|
||||
|
||||
return {"messages": rows, "has_more": has_more, "next_cursor": next_cursor}
|
||||
|
||||
|
||||
def add_participant(conversation_id, participant_id, participant_type="user", display_name="", role="member"):
|
||||
"""Add a participant to a conversation.
|
||||
|
||||
Returns:
|
||||
dict with participant data
|
||||
"""
|
||||
cid = _str(conversation_id)
|
||||
pid = _str(participant_id)
|
||||
|
||||
# Check if already a participant
|
||||
existing = db.query("participants", filters={"conversation_id": cid, "participant_id": pid}, limit=1)
|
||||
if existing and len(existing) > 0:
|
||||
return existing[0]
|
||||
|
||||
p = db.insert("participants", {
|
||||
"conversation_id": cid,
|
||||
"participant_id": pid,
|
||||
"participant_type": _str(participant_type),
|
||||
"display_name": _str(display_name),
|
||||
"role": _str(role),
|
||||
"joined_at": "",
|
||||
})
|
||||
|
||||
# Publish realtime event
|
||||
realtime.publish(
|
||||
"conversation:" + cid,
|
||||
"participant.added",
|
||||
{
|
||||
"conversation_id": cid,
|
||||
"participant_id": pid,
|
||||
"participant_type": _str(participant_type),
|
||||
"display_name": _str(display_name),
|
||||
"role": _str(role),
|
||||
},
|
||||
)
|
||||
|
||||
# Insert system message
|
||||
send(cid, pid, "joined the conversation", "system")
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def remove_participant(conversation_id, participant_id):
|
||||
"""Remove a participant from a conversation.
|
||||
|
||||
Returns:
|
||||
True on success
|
||||
"""
|
||||
cid = _str(conversation_id)
|
||||
pid = _str(participant_id)
|
||||
|
||||
# Delete participant record
|
||||
rows = db.query("participants", filters={"conversation_id": cid, "participant_id": pid}, limit=1)
|
||||
for r in (rows or []):
|
||||
db.delete("participants", r["id"])
|
||||
|
||||
# Delete read cursor
|
||||
cursors = db.query("read_cursors", filters={"conversation_id": cid, "participant_id": pid}, limit=1)
|
||||
for c in (cursors or []):
|
||||
db.delete("read_cursors", c["id"])
|
||||
|
||||
# Publish realtime event
|
||||
realtime.publish(
|
||||
"conversation:" + cid,
|
||||
"participant.removed",
|
||||
{"conversation_id": cid, "participant_id": pid},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def mark_read(conversation_id, participant_id, last_read_message_id):
|
||||
"""Update the read cursor for a participant.
|
||||
|
||||
Returns:
|
||||
True on success
|
||||
"""
|
||||
cid = _str(conversation_id)
|
||||
pid = _str(participant_id)
|
||||
mid = _str(last_read_message_id)
|
||||
|
||||
# Delete existing cursor (upsert via delete+insert)
|
||||
existing = db.query("read_cursors", filters={"conversation_id": cid, "participant_id": pid}, limit=1)
|
||||
for r in (existing or []):
|
||||
db.delete("read_cursors", r["id"])
|
||||
|
||||
db.insert("read_cursors", {
|
||||
"conversation_id": cid,
|
||||
"participant_id": pid,
|
||||
"last_read_message_id": mid,
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# REST API dispatcher
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_request(req):
|
||||
path = req["path"]
|
||||
method = req["method"]
|
||||
user_id = req.get("user_id", "")
|
||||
|
||||
# ── Conversations ──────────────────────────
|
||||
|
||||
# GET /conversations — list user's conversations
|
||||
if method == "GET" and path == "/conversations":
|
||||
return _handle_list_conversations(req, user_id)
|
||||
|
||||
# POST /conversations — create
|
||||
if method == "POST" and path == "/conversations":
|
||||
return _handle_create_conversation(req, user_id)
|
||||
|
||||
# GET /unread — unread counts
|
||||
if method == "GET" and path == "/unread":
|
||||
return _handle_unread(user_id)
|
||||
|
||||
# GET /conversations/:id
|
||||
if method == "GET" and path.startswith("/conversations/"):
|
||||
cid = path[len("/conversations/"):]
|
||||
return _handle_get_conversation(cid, user_id)
|
||||
|
||||
# PUT /conversations/:id
|
||||
if method == "PUT" and path.startswith("/conversations/"):
|
||||
cid = path[len("/conversations/"):]
|
||||
return _handle_update_conversation(cid, req, user_id)
|
||||
|
||||
# DELETE /conversations/:id
|
||||
if method == "DELETE" and path.startswith("/conversations/"):
|
||||
cid = path[len("/conversations/"):]
|
||||
return _handle_delete_conversation(cid, user_id)
|
||||
|
||||
# ── Messages ───────────────────────────────
|
||||
|
||||
# Routes: /messages/:conversation_id or /messages/:conversation_id/:message_id
|
||||
if path.startswith("/messages/"):
|
||||
remainder = path[len("/messages/"):]
|
||||
parts = remainder.split("/")
|
||||
cid = parts[0]
|
||||
mid = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if method == "GET" and not mid:
|
||||
return _handle_list_messages(cid, req, user_id)
|
||||
if method == "POST" and not mid:
|
||||
return _handle_send_message(cid, req, user_id)
|
||||
if method == "PUT" and mid:
|
||||
return _handle_edit_message(cid, mid, req, user_id)
|
||||
if method == "DELETE" and mid:
|
||||
return _handle_delete_message(cid, mid, user_id)
|
||||
|
||||
# ── Participants ───────────────────────────
|
||||
|
||||
if path.startswith("/participants/"):
|
||||
remainder = path[len("/participants/"):]
|
||||
parts = remainder.split("/")
|
||||
cid = parts[0]
|
||||
pid = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if method == "GET" and not pid:
|
||||
return _handle_list_participants(cid, user_id)
|
||||
if method == "POST" and not pid:
|
||||
return _handle_add_participant(cid, req, user_id)
|
||||
if method == "DELETE" and pid:
|
||||
return _handle_remove_participant(cid, pid, user_id)
|
||||
|
||||
# ── Read cursors ──────────────────────────
|
||||
|
||||
if method == "POST" and path.startswith("/read/"):
|
||||
cid = path[len("/read/"):]
|
||||
return _handle_mark_read(cid, req, user_id)
|
||||
|
||||
return _resp(404, {"error": "not found"})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Conversation handlers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _handle_list_conversations(req, user_id):
|
||||
"""List conversations the user is a participant in."""
|
||||
# Get all conversation IDs for this user
|
||||
my_parts = db.query("participants", filters={"participant_id": user_id}, limit=500)
|
||||
if not my_parts:
|
||||
return _resp(200, {"data": []})
|
||||
|
||||
cids = []
|
||||
for p in my_parts:
|
||||
cids.append(_str(p.get("conversation_id", "")))
|
||||
|
||||
# Fetch conversations and enrich with last message
|
||||
items = []
|
||||
for cid in cids:
|
||||
conv = _get_conversation(cid)
|
||||
if not conv:
|
||||
continue
|
||||
|
||||
# Get last message
|
||||
last_msgs = db.query("messages", filters={"conversation_id": cid}, order="-created_at", limit=1)
|
||||
last_msg = None
|
||||
if last_msgs and len(last_msgs) > 0:
|
||||
last_msg = {
|
||||
"id": last_msgs[0].get("id", ""),
|
||||
"content": _str(last_msgs[0].get("content", "")),
|
||||
"participant_id": _str(last_msgs[0].get("participant_id", "")),
|
||||
"content_type": _str(last_msgs[0].get("content_type", "")),
|
||||
"created_at": _str(last_msgs[0].get("created_at", "")),
|
||||
}
|
||||
|
||||
# Get participant count
|
||||
parts = db.query("participants", filters={"conversation_id": cid}, limit=500)
|
||||
part_count = len(parts or [])
|
||||
|
||||
items.append({
|
||||
"id": conv.get("id", ""),
|
||||
"title": conv.get("title", ""),
|
||||
"type": conv.get("type", ""),
|
||||
"created_by": conv.get("created_by", ""),
|
||||
"updated_at": conv.get("updated_at", ""),
|
||||
"created_at": conv.get("created_at", ""),
|
||||
"last_message": last_msg,
|
||||
"participant_count": part_count,
|
||||
})
|
||||
|
||||
# Sort by updated_at descending (most recent first)
|
||||
items = sorted(items, key=lambda x: x.get("updated_at", "") or x.get("created_at", ""), reverse=True)
|
||||
|
||||
return _resp(200, {"data": items})
|
||||
|
||||
|
||||
def _handle_create_conversation(req, user_id):
|
||||
"""Create a new conversation."""
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
title = _str(body.get("title", ""))
|
||||
conv_type = _str(body.get("type", "group"))
|
||||
participants_list = body.get("participants", [])
|
||||
|
||||
conv = create(title, conv_type, participants_list, user_id)
|
||||
return _resp(201, conv)
|
||||
|
||||
|
||||
def _handle_get_conversation(cid, user_id):
|
||||
"""Get conversation detail with participants."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
conv = _get_conversation(cid)
|
||||
if not conv:
|
||||
return _resp(404, {"error": "conversation not found"})
|
||||
|
||||
parts = db.query("participants", filters={"conversation_id": cid}, limit=500)
|
||||
|
||||
return _resp(200, {
|
||||
"id": conv.get("id", ""),
|
||||
"title": conv.get("title", ""),
|
||||
"type": conv.get("type", ""),
|
||||
"created_by": conv.get("created_by", ""),
|
||||
"updated_at": conv.get("updated_at", ""),
|
||||
"created_at": conv.get("created_at", ""),
|
||||
"participants": parts or [],
|
||||
})
|
||||
|
||||
|
||||
def _handle_update_conversation(cid, req, user_id):
|
||||
"""Update conversation (admin only)."""
|
||||
if not _is_admin(cid, user_id):
|
||||
return _resp(403, {"error": "admin only"})
|
||||
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
patch = {}
|
||||
title = body.get("title", None)
|
||||
if title != None:
|
||||
patch["title"] = _str(title)
|
||||
|
||||
if patch:
|
||||
db.update("conversations", cid, patch)
|
||||
|
||||
conv = _get_conversation(cid)
|
||||
return _resp(200, conv)
|
||||
|
||||
|
||||
def _handle_delete_conversation(cid, user_id):
|
||||
"""Delete conversation and all associated data (admin only)."""
|
||||
if not _is_admin(cid, user_id):
|
||||
return _resp(403, {"error": "admin only"})
|
||||
|
||||
# Cascade delete: messages, participants, read_cursors, then conversation
|
||||
msgs = db.query("messages", filters={"conversation_id": cid}, limit=5000)
|
||||
for m in (msgs or []):
|
||||
db.delete("messages", m["id"])
|
||||
|
||||
parts = db.query("participants", filters={"conversation_id": cid}, limit=500)
|
||||
for p in (parts or []):
|
||||
db.delete("participants", p["id"])
|
||||
|
||||
cursors = db.query("read_cursors", filters={"conversation_id": cid}, limit=500)
|
||||
for c in (cursors or []):
|
||||
db.delete("read_cursors", c["id"])
|
||||
|
||||
db.delete("conversations", cid)
|
||||
return _resp(200, {"ok": True})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Message handlers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _handle_list_messages(cid, req, user_id):
|
||||
"""Paginated message history."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
q = req.get("query", {})
|
||||
limit_val = _int(q.get("limit", "50"))
|
||||
cursor = _str(q.get("cursor", ""))
|
||||
|
||||
result = history(cid, limit_val, cursor)
|
||||
return _resp(200, result)
|
||||
|
||||
|
||||
def _handle_send_message(cid, req, user_id):
|
||||
"""Send a message."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
content = _str(body.get("content", ""))
|
||||
content_type = _str(body.get("content_type", "text"))
|
||||
|
||||
if not content:
|
||||
return _resp(400, {"error": "content required"})
|
||||
|
||||
msg = send(cid, user_id, content, content_type)
|
||||
return _resp(201, msg)
|
||||
|
||||
|
||||
def _handle_edit_message(cid, mid, req, user_id):
|
||||
"""Edit a message (author only)."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
# Verify ownership
|
||||
msgs = db.query("messages", filters={"id": mid}, limit=1)
|
||||
if not msgs:
|
||||
return _resp(404, {"error": "message not found"})
|
||||
|
||||
msg = msgs[0]
|
||||
if _str(msg.get("participant_id", "")) != user_id:
|
||||
return _resp(403, {"error": "can only edit own messages"})
|
||||
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
content = _str(body.get("content", ""))
|
||||
if not content:
|
||||
return _resp(400, {"error": "content required"})
|
||||
|
||||
# Use created_at of the message as the edited_at marker
|
||||
db.update("messages", mid, {"content": content, "edited_at": msg.get("created_at", "")})
|
||||
|
||||
# Publish edit event
|
||||
realtime.publish(
|
||||
"conversation:" + cid,
|
||||
"message.edited",
|
||||
{"id": mid, "conversation_id": cid, "content": content, "edited_by": user_id},
|
||||
)
|
||||
|
||||
updated = db.query("messages", filters={"id": mid}, limit=1)
|
||||
return _resp(200, updated[0] if updated else {})
|
||||
|
||||
|
||||
def _handle_delete_message(cid, mid, user_id):
|
||||
"""Delete a message (author or admin)."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
msgs = db.query("messages", filters={"id": mid}, limit=1)
|
||||
if not msgs:
|
||||
return _resp(404, {"error": "message not found"})
|
||||
|
||||
msg = msgs[0]
|
||||
is_author = _str(msg.get("participant_id", "")) == user_id
|
||||
is_conv_admin = _is_admin(cid, user_id)
|
||||
|
||||
if not is_author and not is_conv_admin:
|
||||
return _resp(403, {"error": "can only delete own messages or be admin"})
|
||||
|
||||
db.delete("messages", mid)
|
||||
|
||||
# Publish delete event
|
||||
realtime.publish(
|
||||
"conversation:" + cid,
|
||||
"message.deleted",
|
||||
{"id": mid, "conversation_id": cid, "deleted_by": user_id},
|
||||
)
|
||||
|
||||
return _resp(200, {"ok": True})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Participant handlers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _handle_list_participants(cid, user_id):
|
||||
"""List participants in a conversation."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
parts = db.query("participants", filters={"conversation_id": cid}, limit=500)
|
||||
return _resp(200, {"data": parts or []})
|
||||
|
||||
|
||||
def _handle_add_participant(cid, req, user_id):
|
||||
"""Add a participant (admin only)."""
|
||||
if not _is_admin(cid, user_id):
|
||||
return _resp(403, {"error": "admin only"})
|
||||
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
pid = _str(body.get("participant_id", ""))
|
||||
if not pid:
|
||||
return _resp(400, {"error": "participant_id required"})
|
||||
|
||||
ptype = _str(body.get("participant_type", "user"))
|
||||
display_name = _str(body.get("display_name", ""))
|
||||
role = _str(body.get("role", "member"))
|
||||
|
||||
p = add_participant(cid, pid, ptype, display_name, role)
|
||||
return _resp(201, p)
|
||||
|
||||
|
||||
def _handle_remove_participant(cid, pid, user_id):
|
||||
"""Remove a participant (admin only, or self-remove)."""
|
||||
is_self = pid == user_id
|
||||
if not is_self and not _is_admin(cid, user_id):
|
||||
return _resp(403, {"error": "admin only (or remove yourself)"})
|
||||
|
||||
remove_participant(cid, pid)
|
||||
return _resp(200, {"ok": True})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Read cursor handlers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _handle_mark_read(cid, req, user_id):
|
||||
"""Mark conversation as read up to a message."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
mid = _str(body.get("last_read_message_id", ""))
|
||||
if not mid:
|
||||
return _resp(400, {"error": "last_read_message_id required"})
|
||||
|
||||
mark_read(cid, user_id, mid)
|
||||
return _resp(200, {"ok": True})
|
||||
|
||||
|
||||
def _handle_unread(user_id):
|
||||
"""Get unread counts for all user's conversations."""
|
||||
my_parts = db.query("participants", filters={"participant_id": user_id}, limit=500)
|
||||
if not my_parts:
|
||||
return _resp(200, {"data": {}})
|
||||
|
||||
counts = {}
|
||||
for p in my_parts:
|
||||
cid = _str(p.get("conversation_id", ""))
|
||||
if not cid:
|
||||
continue
|
||||
|
||||
# Get read cursor
|
||||
cursors = db.query("read_cursors", filters={"conversation_id": cid, "participant_id": user_id}, limit=1)
|
||||
|
||||
if cursors and len(cursors) > 0:
|
||||
last_read_id = _str(cursors[0].get("last_read_message_id", ""))
|
||||
if last_read_id:
|
||||
# Get the created_at of the last read message
|
||||
last_read_msgs = db.query("messages", filters={"id": last_read_id}, limit=1)
|
||||
if last_read_msgs and len(last_read_msgs) > 0:
|
||||
last_read_at = _str(last_read_msgs[0].get("created_at", ""))
|
||||
# Count messages after the read cursor
|
||||
unread = db.query("messages", filters={"conversation_id": cid}, after={"created_at": last_read_at}, limit=1000)
|
||||
counts[cid] = len(unread or [])
|
||||
else:
|
||||
# Last read message was deleted — count all
|
||||
all_msgs = db.query("messages", filters={"conversation_id": cid}, limit=1000)
|
||||
counts[cid] = len(all_msgs or [])
|
||||
else:
|
||||
# No cursor value — all messages are unread
|
||||
all_msgs = db.query("messages", filters={"conversation_id": cid}, limit=1000)
|
||||
counts[cid] = len(all_msgs or [])
|
||||
else:
|
||||
# No cursor at all — all messages are unread
|
||||
all_msgs = db.query("messages", filters={"conversation_id": cid}, limit=1000)
|
||||
counts[cid] = len(all_msgs or [])
|
||||
|
||||
return _resp(200, {"data": counts})
|
||||
Reference in New Issue
Block a user