|
# retoor <retoor@molodetz.nl>
|
|
|
|
import asyncio
|
|
import logging
|
|
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 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 (
|
|
message_frame,
|
|
message_hub,
|
|
message_relay,
|
|
persist_message,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
MAX_WS_ATTACHMENTS = 5
|
|
|
|
CONVERSATION_MESSAGE_LIMIT = 500
|
|
|
|
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 {}
|
|
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"], []),
|
|
}
|
|
)
|
|
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.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")
|
|
|
|
await _finalize_and_broadcast(user, message, request)
|
|
return action_result(
|
|
request, f"/messages?with_uid={receiver_uid}", data={"uid": message["uid"]}
|
|
)
|
|
|
|
async def broadcast_message(
|
|
sender: dict, message: dict, client_id: Optional[str] = None
|
|
) -> None:
|
|
frame = message_frame(message, sender.get("username", ""), client_id, sender_role=sender.get("role"))
|
|
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
|
|
) -> None:
|
|
message_hub.mark_delivered(message["uid"])
|
|
scope = getattr(request, "scope", None)
|
|
pending = scope.get(PENDING_SCOPE_KEY) if scope is not None else None
|
|
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)
|
|
|
|
def _resolve_ws_user(websocket: WebSocket):
|
|
user = _user_from_session(websocket)
|
|
if user:
|
|
return user
|
|
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)
|