# retoor <retoor@molodetz.nl>
import asyncio
import logging
from datetime import datetime
from typing import Annotated, Optional
from fastapi import Depends, APIRouter, Request, WebSocket, WebSocketDisconnect
from devplacepy.models import MessageForm
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.database import (
get_table,
db,
get_users_by_uids,
search_users_by_username,
get_blocked_uids,
mark_notifications_read_by_target,
)
from devplacepy.attachments import get_attachments_batch
from devplacepy.templating import clear_messages_cache
from devplacepy.utils import (
require_user,
time_ago,
is_admin,
_user_from_session,
_user_from_api_key,
)
from devplacepy.seo import base_seo_context
from devplacepy.responses import respond, action_result
from devplacepy.schemas import ConversationOut, MessagesOut
from devplacepy.services import presence
from devplacepy.services.audit import record as audit
from devplacepy.services.correction import PENDING_SCOPE_KEY
from devplacepy.dependencies import json_or_form
from devplacepy.services.messaging import (
issue_ticket,
message_frame,
message_hub,
message_relay,
persist_message,
redeem_ticket,
)
logger = logging.getLogger(__name__)
router = APIRouter()
MAX_WS_ATTACHMENTS = 5
CONVERSATION_MESSAGE_LIMIT = 500
MESSAGE_GROUP_GAP_SECONDS = 300
def _grouped_with_previous(sender_uid, created_at, previous_sender_uid, previous_created_at) -> bool:
if previous_sender_uid is None or sender_uid != previous_sender_uid:
return False
try:
current_dt = datetime.fromisoformat(created_at)
previous_dt = datetime.fromisoformat(previous_created_at)
except (TypeError, ValueError):
return False
return (current_dt - previous_dt).total_seconds() <= MESSAGE_GROUP_GAP_SECONDS
def mark_conversation_read(user_uid: str, other_uid: str) -> None:
if "messages" not in db.tables:
return
with db:
db.query(
"UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0",
me=user_uid,
other=other_uid,
)
clear_messages_cache(user_uid)
def get_conversations(user_uid: str):
if "messages" not in db.tables:
return []
latest = list(
db.query(
"SELECT * FROM ("
" SELECT *,"
" CASE WHEN sender_uid = :me THEN receiver_uid ELSE sender_uid END AS other_uid,"
" ROW_NUMBER() OVER ("
" PARTITION BY CASE WHEN sender_uid = :me THEN receiver_uid ELSE sender_uid END"
" ORDER BY created_at DESC, id DESC"
" ) AS rn"
" FROM messages"
" WHERE sender_uid = :me OR receiver_uid = :me"
") WHERE rn = 1 ORDER BY created_at DESC",
me=user_uid,
)
)
blocked = get_blocked_uids(user_uid)
conversations = []
other_uids = []
for msg in latest:
other_uid = msg["other_uid"]
if other_uid in blocked:
continue
other_uids.append(other_uid)
conversations.append(
{
"other_uid": other_uid,
"other_user": None,
"last_message": msg["content"],
"last_message_at": msg["created_at"],
"unread": msg["receiver_uid"] == user_uid and not msg["read"],
}
)
if other_uids:
users_map = get_users_by_uids(other_uids)
for conv in conversations:
conv["other_user"] = users_map.get(conv["other_uid"])
for conv in conversations:
conv.pop("other_uid", None)
return conversations
def get_conversation_messages(user_uid: str, other_uid: str):
if other_uid in get_blocked_uids(user_uid):
return [], None
if "messages" not in db.tables:
return [], get_users_by_uids([other_uid]).get(other_uid)
msgs = list(
db.query(
"SELECT * FROM messages"
" WHERE (sender_uid = :me AND receiver_uid = :other)"
" OR (sender_uid = :other AND receiver_uid = :me)"
" ORDER BY created_at DESC, id DESC LIMIT :lim",
me=user_uid,
other=other_uid,
lim=CONVERSATION_MESSAGE_LIMIT,
)
)
msgs.reverse()
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
users_map = get_users_by_uids(user_ids)
other_user = users_map.get(other_uid)
result = []
msg_uids = [m["uid"] for m in msgs]
attachments_map = get_attachments_batch("message", msg_uids) if msg_uids else {}
previous_sender_uid = None
previous_created_at = None
for m in msgs:
result.append(
{
"message": m,
"sender": users_map.get(m["sender_uid"]),
"is_mine": m["sender_uid"] == user_uid,
"time_ago": time_ago(m["created_at"]),
"attachments": attachments_map.get(m["uid"], []),
"grouped": _grouped_with_previous(
m["sender_uid"], m["created_at"], previous_sender_uid, previous_created_at
),
}
)
previous_sender_uid = m["sender_uid"]
previous_created_at = m["created_at"]
return result, other_user
@router.get("", response_class=HTMLResponse)
async def messages_page(request: Request, with_uid: str = None, search: str = ""):
user = require_user(request)
conversations = get_conversations(user["uid"])
if search:
users_table = get_table("users")
found = users_table.find_one(username=search)
if found:
with_uid = found["uid"]
current_conversation = None
messages = []
other_user = None
other_online = False
other_last_seen = None
if with_uid:
messages, other_user = get_conversation_messages(user["uid"], with_uid)
mark_conversation_read(user["uid"], with_uid)
mark_notifications_read_by_target(
user["uid"], f"/messages?with_uid={with_uid}"
)
current_conversation = with_uid
other_online = presence.is_online(other_user)
other_last_seen = other_user.get("last_seen") if other_user else None
audit.record(
request,
"message.read_on_view",
user=user,
target_type="user",
target_uid=with_uid,
target_label=other_user.get("username") if other_user else None,
metadata={"message_count": len(messages)},
summary=f"{user['username']} read messages from {other_user.get('username') if other_user else with_uid}",
links=[audit.target("user", with_uid, other_user.get("username") if other_user else None)],
)
seo_ctx = base_seo_context(
request,
title="Messages",
robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Messages", "url": "/messages"},
],
)
return respond(
request,
"messages.html",
{
**seo_ctx,
"request": request,
"user": user,
"conversations": conversations,
"messages": messages,
"other_user": other_user,
"current_conversation": current_conversation,
"search": search,
"other_online": other_online,
"other_last_seen": other_last_seen,
},
model=MessagesOut,
)
@router.get("/search")
async def search_users(request: Request, q: str = ""):
user = require_user(request)
if not q or len(q) < 1:
return JSONResponse({"results": []})
results = search_users_by_username(q, exclude_uid=user["uid"])
return JSONResponse({"results": results})
@router.get("/conversations")
async def list_conversations(request: Request):
user = require_user(request)
conversations = get_conversations(user["uid"])
payload = [ConversationOut.model_validate(c).model_dump() for c in conversations]
return JSONResponse({"conversations": payload})
@router.post("/ws-ticket")
async def create_ws_ticket(request: Request):
user = require_user(request)
token = issue_ticket(user["uid"])
return JSONResponse({"ticket": token, "expires_in": 30})
@router.post("/send")
async def send_message(request: Request, data: Annotated[MessageForm, Depends(json_or_form(MessageForm))]):
user = require_user(request)
receiver_uid = data.receiver_uid
message = persist_message(
user,
receiver_uid,
data.content,
data.attachment_uids,
request=request,
origin="web",
)
if message is None:
return action_result(request, "/messages")
ai_processed = await _finalize_and_broadcast(
user, message, request, client_id=data.client_id
)
frame = message_frame(
message, user.get("username", ""), data.client_id,
sender_role=user.get("role"), ai_processed=ai_processed,
)
return action_result(
request, f"/messages?with_uid={receiver_uid}", data=frame
)
async def broadcast_message(
sender: dict, message: dict, client_id: Optional[str] = None,
ai_processed: bool = False,
) -> None:
frame = message_frame(
message, sender.get("username", ""), client_id,
sender_role=sender.get("role"), ai_processed=ai_processed,
)
message_hub.mark_delivered(message["uid"])
targets = [message["sender_uid"], message["receiver_uid"]]
await message_hub.send_to_users(targets, frame)
async def _finalize_and_broadcast(
sender: dict, message: dict, request: object, client_id: Optional[str] = None
) -> bool:
message_hub.mark_delivered(message["uid"])
scope = getattr(request, "scope", None)
pending = scope.get(PENDING_SCOPE_KEY) if scope is not None else None
ai_processed = bool(pending)
if pending:
await asyncio.gather(*pending, return_exceptions=True)
pending.clear()
row = get_table("messages").find_one(uid=message["uid"])
if row:
message["content"] = row["content"]
await broadcast_message(sender, message, client_id, ai_processed=ai_processed)
return ai_processed
def _resolve_ws_user(websocket: WebSocket):
user = _user_from_session(websocket)
if user:
return user
ticket = websocket.query_params.get("ticket", "").strip()
if ticket:
user_uid = redeem_ticket(ticket)
if user_uid:
return get_table("users").find_one(uid=user_uid)
key = websocket.headers.get("x-api-key", "").strip()
if not key:
scheme, _, credentials = websocket.headers.get("authorization", "").partition(
" "
)
if scheme.lower() == "bearer":
key = credentials.strip()
if key:
return _user_from_api_key(key)
return None
@router.websocket("/ws")
async def messages_ws(websocket: WebSocket):
await websocket.accept()
user = _resolve_ws_user(websocket)
if not user:
await websocket.close(code=1008)
return
user_uid = user["uid"]
message_hub.register(user_uid, websocket)
message_relay.start()
try:
await websocket.send_json({"type": "ready", "user_uid": user_uid})
while True:
data = await websocket.receive_json()
kind = data.get("type")
if kind == "send":
receiver_uid = str(data.get("receiver_uid", "")).strip()
content = str(data.get("content", ""))
client_id = data.get("client_id")
raw_attachments = data.get("attachment_uids") or []
attachment_uids = [str(a) for a in raw_attachments][:MAX_WS_ATTACHMENTS]
if not receiver_uid:
continue
message = persist_message(
user,
receiver_uid,
content,
attachment_uids,
request=websocket,
origin="websocket",
)
if message is None:
await websocket.send_json(
{"type": "error", "client_id": client_id, "text": "Message not sent."}
)
continue
await _finalize_and_broadcast(user, message, websocket, client_id)
elif kind == "typing":
receiver_uid = str(data.get("receiver_uid", "")).strip()
if receiver_uid:
await message_hub.send_to_user(
receiver_uid, {"type": "typing", "from_uid": user_uid}
)
elif kind == "read":
with_uid = str(data.get("with_uid", "")).strip()
if with_uid:
mark_conversation_read(user_uid, with_uid)
other_user = get_users_by_uids([with_uid]).get(with_uid)
other_username = other_user.get("username") if other_user else None
audit.record(
websocket,
"message.read_on_view",
user=user,
target_type="user",
target_uid=with_uid,
target_label=other_username,
summary=f"{user['username']} read messages from {other_username or with_uid}",
links=[audit.target("user", with_uid, other_username)],
)
await message_hub.send_to_user(
with_uid, {"type": "read", "by_uid": user_uid}
)
except WebSocketDisconnect:
pass
except Exception: # noqa: BLE001
logger.exception("messages websocket loop failed for %s", user_uid)
finally:
message_hub.unregister(user_uid, websocket)