Updpdate
Some checks failed
DevPlace CI / test (push) Failing after 7m3s

This commit is contained in:
retoor 2026-07-23 00:02:43 +02:00
parent 34fa56a836
commit 64c3983c9f
37 changed files with 2105 additions and 824 deletions

View File

@ -72,7 +72,7 @@ devplacepy/
| `/uploads` | File upload endpoints: `POST /uploads/upload` (multipart), `POST /uploads/upload-url` (from URL); served at `/static/uploads/` |
| `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform |
| `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image and YouTube embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants. An opened conversation loads its 500 most recent messages; older history is retained in the database. The `POST /messages/send` form remains as a no-JavaScript fallback |
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image, video and audio embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants (the `message` WS frame carries an additive `ai_processed` flag when a pending correction/modification was applied). An opened conversation loads its 500 most recent messages; older history is retained in the database. `GET /messages/conversations` returns the conversation list as JSON for a live client refresh; the `POST /messages/send` form remains as a no-JavaScript fallback and now accepts an attachment-only, empty-content message. `POST /messages/ws-ticket` exchanges a session/API-key auth to a short-lived (30s), single-use ticket a browser WebSocket can carry as `?ticket=...` on the handshake, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header - this is what makes off-session embedding of the chat possible. CLI: `devplace messaging prune-tickets` removes expired tickets |
| `/votes` | Upvote/downvote on posts, comments, projects |
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |

View File

@ -14,6 +14,7 @@ from devplacepy.cli.containers import register_containers
from devplacepy.cli.migrate import register_migrate
from devplacepy.cli.game import register_game
from devplacepy.cli.gateway import register_gateway
from devplacepy.cli.messaging import register_messaging
def build_parser():
@ -32,6 +33,7 @@ def build_parser():
register_migrate(sub)
register_game(sub)
register_gateway(sub)
register_messaging(sub)
return parser

View File

@ -0,0 +1,29 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_messaging_prune_tickets(args):
from datetime import datetime, timezone
from devplacepy.database import get_table
now = datetime.now(timezone.utc).isoformat()
tickets = get_table("ws_tickets")
expired = list(tickets.find(expires_at={"<": now}))
for ticket in expired:
tickets.delete(uid=ticket["uid"])
_audit_cli(
"cli.messaging.prune_tickets",
f"CLI pruned {len(expired)} expired WS tickets",
metadata={"count": len(expired)},
)
print(f"Pruned {len(expired)} expired WS ticket(s)")
def register_messaging(subparsers):
messaging = subparsers.add_parser("messaging", help="Messaging WS ticket management")
messaging_sub = messaging.add_subparsers(title="action", dest="action")
messaging_prune_tickets = messaging_sub.add_parser(
"prune-tickets", help="Delete expired WebSocket auth tickets"
)
messaging_prune_tickets.set_defaults(func=cmd_messaging_prune_tickets)

View File

@ -57,9 +57,9 @@ four ways to sign requests.
"content",
"form",
"textarea",
True,
False,
"Hello there.",
"Body, 1-2000 characters.",
"Body, 0-2000 characters. May be empty when at least one attachment is provided.",
),
field(
"receiver_uid",
@ -71,5 +71,38 @@ four ways to sign requests.
),
],
),
endpoint(
id="messages-conversations",
method="GET",
path="/messages/conversations",
title="List conversations",
summary="Return the signed-in user's conversation list as JSON, for live refresh without a full page reload.",
auth="user",
interactive=False,
sample_response={
"conversations": [
{
"other_user": {"uid": "8f14e45f-...", "username": "alice_test"},
"last_message": "Hello there.",
"last_message_at": "2026-07-21T10:00:00+00:00",
"unread": True,
}
]
},
),
endpoint(
id="messages-ws-ticket",
method="POST",
path="/messages/ws-ticket",
title="Issue a WebSocket ticket",
summary="Exchange the caller's session/API-key auth for a short-lived, single-use ticket that a browser WebSocket handshake can carry as a query parameter (a native WebSocket cannot set custom auth headers).",
auth="user",
encoding="none",
interactive=False,
notes=[
"The ticket is valid for 30 seconds and can be redeemed exactly once, as `wss://.../messages/ws?ticket=<ticket>`.",
],
sample_response={"ticket": "3f9c2a...", "expires_in": 30},
),
],
}

View File

@ -386,9 +386,10 @@ class ContainerScheduleForm(BaseModel):
class MessageForm(BaseModel):
content: str = Field(min_length=1, max_length=2000)
content: str = Field(min_length=0, max_length=2000)
receiver_uid: str = Field(min_length=1, max_length=36)
attachment_uids: list[str] = []
client_id: Optional[str] = Field(default=None, max_length=64)
class ProfileForm(BaseModel):

View File

@ -15,7 +15,7 @@ Prefixes are wired in `main.py`:
| `/projects` | projects/ package - `index.py` (listing/detail/create/delete plus owner-only visibility toggles `POST /{slug}/private` and `POST /{slug}/readonly`, and async `POST /{slug}/fork`), `files.py`, and the `containers/` subpackage (below). `main.py` mounts the whole `/projects` tree from this one package. See `routers/projects/CLAUDE.md` for the deep detail on this tree |
| `/projects/{slug}/files` | projects/files.py - per-project virtual filesystem (CRUD dirs/files, upload, inline edit, plus line-range ops: `lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append` for large text files) |
| `/profile` | profile/ package - `index.py` (page/search/followers/update/api-key; the **Posts** tab shows only the 10 most recent posts, while the sidebar `posts_count` stat always uses the real `count()` so it is never capped to the rendered 10), `customization.py`, `notifications.py`, `ai_correction.py` (`POST /{username}/ai-correction`, owner-or-admin AI content correction toggle + prompt), `ai_modifier.py` (`POST /{username}/ai-modifier`, owner-or-admin AI modifier toggle + prompt), `interactions.py` (`POST /{username}/interactions`, owner-or-admin Devii interactive widgets preference; admin default via `devii_interactions_default`), `telegram.py` (`POST /{username}/telegram`, owner-or-admin Telegram pairing-code request/unpair), `usage.py` (`_ai_quota`, re-exported from the package) |
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `POST /send` (no-JS fallback), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, read receipts, and in-process presence on top of the existing `messages` table; both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live) and broadcasts the FINAL corrected/modified content (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `GET /conversations` (flat JSON conversation list, same `get_conversations` helper as the page), `POST /send` (no-JS fallback, also accepts an attachment-only empty-content message), `POST /ws-ticket` (issues a 30s single-use WS auth ticket so a browser WebSocket can authenticate cross-context, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, and read receipts on top of the existing `messages` table - presence is NOT part of this WS (it is the generic `PresenceManager`/pub/sub mechanism, see `devplacepy/services/CLAUDE.md`). Both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live), broadcasts the FINAL corrected/modified content with an additive `ai_processed` frame flag (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
| `/notifications` | notifications.py |
| `/votes` | votes.py |
| `/reactions` | reactions.py - emoji reaction toggle on any target: `POST /reactions/{target_type}/{target_uid}` (optimistic `ReactionBar`) |

View File

@ -2,6 +2,7 @@
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
@ -25,16 +26,18 @@ from devplacepy.utils import (
)
from devplacepy.seo import base_seo_context
from devplacepy.responses import respond, action_result
from devplacepy.schemas import MessagesOut
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__)
@ -44,6 +47,18 @@ 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
@ -123,6 +138,8 @@ def get_conversation_messages(user_uid: str, other_uid: str):
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(
{
@ -131,8 +148,13 @@ def get_conversation_messages(user_uid: str, other_uid: str):
"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)
@ -207,6 +229,19 @@ async def search_users(request: Request, q: str = ""):
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)
@ -223,37 +258,54 @@ async def send_message(request: Request, data: Annotated[MessageForm, Depends(js
if message is None:
return action_result(request, "/messages")
await _finalize_and_broadcast(user, message, request)
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={"uid": message["uid"]}
request, f"/messages?with_uid={receiver_uid}", data=frame
)
async def broadcast_message(
sender: dict, message: dict, client_id: Optional[str] = None
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"))
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
) -> 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)
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(

View File

@ -70,6 +70,7 @@ class MessageItemOut(_Out):
is_mine: bool = False
time_ago: Optional[str] = None
attachments: list[AttachmentOut] = []
grouped: bool = False
class NotificationItemOut(_Out):

View File

@ -79,6 +79,10 @@ Beyond the avatar, Devii has a `client`/browser channel using the same request/r
**Target selection (visibility-aware).** Unlike replies/traces, which `_emit` *broadcasts* to every tab, a browser request is sent to one **target** chosen by `_pick_target()`. The terminal reports each connection's `document` visibility and focus via `{"type":"visibility"}` (on connect, `focus`, `blur`, and `visibilitychange`); the session stores it in `_conn_meta` and ranks connections `(focused, visible, attach_seq)`, so the command runs on the tab the user is actually looking at, falling back to the most recently attached when none reports focus. **Regression to avoid: do NOT route to a single "last-attached primary" socket** - with the `/docs` auto-open tab or any second tab, `reload_page`/`navigate_to` ran on the wrong (hidden) tab while still reporting success, so "the reload did not happen" from the user's view. `run_js` is gated by the `devii_allow_eval` config field (default on), checked in `ClientController` before any round-trip. Overlays (`.devii-hl-box`, `.devii-hl-callout`, `.devii-toast`) attach to `document.body`, not the terminal.
## Stale API key auto-recovery (401 self-healing)
A user session captures the owner's platform `api_key` into its frozen `Settings` at build time (both `Settings.ai_key` for the gateway and `Settings.platform_api_key` for REST). Regenerating that key (profile regenerate, Devii tool, `devplace apikey reset` - possibly from another worker or process) would strand every live session with a permanent `401 Unauthorized` on all LLM and platform calls. Recovery is **reactive at the two HTTP chokepoints**, never proactive session invalidation (which is per-process and cannot reach the hub on the lock-owner worker): `hub.get_or_create` builds a `_user_key_resolver(owner_id)` (a fresh `users.api_key` DB read, user owners only - guests get `None` and keep the internal gateway key) and passes it to `LLMClient(settings, key_resolver=)` and through `DeviiSession(key_resolver=)` into `PlatformClient`. On a 401 (`LLMClient._post`) or a 401/redirect-to-login (`PlatformClient.call` via `_auth_failed`), the client calls `_refresh_key()`: resolve the current key, and only when it is non-empty AND differs from the header already sent, rewrite the auth headers and retry the request **once**. An unchanged or unresolvable key skips the retry so a genuinely invalid credential still fails closed with the original error. The resolver read is cross-worker correct because it goes straight to SQLite, not any per-process cache.
## Shared browser session (auth adoption)
The terminal and the browser are one session. `/devii/ws` resolves its owner from the browser's `session` cookie (`_resolve_ws_owner`), so the terminal is whoever the browser is logged in as. Agent-initiated auth propagates: the session watches its own trace stream (`_trace`) and, on a successful `login`/`signup`, captures the real session token the `PlatformClient` minted against this instance (`session_cookie()`) and broadcasts `{"type":"auth","action":"adopt"}`; the browser navigates to single-use `GET /devii/adopt` which sets the httpOnly `session` cookie and redirects (reload). On `logout` it broadcasts `{"type":"auth","action":"logout"}` and the browser goes to `/auth/logout`. Browser->terminal: login/logout are navigations that reconnect the WS; a cross-tab change is caught by a focus check against `/devii/session` that reloads. The httpOnly cookie is only ever set/cleared by HTTP endpoints, never JS.

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from typing import Any, Callable
import httpx
@ -19,7 +19,11 @@ LOGIN_PATH = "/auth/login"
class PlatformClient:
def __init__(
self, base_url: str, timeout_seconds: float, api_key: str = ""
self,
base_url: str,
timeout_seconds: float,
api_key: str = "",
key_resolver: Callable[[], str] | None = None,
) -> None:
headers = {
"User-Agent": "devii/0.1",
@ -29,6 +33,7 @@ class PlatformClient:
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers["X-API-Key"] = api_key
self._key_resolver = key_resolver
self._client = stealth.stealth_async_client(
base_url=base_url,
timeout=timeout_seconds,
@ -106,6 +111,10 @@ class PlatformClient:
response = await self._send(
method, path, params=params, data=data, files=files, headers=headers
)
if self._auth_failed(path, response) and self._refresh_key():
response = await self._send(
method, path, params=params, data=data, files=files, headers=headers
)
if path != LOGIN_PATH and LOGIN_PATH in str(response.url):
self.authenticated = False
@ -132,6 +141,29 @@ class PlatformClient:
)
return response
@staticmethod
def _auth_failed(path: str, response: httpx.Response) -> bool:
if response.status_code == 401:
return True
return path != LOGIN_PATH and LOGIN_PATH in str(response.url)
def _refresh_key(self) -> bool:
if self._key_resolver is None:
return False
try:
key = self._key_resolver()
except Exception: # noqa: BLE001 - refresh is best-effort; the original failure still surfaces
logger.exception("Credential refresh failed after auth failure")
return False
if not key or key == self._client.headers.get("X-API-Key"):
return False
self._client.headers["Authorization"] = f"Bearer {key}"
self._client.headers["X-API-Key"] = key
self.authenticated = True
self.username = self.username or "api-key"
logger.info("Refreshed platform credentials after auth failure")
return True
def _open_upload(self, raw_path: str) -> tuple[str, bytes]:
candidate = Path(raw_path).expanduser()
if not candidate.is_file():

View File

@ -22,6 +22,16 @@ logger = logging.getLogger("devii.hub")
SettingsBuilder = Callable[[str, str, str, bool], "tuple[Settings, Pricing]"]
def _user_key_resolver(owner_id: str) -> Callable[[], str]:
def resolve() -> str:
if "users" not in db.tables:
return ""
row = db["users"].find_one(uid=owner_id, deleted_at=None)
return (row or {}).get("api_key") or ""
return resolve
class DeviiHub:
def __init__(self, settings_builder: SettingsBuilder) -> None:
self._build = settings_builder
@ -52,7 +62,10 @@ class DeviiHub:
if session is not None:
return session
settings, pricing = self._build(api_key, base_url, owner_kind, is_admin)
llm = LLMClient(settings)
key_resolver = (
_user_key_resolver(owner_id) if owner_kind == "user" else None
)
llm = LLMClient(settings, key_resolver=key_resolver)
# Persistent, owner-isolated stores for signed-in users; ephemeral in-memory for guests.
# The `docs` channel (Docii) is a self-contained documentation assistant: it gets its own
# ephemeral stores so Devii's tasks, lessons, behavior and virtual tools never leak into it
@ -82,6 +95,7 @@ class DeviiHub:
is_admin=is_admin,
is_primary_admin=is_primary_admin,
channel=channel,
key_resolver=key_resolver,
)
if owner_kind == "user":
saved = self._stores["conversations"].load(owner_kind, owner_id, channel)

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
from typing import Any, Callable
import httpx
@ -16,8 +16,13 @@ logger = logging.getLogger("devii.llm")
class LLMClient:
def __init__(self, settings: Settings) -> None:
def __init__(
self,
settings: Settings,
key_resolver: Callable[[], str] | None = None,
) -> None:
self._settings = settings
self._key_resolver = key_resolver
self._client = stealth.stealth_async_client(
timeout=settings.timeout_seconds,
headers={
@ -29,6 +34,26 @@ class LLMClient:
async def aclose(self) -> None:
await self._client.aclose()
async def _post(self, payload: dict[str, Any]) -> httpx.Response:
response = await self._client.post(self._settings.ai_url, json=payload)
if response.status_code == 401 and self._refresh_key():
response = await self._client.post(self._settings.ai_url, json=payload)
return response
def _refresh_key(self) -> bool:
if self._key_resolver is None:
return False
try:
key = self._key_resolver()
except Exception: # noqa: BLE001 - refresh is best-effort; the original 401 still surfaces
logger.exception("Credential refresh failed after 401")
return False
if not key or self._client.headers.get("Authorization") == f"Bearer {key}":
return False
self._client.headers["Authorization"] = f"Bearer {key}"
logger.info("Refreshed model endpoint credentials after 401")
return True
async def complete(
self,
messages: list[dict[str, Any]],
@ -42,7 +67,7 @@ class LLMClient:
}
try:
logger.debug("LLM request with %d messages", len(messages))
response = await self._client.post(self._settings.ai_url, json=payload)
response = await self._post(payload)
except httpx.HTTPError as exc:
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
@ -82,7 +107,7 @@ class LLMClient:
"temperature": temperature,
}
try:
response = await self._client.post(self._settings.ai_url, json=payload)
response = await self._post(payload)
except httpx.HTTPError as exc:
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
if response.status_code >= 400:
@ -111,7 +136,7 @@ class LLMClient:
"temperature": 0.0,
}
try:
response = await self._client.post(self._settings.ai_url, json=payload)
response = await self._post(payload)
except httpx.HTTPError as exc:
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
if response.status_code >= 400:

View File

@ -75,6 +75,7 @@ class DeviiSession:
is_admin: bool = False,
is_primary_admin: bool = False,
channel: str = "main",
key_resolver: Any = None,
) -> None:
self.owner_kind = owner_kind
self.owner_id = owner_id
@ -89,7 +90,10 @@ class DeviiSession:
self._llm = llm
self._lessons = lessons
self.client = PlatformClient(
settings.base_url, settings.timeout_seconds, settings.platform_api_key
settings.base_url,
settings.timeout_seconds,
settings.platform_api_key,
key_resolver=key_resolver,
)
self.avatar = AvatarController()
self.browser = ClientController(

View File

@ -4,46 +4,69 @@ This file documents the real-time direct-message chat subsystem. Claude Code aut
## Overview
The `/messages` feature is a live WebSocket chat layered over the SAME `messages` table and audit/notification cores as before; there is no parallel chat store. The WS only adds live delivery, presence, typing, and read receipts on top of the existing persistence.
The `/messages` feature is a live WebSocket chat layered over the SAME `messages` table and audit/notification cores as before; there is no parallel chat store. The WS only adds live delivery, typing, and read receipts on top of the existing persistence - presence is NOT part of this WS (see "Presence is not part of the messaging WS" below).
`messages.py` (the router, mounted at `/messages`) exposes: `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `POST /send` (no-JS fallback), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests).
`messages.py` (the router, mounted at `/messages`) exposes: `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `GET /conversations` (flat JSON conversation list, for a client-side refresh without a full page reload), `POST /send` (no-JS fallback, now also accepting an attachment-only empty-content message), `POST /ws-ticket` (issues a short-lived WS auth ticket), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests).
## Bounded page queries (load-bearing performance rule)
`get_conversations` is ONE window-function query (`ROW_NUMBER() OVER (PARTITION BY other_uid ORDER BY created_at DESC, id DESC)` over `sender_uid = :me OR receiver_uid = :me`, the `get_recent_comments_by_target_uids` precedent) that returns only each partner's latest row - never load the user's full message history into Python to build the sidebar. `get_conversation_messages` loads at most `CONVERSATION_MESSAGE_LIMIT` (500) most-recent rows (`ORDER BY created_at DESC, id DESC LIMIT`, reversed to ascending in Python); older history stays in the DB and is simply not rendered. Both are covered by `idx_messages_conversation`/`_rev` (verified `EXPLAIN QUERY PLAN`, MULTI-INDEX OR, no table scan). `messages` is NOT in `SOFT_DELETE_TABLES`, so neither query filters `deleted_at`. Any new message listing must stay bounded and indexed the same way.
`get_conversations` is ONE window-function query (`ROW_NUMBER() OVER (PARTITION BY other_uid ORDER BY created_at DESC, id DESC)` over `sender_uid = :me OR receiver_uid = :me`, the `get_recent_comments_by_target_uids` precedent) that returns only each partner's latest row - never load the user's full message history into Python to build the sidebar. It backs both the server-rendered `GET /messages` page and the JSON `GET /messages/conversations` route (same helper, no duplicated query). `get_conversation_messages` loads at most `CONVERSATION_MESSAGE_LIMIT` (500) most-recent rows (`ORDER BY created_at DESC, id DESC LIMIT`, reversed to ascending in Python); older history stays in the DB and is simply not rendered. Both are covered by `idx_messages_conversation`/`_rev` (verified `EXPLAIN QUERY PLAN`, MULTI-INDEX OR, no table scan). `messages` is NOT in `SOFT_DELETE_TABLES`, so neither query filters `deleted_at`. Any new message listing must stay bounded and indexed the same way.
## `GET /messages/conversations`
Additive JSON endpoint (`require_user`), added so a frontend component can refresh the conversation list without a full page reload. It calls the exact same `get_conversations(user_uid)` helper `GET /messages` already uses, then projects each entry through `schemas.ConversationOut` (which nests `other_user` as `UserOut`, stripping `email`/`api_key`/`password_hash`) before returning `{"conversations": [...]}`. Like `GET /messages/search`, this is flat JSON, not content-negotiated through `respond()`.
## WS endpoint `/messages/ws`
Lives on the existing messages router (no new router/prefix; already mounted at `/messages`). It is **NOT lock-owner gated** (deliberately - unlike `/devii/ws`): `await websocket.accept()`, resolve the owner via session cookie / api-key (`_resolve_ws_user` reuses `utils._user_from_session`/`_user_from_api_key`), and accept on EVERY worker. Gating on `service_manager.owns_lock()` was the original design and was wrong here: when no worker holds the service lock (services disabled, or the lock not yet acquired) every socket got closed `4013` and the client fast-retried every 200ms forever (a connection storm with no stream, and the send form fell back to a full-page POST reload). Chat must not depend on the background-service lock. Messaging requires an authenticated user - guests are closed with `1008`. The receive loop is wrapped in `try/except WebSocketDisconnect` + a broad `except` (never crash the worker) and ALWAYS unregisters the socket in `finally`.
Lives on the existing messages router (no new router/prefix; already mounted at `/messages`). It is **NOT lock-owner gated** (deliberately - unlike `/devii/ws`): `await websocket.accept()`, resolve the owner via `_resolve_ws_user`, and accept on EVERY worker. Gating on `service_manager.owns_lock()` was the original design and was wrong here: when no worker holds the service lock (services disabled, or the lock not yet acquired) every socket got closed `4013` and the client fast-retried every 200ms forever (a connection storm with no stream, and the send form fell back to a full-page POST reload). Chat must not depend on the background-service lock. Messaging requires an authenticated user - guests are closed with `1008`. The receive loop is wrapped in `try/except WebSocketDisconnect` + a broad `except` (never crash the worker) and ALWAYS unregisters the socket in `finally`.
### `_resolve_ws_user` auth resolution order
1. **Session cookie** (`_user_from_session`) - the same-origin `page` mode path; browsers send cookies on a same-origin WS upgrade, so this needs no special handling.
2. **WS ticket** (`ticket` query param, tried AFTER the session-cookie check and BEFORE the header checks - additive, inserted here specifically so it never changes same-origin `page`-mode behavior): if present, `redeem_ticket(ticket)` looks it up; on success it resolves to the ticket's owner user row. This is the ONLY way a real browser can authenticate a cross-context (`embed` mode / third-party) WebSocket, because the native `WebSocket` constructor cannot set custom request headers - there is no way to attach `X-API-KEY`/`Authorization` to `new WebSocket(url)` in any browser. See "WS auth tickets" below for the full mechanism. If the ticket is missing/expired/already-used, resolution simply falls through to the header checks below (never a hard reject at this point - only the final `if not user` in the WS handler closes the socket).
3. **`X-API-KEY` header**, else **`Authorization: Bearer <key>`** - the existing non-browser API-client path (unreachable from a browser's native `WebSocket`, but real and unchanged for programmatic clients that can set headers, e.g. a server-side bot).
## WS auth tickets (`services/messaging/tickets.py`, table `ws_tickets`)
Short-lived, single-use tokens that let a browser-based WebSocket authenticate without ever needing to set a request header on the handshake. `POST /messages/ws-ticket` (`require_user`, so it accepts the same HTTP-reachable auth as any other guarded route - session cookie, `X-API-KEY`, or `Authorization: Bearer` - over a normal fetch/XHR, where headers work fine) calls `issue_ticket(user_uid)` and returns `{"ticket": "<opaque hex token>", "expires_in": 30}`. The client then opens `wss://.../messages/ws?ticket=<ticket>` - a query parameter, which a WS handshake CAN carry.
- `issue_ticket(user_uid) -> str` inserts a `ws_tickets` row (`uid` uuid7, `token` = `secrets.token_hex(24)`, `user_uid`, `created_at`, `expires_at` = now + 30s, `used_at=None`) and returns the token.
- `redeem_ticket(token) -> Optional[str]` looks the row up by `token`; returns `None` if missing, already used (`used_at` set), or past `expires_at`; otherwise stamps `used_at` and returns the `user_uid`. Redemption is single-use by construction - a ticket leaked via referrer or browser history cannot be replayed once redeemed, and it self-expires after 30 seconds even if never used.
- `ws_tickets` is NOT in `SOFT_DELETE_TABLES` - it is an ephemeral, single-use artifact, garbage-collected hard via `devplace messaging prune-tickets` (deletes rows where `expires_at < now`), the same shape as the zip/fork/backup `prune` CLI commands. `database/schema.py` `init_db()` ensures its columns (`uid`, `token`, `user_uid`, `created_at`, `expires_at`, `used_at`) and a unique index on `token` (the hot lookup path) plus an index on `expires_at` (the prune sweep).
- This is strictly additive to `_resolve_ws_user` - the existing session-cookie and header paths are completely unchanged, so `mode="page"` behavior and every existing non-browser API consumer are unaffected.
## Connection registry
The `ConnectionManager` singleton `message_hub` (`services/messaging/hub.py`), **one per worker**: `user_uid -> set[WebSocket]`, plus an in-process `last_seen` map and a bounded `delivered` LRU set (`mark_delivered`/`was_delivered`, cap 4000) used to dedupe direct vs relayed delivery. `register`/`unregister` return whether the user just came online / went offline (for presence announces). `send_to_user`/`send_to_users` fan a frame out to all of a user's sockets (multi-tab) and swallow dead-socket errors. Presence and last-seen are in-process per worker (no DB column).
The `ConnectionManager` singleton `message_hub` (`services/messaging/hub.py`), **one per worker**: `user_uid -> set[WebSocket]`, plus a bounded (4000-entry) `delivered` LRU dedupe set (`mark_delivered`/`was_delivered`) used to dedupe direct vs relayed delivery. `register`/`unregister` track connections for delivery only - **no presence data lives here** (see below).
## Cross-worker delivery (the relay)
Because the WS accepts on every worker, a message persisted on worker A must still reach a recipient whose socket lives on worker B. `services/messaging/relay.py` `message_relay` (a per-worker singleton asyncio loop, started on the first socket connect, self-stops when `message_hub.has_connections()` is false) polls `SELECT * FROM messages WHERE id > :watermark` (uuid7-backed autoincrement `id`) every ~1s and pushes any row whose sender/receiver has a LOCAL socket and that was not already `was_delivered` to those local sockets, advancing the watermark to the max row seen. Same-worker sends are delivered INSTANTLY by `broadcast_message` (which calls `message_hub.mark_delivered` so the relay skips them); the relay only fills the cross-worker gap, so same-worker latency is zero and cross-worker latency is bounded by the poll interval. The relay primes its watermark to the current `MAX(id)` on first start so it never replays history. **Trade-off:** typing/read/presence frames are in-process per worker only - across the two `make prod` workers those ephemeral signals reach only same-worker peers (message delivery is always correct). On a single dev worker everything is instant and complete.
Because the WS accepts on every worker, a message persisted on worker A must still reach a recipient whose socket lives on worker B. `services/messaging/relay.py` `message_relay` (a per-worker singleton asyncio loop, started on the first socket connect, self-stops when `message_hub.has_connections()` is false) polls `SELECT * FROM messages WHERE id > :watermark` (uuid7-backed autoincrement `id`) every ~1s and pushes any row whose sender/receiver has a LOCAL socket and that was not already `was_delivered` to those local sockets, advancing the watermark to the max row seen. Same-worker sends are delivered INSTANTLY by `broadcast_message` (which calls `message_hub.mark_delivered` so the relay skips them); the relay only fills the cross-worker gap, so same-worker latency is zero and cross-worker latency is bounded by the poll interval. The relay primes its watermark to the current `MAX(id)` on first start so it never replays history. **Trade-off:** typing/read frames are in-process per worker only - across the two `make prod` workers those ephemeral signals reach only same-worker peers (message delivery is always correct). On a single dev worker everything is instant and complete.
## DRY persist choke point
`services/messaging/persist.py` `persist_message(sender, receiver_uid, content, attachment_uids, *, request=None, origin)` is the ONE function that inserts the row, links attachments, fires `create_notification` + `clear_messages_cache` + `create_mention_notifications`, logs, and writes the `message.send` audit event. Both the WS `send` handler and the HTTP `POST /messages/send` handler call it, so audit/notification/mention behavior is byte-identical on both paths (the Devii `send_message` action is `handler="http"` -> `POST /messages/send`, so it also flows through here and broadcasts live). Content is capped at 2000 chars server-side. When `request` is a `Request` it audits via `audit.record`; on the WS path it now passes `request=websocket` (auditing via `audit.record_system` with `actor_kind="user"` + `origin="websocket"`, same `message.send` key/category - no new event invented; presence/typing/read are ephemeral and NOT audited).
`services/messaging/persist.py` `persist_message(sender, receiver_uid, content, attachment_uids, *, request=None, origin)` is the ONE function that inserts the row, links attachments, fires `create_notification` + `clear_messages_cache` + `create_mention_notifications`, logs, and writes the `message.send` audit event. Both the WS `send` handler and the HTTP `POST /messages/send` handler call it, so audit/notification/mention behavior is byte-identical on both paths (the Devii `send_message` action is `handler="http"` -> `POST /messages/send`, so it also flows through here and broadcasts live). Content is capped at 2000 chars server-side; `content` itself is allowed to be empty (`MessageForm.content` is `min_length=0`) as long as at least one attachment is present - `persist_message` is the single source of truth for that rule (`if not content and not attachment_uids: return None`), so the HTTP form model deliberately does not duplicate it. Whenever `request` is not `None` it audits via `audit.record(request, ...)` - this covers BOTH the HTTP `Request` and the WS path, since the WS handler passes `request=websocket` and a `WebSocket` object is just as non-`None` as a `Request` (`audit.record` never reads HTTP-specific attributes off it beyond what's already supplied explicitly via `user=sender`). `audit.record_system(..., actor_kind="user", origin=origin)` is the fallback used ONLY when `persist_message` is called with no request/websocket context at all (e.g. a future internal/system-originated send) - same `message.send` key/category either way, no new event invented; typing/read are ephemeral and NOT audited.
## AI correction and AI modifier apply to direct messages, with LIVE delivery of the final content
`"messages": ("content",)` is in the `CORRECTABLE_FIELDS` registry and `persist_message` invokes `schedule_correction`/`schedule_modification` (sender = the user), so typing `@ai <instruction>` in a DM runs the AI modifier (default on + sync) and an enabled correction rewrites the content. Both send paths funnel through `routers/messages._finalize_and_broadcast(sender, message, request, client_id=None)`: it awaits any pending SYNC correction/modification futures stashed on `request.scope[PENDING_SCOPE_KEY]` (the same `PENDING_SCOPE_KEY`/`loop.run_in_executor` mechanism the HTTP `await_pending_corrections` middleware uses), RE-READS the message row, and broadcasts the FINAL (corrected/modified) content via `broadcast_message`. The HTTP `POST /send` and the WS `/ws` send path both call it; the WS path passes `request=websocket` to `persist_message` so the modifier stashes its executor future on the websocket scope and the handler awaits it directly (HTTP middleware does not run for websockets). A message with no `@ai` directive and correction off has nothing pending, so the broadcast is immediate with zero added latency. Frontend `static/js/MessagesLayout.js` reconciles the sender's own echo by `client_id` and replaces the optimistic bubble's `.rendered-content` with the server's authoritative `frame.content`, re-rendered - so the sender sees their `@ai` directive resolved in-place (and corrections become visible to the sender too). See "AI content correction" and "AI modifier".
`"messages": ("content",)` is in the `CORRECTABLE_FIELDS` registry and `persist_message` invokes `schedule_correction`/`schedule_modification` (sender = the user), so typing `@ai <instruction>` in a DM runs the AI modifier (default on + sync) and an enabled correction rewrites the content. Both send paths funnel through `routers/messages._finalize_and_broadcast(sender, message, request, client_id=None)`: it reads any pending SYNC correction/modification futures stashed on `request.scope[PENDING_SCOPE_KEY]` (the same `PENDING_SCOPE_KEY`/`loop.run_in_executor` mechanism the HTTP `await_pending_corrections` middleware uses) into `ai_processed = bool(pending)` **before** awaiting/clearing them, awaits them, RE-READS the message row, and broadcasts the FINAL (corrected/modified) content via `broadcast_message(sender, message, client_id, ai_processed=ai_processed)`. The HTTP `POST /send` and the WS `/ws` send path both call it; the WS path passes `request=websocket` to `persist_message` so the modifier stashes its executor future on the websocket scope and the handler awaits it directly (HTTP middleware does not run for websockets). A message with no `@ai` directive and correction off has nothing pending, so `ai_processed` is `False` and the broadcast is immediate with zero added latency. `broadcast_message` forwards `ai_processed` straight into `message_frame(..., ai_processed=ai_processed)` - see "WS protocol frames" below for the frame shape. This flag is informational only: the server never retains the pre-AI text anywhere (`table.update` overwrites in place), so it exists purely to drive a client-side "Adjusted by AI" affordance, not any kind of diff/revert capability.
## WS protocol frames
Client -> server: `{type:"send", receiver_uid, content, attachment_uids?, client_id}`, `{type:"typing", receiver_uid}` (throttled client-side), `{type:"read", with_uid}`, `{type:"presence", with_uid}` (one-shot presence query). Server -> client: `{type:"ready", user_uid}` (sent first on connect), `{type:"message", uid, sender_uid, sender_username, receiver_uid, content, created_at, time_ago, client_id}` (broadcast to BOTH sender and receiver sockets so multi-tab and the sender's own optimistic bubble reconcile via the echoed `client_id`), `{type:"typing", from_uid}` (to the receiver only), `{type:"read", by_uid}` (read-receipt to the other user), `{type:"presence", user_uid, online, last_seen}` (announced to everyone in an open conversation with the user on connect/disconnect, and as a direct reply to a `presence` query). The WS `read` path reuses `mark_conversation_read` (the same `UPDATE messages SET read=1` SQL the page uses) and `clear_messages_cache`.
Client -> server: `{type:"send", receiver_uid, content, attachment_uids?, client_id}`, `{type:"typing", receiver_uid}` (throttled client-side), `{type:"read", with_uid}`. Server -> client: `{type:"ready", user_uid}` (sent first on connect), `{type:"message", uid, sender_uid, sender_username, sender_role, receiver_uid, content, created_at, time_ago, client_id, attachments, ai_processed}` (broadcast to BOTH sender and receiver sockets so multi-tab and the sender's own optimistic bubble reconcile via the echoed `client_id`; `ai_processed` is additive - `true` only when the broadcast content is the result of an awaited pending correction/modifier future, so an unmodified send is `false` with zero extra computation), `{type:"typing", from_uid}` (to the receiver only), `{type:"read", by_uid}` (read-receipt to the other user), `{type:"error", client_id, text}` (sender only, on a dropped send, e.g. the recipient blocked the sender). There is no `presence` frame on this socket - see below.
## Presence is NOT part of the messaging WS
Presence is handled entirely by the generic, cross-worker-correct mechanism documented in `devplacepy/services/CLAUDE.md` ("Online presence"): a single `users.last_seen` column, written by the `track_presence` HTTP middleware, read server-side at page load via `presence.is_online(user)`, and kept live client-side by `PresenceManager` (`static/js/PresenceManager.js`) subscribing any `[data-presence-uid]` element to the pub/sub topic `public.presence.{uid}`. The messages page's `#messages-presence` span carries `data-presence-uid`/`data-presence-last-seen` like every other avatar-presence site in the app - it is not messaging-specific code. An earlier design had WS-connect-based presence living in `message_hub` (`is_online`/`last_seen`, an `_announce_presence` helper, and a WS `presence` frame); that design was per-worker only and broke with more than one uvicorn worker, so it was removed outright. `message_hub` today tracks ONLY socket connections for message delivery - it has no presence state, and there is no `presence` frame anywhere in the current WS protocol (client or server). **Never re-implement WS-connect presence on this socket** - reuse `presence.is_online`, the `public.presence.{uid}` topic, and `PresenceManager`, exactly as every other presence surface in the app does.
## Renderer integration (XSS control)
Live/echoed message bubbles are NEVER injected as raw HTML. `MessagesLayout.buildBubble` creates a `<div class="rendered-content" data-render>`, sets its `textContent` to the message body, then runs `contentRenderer.applyTo(el)` + marks `img:not(.avatar-img)` with `data-lightbox` + `contentRenderer.highlightAll()` - exactly the `ContentEnhancer.initContentRenderer` sequence (emoji shortcode -> marked -> DOMPurify.sanitize -> highlight.js -> image/YouTube/autolink). This gives Discord-style emoji, image and YouTube embeds, autolinking, and sanitization for free.
Live/echoed message bubbles are NEVER injected as raw HTML. `AppChat._buildBubble` (`static/js/components/AppChat.js`) creates a `<dp-content>` element and sets its `textContent` to the message body - never `innerHTML` - so the element's own client-side pipeline (emoji shortcode -> marked -> `DOMPurify.sanitize` -> highlight.js -> image/YouTube/autolink) renders it safely, exactly like every other live-content surface that uses `<dp-content>`. This gives Discord-style emoji, image and YouTube embeds, autolinking, and sanitization for free.
## Frontend
`MessagesSocket.js` (mirrors `DeviiSocket.js`: reconnect with backoff, `4013` silent fast-retry, `onReady` fires on the first server frame not raw open) points at `/messages/ws`. `MessagesLayout.js` (instantiated on `app` in `Application.js`) opens the socket on the messages page, sends over WS with an optimistic pending bubble keyed by `client_id`, reconciles on the echoed `message` frame, appends incoming messages live through the renderer, auto-scrolls, throttles + shows the typing indicator, flips the read-receipt double-check, renders the presence dot / last-seen in the header, and live-updates the conversation-list preview + unread dot + ordering. If the socket is not open the send `<form method=POST>` submits normally (no-JS fallback). The template carries `data-self-uid`/`data-other-uid`/`data-other-online`/`data-other-last-seen` on `.messages-layout`. Styling (presence dot, typing dots, read-receipt, unread dot, pending opacity) is in `messages.css` using design tokens only, responsive down to 360px.
The messaging frontend is the single self-booting custom element `<dp-chat>` (`static/js/components/AppChat.js`), which replaced the old page-controller trio `MessagesLayout.js`/`MessagesSocket.js`/`MessageSearch.js` (all deleted) and `messages.css` (superseded by `static/css/chat.css`) - see `devplacepy/static/js/CLAUDE.md` for the component roster entry. `templates/messages.html` renders `<dp-chat mode="page" self-uid="..." with-uid="..." conversations-url="/messages/conversations" search-url="/messages/search" send-url="/messages/send" ws-url="/messages/ws" ai-indicator="true">` wrapping the SAME server-rendered `.messages-list`/`.messages-main`/`.messages-thread`/`.message-bubble` markup as before (no-JS/crawler fallback), which the component *adopts* on `connectedCallback` rather than discarding. It owns a `ChatSocket` (`static/js/chat/ChatSocket.js`, modeled on `PubSubClient.js`'s real exponential backoff instead of a flat-delay retry, same `4013` fast-path), sends over WS with an optimistic pending bubble keyed by `client_id` (the FIRST, unconditional branch of the incoming-frame handler matches `sender_uid === selfUid && client_id` against a pending bubble before any other branch - the fix for the old, permanently-disabled `appendOptimistic`), reconciles on the echoed `message` frame (falling to a `.failed`/tap-to-retry state after an 8s timeout with no echo), appends incoming messages live, auto-scrolls, throttles + shows the typing indicator, flips the read-receipt double-check, live-updates the conversation-list preview + unread dot + ordering, and groups consecutive same-sender messages within a 300s gap (`static/js/chat/MessageGrouping.js`, shared between the initial adopted history and the live-append path). If the socket is not open the send `<form method=POST>` submits normally (no-JS fallback). Presence in `mode="page"` needs no component code - the adopted markup keeps its `data-presence-uid` attributes, so the page-global `PresenceManager` already drives the dot/last-seen label; `<dp-chat mode="embed">` (no page-global `PresenceManager`) opens its own scoped `PubSubClient` subscription instead. An opt-in `ai-indicator="true"` attribute shows a brief "Adjusted by AI" caption when a reconciled echo's `ai_processed` flag is true and the content differs from what was locally typed - never a diff/revert UI. Styling is in `static/css/chat.css` using `variables.css` tokens only, responsive down to 360px, with a `theme`/`accent` attribute override mechanism (scoped inline custom properties on the component's own root) for future off-site embedding.
## Attachments stream live
The single `message_frame` builder (`services/messaging/persist.py`, used by BOTH `broadcast_message` and the relay) fetches `get_attachments("message", uid)` and includes a slimmed list (`uid`/`url`/`thumbnail_url`/`is_image`/`is_video`/`original_filename`/`file_size`/`mime_type`) on every frame. `MessagesLayout.renderAttachments` mirrors `_attachment_display.html` in the DOM (image -> `img.gallery-thumb` marked `data-lightbox`+`data-full`, video -> `<video>`, else a download link) - no `innerHTML`, so it stays XSS-safe. The sender's optimistic bubble shows an "Uploading attachment..." placeholder until its own echo arrives and swaps in the real gallery. **Attachment-only messages (empty caption) are allowed**: the content input dropped `required`, `sendViaSocket` sends when content OR attachments are present, and `persist_message` accepts empty content when `attachment_uids` is non-empty (`if not content and not attachment_uids: return None`). `dp-upload` (mode `attachment`) uploads each file to `/uploads/upload` and writes the uids into its hidden `attachment_uids` input; `collectAttachments()` reads that before the send, then `dp-upload.clear()` resets it. The chat uploader sets `hide-chips` so only the `(N)` count badge shows (no filename chips) - a generic `dp-upload` attribute. The send button shows a spinner and is disabled while busy: `MessagesLayout.refreshSendButton()` ORs `_uploading` (driven by the new `dp-upload:busy` event) with `_pendingSends` (a set of in-flight send `client_id`s, cleared on the echo or an 8s safety timeout), toggling `.is-sending` + `disabled` on `.messages-send-btn`.
The single `message_frame` builder (`services/messaging/persist.py`, used by BOTH `broadcast_message` and the relay) fetches `get_attachments("message", uid)` and includes a slimmed list (`uid`/`url`/`thumbnail_url`/`is_image`/`is_video`/`is_audio`/`original_filename`/`file_size`/`mime_type`) on every frame via `_slim_attachment`. `is_audio` is derived identically to every other attachment surface in the app (`mime_type.startswith("audio/")`, already computed by `attachments._row_to_attachment` and simply forwarded here) - `AppChat._renderAttachments` mirrors `_attachment_display.html`'s type branches (image -> `img.gallery-thumb` marked `data-lightbox`+`data-full`, video -> `<video>`, audio -> `<audio controls>`, else a download link) - no `innerHTML`, so it stays XSS-safe, and a live-delivered audio attachment now renders identically to a page-refreshed one instead of falling back to a generic download link until reload. The sender's optimistic bubble shows an "Uploading attachment(s)..." placeholder until its own echo arrives and swaps in the real gallery. **Attachment-only messages (empty caption) are allowed on both send paths**: the WS `send` handler always allowed it (it reads `content` as a raw string with no Pydantic model), and the HTTP `POST /messages/send` form now does too (`MessageForm.content` is `min_length=0`) - `persist_message` accepts empty content when `attachment_uids` is non-empty (`if not content and not attachment_uids: return None`) and is the single place that rule lives. `dp-upload` (mode `attachment`, default `show-chips` off so only the `(N)` count badge shows) uploads each file to `/uploads/upload` and writes the uids into its hidden `attachment_uids` input; `AppChat._collectAttachments()` reads that before the send, then `dp-upload.clear()` resets it. The send button shows a spinner and is disabled while busy: `AppChat._refreshSendButton()` ORs `_uploading` (driven by the `dp-upload:busy` event) with `_pendingSends` (a map of in-flight send `client_id`s, cleared on the echo or an 8s safety timeout that flips the bubble to `.failed`/tap-to-retry), toggling `.is-sending` + `disabled` on `.messages-send-btn`.

View File

@ -3,5 +3,13 @@
from devplacepy.services.messaging.hub import message_hub
from devplacepy.services.messaging.persist import message_frame, persist_message
from devplacepy.services.messaging.relay import message_relay
from devplacepy.services.messaging.tickets import issue_ticket, redeem_ticket
__all__ = ["message_frame", "message_hub", "message_relay", "persist_message"]
__all__ = [
"issue_ticket",
"message_frame",
"message_hub",
"message_relay",
"persist_message",
"redeem_ticket",
]

View File

@ -30,6 +30,7 @@ def _slim_attachment(attachment: dict[str, Any]) -> dict[str, Any]:
"thumbnail_url": attachment.get("thumbnail_url"),
"is_image": bool(attachment.get("is_image")),
"is_video": bool(attachment.get("is_video")),
"is_audio": bool(attachment.get("is_audio")),
"original_filename": attachment.get("original_filename", ""),
"file_size": attachment.get("file_size", 0),
"mime_type": attachment.get("mime_type", ""),
@ -38,7 +39,7 @@ def _slim_attachment(attachment: dict[str, Any]) -> dict[str, Any]:
def message_frame(
message: dict[str, Any], sender_username: str, client_id: Optional[str] = None,
sender_role: Optional[str] = None,
sender_role: Optional[str] = None, ai_processed: bool = False,
) -> dict[str, Any]:
attachments = get_attachments("message", message["uid"])
return {
@ -53,6 +54,7 @@ def message_frame(
"time_ago": time_ago(message["created_at"]),
"client_id": client_id,
"attachments": [_slim_attachment(a) for a in attachments],
"ai_processed": ai_processed,
}

View File

@ -0,0 +1,50 @@
# retoor <retoor@molodetz.nl>
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from devplacepy.database import db, get_table
from devplacepy.utils import generate_uid
TICKET_KEY_BYTES = 24
TICKET_TTL_SECONDS = 30
def issue_ticket(user_uid: str) -> str:
tickets = get_table("ws_tickets")
token = secrets.token_hex(TICKET_KEY_BYTES)
now = datetime.now(timezone.utc)
expires_at = (now + timedelta(seconds=TICKET_TTL_SECONDS)).isoformat()
tickets.insert(
{
"uid": generate_uid(),
"token": token,
"user_uid": user_uid,
"created_at": now.isoformat(),
"expires_at": expires_at,
"used_at": None,
}
)
return token
def redeem_ticket(token: str) -> Optional[str]:
if not token:
return None
from sqlalchemy import text
now = datetime.now(timezone.utc).isoformat()
with db:
result = db.executable.execute(
text(
"UPDATE ws_tickets SET used_at = :now"
" WHERE token = :token AND used_at IS NULL AND expires_at >= :now"
),
{"now": now, "token": token},
)
if result.rowcount != 1:
return None
tickets = get_table("ws_tickets")
row = tickets.find_one(token=token)
return row.get("user_uid") if row else None

View File

@ -8,16 +8,37 @@
--kb-inset: 0px;
}
dp-chat {
display: grid;
flex: 1;
min-width: 0;
min-height: 0;
width: 100%;
}
.messages-layout {
display: grid;
grid-template-columns: minmax(0, 320px) 1fr;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 0;
flex: 1;
min-width: 0;
min-height: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
--kb-inset: 0px;
}
dp-chat[mode="embed"] {
display: flex;
width: 100%;
height: 100%;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
--kb-inset: 0px;
}
.messages-list {
@ -29,7 +50,7 @@
}
.messages-list-header {
padding: 1rem;
padding: var(--space-sm) var(--space-lg);
border-bottom: 1px solid var(--border);
position: relative;
}
@ -56,8 +77,8 @@
.search-dropdown-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 1rem;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
font-size: 0.875rem;
color: var(--text-primary);
text-decoration: none;
@ -84,8 +105,8 @@
.conversation-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
gap: var(--space-md);
padding: var(--space-sm) var(--space-lg);
cursor: pointer;
}
@ -125,15 +146,16 @@
.messages-main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.messages-main-header {
padding: 1rem;
padding: var(--space-lg);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 0.75rem;
gap: var(--space-md);
flex-shrink: 0;
}
@ -154,7 +176,7 @@
}
.messages-presence.online {
color: var(--success, var(--accent));
color: var(--success);
}
.messages-presence.online::before {
@ -163,7 +185,7 @@
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success, var(--accent));
background: var(--success);
margin-right: 0.3125rem;
vertical-align: middle;
}
@ -181,78 +203,14 @@
display: none;
}
.message-receipt {
font-size: 0.6875rem;
color: rgba(255, 255, 255, 0.85);
margin-left: 0.375rem;
letter-spacing: -2px;
}
.message-receipt[hidden] {
display: none;
}
.message-bubble.pending {
opacity: 0.6;
}
.attachment-pending {
margin-top: 0.35rem;
font-size: 0.8rem;
font-style: italic;
color: var(--text-secondary);
}
.typing-indicator {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: var(--bg-card-hover);
border-radius: var(--radius-lg);
border-bottom-left-radius: 4px;
}
.typing-indicator[hidden] {
display: none;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: typing-bounce 1.2s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing-bounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-4px);
opacity: 1;
}
}
.messages-thread {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding-bottom: 0.5rem;
padding: var(--space-lg);
padding-bottom: var(--space-sm);
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: var(--space-md);
min-height: 0;
}
@ -264,6 +222,11 @@
line-height: 1.5;
position: relative;
overflow-wrap: anywhere;
outline: none;
}
.message-bubble.grouped {
margin-top: calc(var(--space-xs) - var(--space-md));
}
.message-bubble.mine {
@ -280,24 +243,134 @@
border-bottom-left-radius: 4px;
}
.message-bubble.pending {
opacity: 0.6;
}
.message-bubble.failed {
outline: 1px solid var(--danger);
cursor: pointer;
}
.retry-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.6875rem;
color: var(--danger);
}
.message-bubble.mine .content-copy-btn,
.message-bubble.theirs .content-copy-btn {
opacity: 0;
}
.message-bubble:hover .content-copy-btn,
.message-bubble:focus-within .content-copy-btn,
.message-bubble .content-copy-btn:focus {
opacity: 1;
}
.message-time {
font-size: 0.6875rem;
color: var(--text-muted);
margin-top: 0.25rem;
display: block;
transition: opacity 0.1s ease;
}
.message-bubble.grouped .message-time {
opacity: 0;
}
.message-bubble.grouped:hover .message-time,
.message-bubble.grouped:focus-within .message-time {
opacity: 1;
}
.message-bubble.mine .message-time {
color: rgba(255, 255, 255, 0.7);
}
.message-receipt {
font-size: 0.6875rem;
color: rgba(255, 255, 255, 0.85);
margin-left: 0.375rem;
letter-spacing: -2px;
}
.message-receipt[hidden] {
display: none;
}
.ai-adjusted-note {
margin-top: 0.25rem;
font-size: 0.6875rem;
color: var(--text-muted);
font-style: italic;
opacity: 1;
transition: opacity 0.4s ease;
}
.ai-adjusted-note.fading {
opacity: 0;
}
.attachment-pending {
margin-top: 0.35rem;
font-size: 0.8rem;
font-style: italic;
color: var(--text-secondary);
}
.typing-indicator {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
background: var(--bg-card-hover);
border-radius: var(--radius-lg);
border-bottom-left-radius: 4px;
}
.typing-indicator[hidden] {
display: none;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: chat-typing-bounce 1.2s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes chat-typing-bounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-4px);
opacity: 1;
}
}
.messages-input-area {
padding: 0.75rem 1rem;
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom));
padding: var(--space-md) var(--space-lg);
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
display: flex;
align-items: flex-end;
gap: 0.5rem;
gap: var(--space-sm);
flex-shrink: 0;
min-height: 56px;
background: var(--bg-card);
@ -311,7 +384,7 @@
overflow-y: auto;
max-height: 160px;
line-height: 1.5;
padding: 0.5rem 0.75rem;
padding: var(--space-sm) var(--space-md);
font-family: inherit;
font-size: 0.875rem;
border-radius: var(--radius);
@ -321,6 +394,13 @@
outline: none;
}
@supports (field-sizing: content) {
.messages-input-area textarea {
field-sizing: content;
min-height: 1lh;
}
}
.messages-input-area textarea:focus {
border-color: var(--accent);
}
@ -340,7 +420,7 @@
flex: 0 0 100%;
order: -1;
margin-top: 0;
margin-bottom: 0.5rem;
margin-bottom: var(--space-sm);
}
.messages-send-btn {
@ -368,7 +448,7 @@
border: 2px solid var(--white);
border-right-color: transparent;
border-radius: 50%;
animation: messages-send-spin 0.6s linear infinite;
animation: chat-send-spin 0.6s linear infinite;
}
.messages-send-btn.is-sending {
@ -388,7 +468,7 @@
cursor: default;
}
@keyframes messages-send-spin {
@keyframes chat-send-spin {
to {
transform: rotate(360deg);
}
@ -401,7 +481,7 @@
justify-content: center;
height: 100%;
color: var(--text-muted);
gap: 0.5rem;
gap: var(--space-sm);
}
.messages-empty p {
@ -414,27 +494,28 @@
border: none;
color: var(--text-primary);
font-size: 1.25rem;
padding: 0.25rem;
padding: var(--space-xs);
cursor: pointer;
line-height: 1;
margin-right: 0.25rem;
margin-right: var(--space-xs);
}
.messages-back-btn:hover {
color: var(--accent);
}
/* ── Mobile (≤ 768px) ───────────────────────────────────── */
/* Switches layout from grid to flex column so a single visible
child (conversation list OR chat pane) always fills the viewport.
The --kb-inset is applied here so the keyboard never obscures
the input bar. */
/* ── Mobile (<= 768px) ─────────────────────────────────── */
@media (max-width: 768px) {
.page-messages {
padding-bottom: max(env(safe-area-inset-bottom), var(--kb-inset, 0px));
}
.messages-layout,
dp-chat[mode="embed"] {
padding-bottom: max(env(safe-area-inset-bottom), var(--kb-inset, 0px));
}
.messages-layout {
display: flex;
flex-direction: column;
@ -472,17 +553,33 @@
.messages-input-area textarea {
font-size: 16px;
}
.message-bubble.grouped .message-time {
opacity: 1;
}
}
/* ── Small mobile (≤ 360px) ─────────────────────────────── */
/* ── Small mobile (<= 480px) ────────────────────────────── */
@media (max-width: 480px) {
.message-bubble {
max-width: 82%;
}
.messages-main-header {
padding: var(--space-md);
}
}
/* ── Smallest mobile (<= 360px) ─────────────────────────── */
@media (max-width: 360px) {
.messages-thread {
padding: 0.5rem;
padding: var(--space-sm);
}
.messages-input-area {
padding: 0.375rem 0.5rem;
padding: 0.375rem var(--space-sm);
padding-bottom: calc(0.375rem + env(safe-area-inset-bottom));
min-height: 48px;
}
@ -493,7 +590,7 @@
.message-bubble {
max-width: 90%;
padding: 0.5rem 0.625rem;
padding: var(--space-sm) 0.625rem;
font-size: 0.8125rem;
}
@ -502,11 +599,30 @@
}
}
/* ── Touch devices: larger send target ──────────────────── */
/* ── Touch devices: larger tap targets (min 44x44px) ────── */
@media (hover: none) and (pointer: coarse) {
.messages-send-btn {
width: 44px;
height: 44px;
}
.messages-back-btn {
min-width: 44px;
min-height: 44px;
}
.conversation-item {
min-height: 44px;
}
.message-bubble .content-copy-btn {
opacity: 1;
min-width: 44px;
min-height: 32px;
}
.message-bubble.grouped .message-time {
opacity: 1;
}
}

View File

@ -5,8 +5,6 @@ import { ModalManager } from "./ModalManager.js";
import { FormManager } from "./FormManager.js";
import { VoteManager } from "./VoteManager.js";
import { NotificationManager } from "./NotificationManager.js";
import { MessageSearch } from "./MessageSearch.js";
import { MessagesLayout } from "./MessagesLayout.js";
import { ProfileEditor } from "./ProfileEditor.js";
import { MobileNav } from "./MobileNav.js";
import { CommentManager } from "./CommentManager.js";
@ -74,8 +72,6 @@ class Application {
this.forms = new FormManager();
this.votes = new VoteManager();
this.notifications = new NotificationManager();
this.messageSearch = new MessageSearch();
this.messagesLayout = new MessagesLayout();
this.profile = new ProfileEditor();
this.mobileNav = new MobileNav();
this.comments = new CommentManager();

View File

@ -14,7 +14,11 @@ This file documents JS module organization, custom web components, and shared fr
## Custom web components (`static/js/components/`)
Self-contained, presentational UI is built as custom elements with the `dp-` prefix:
`dp-avatar`, `dp-code`, `dp-content`, `dp-title`, `dp-toast`, `dp-dialog`, `dp-context-menu`, `dp-upload`, `dp-lightbox`.
`dp-avatar`, `dp-code`, `dp-content`, `dp-title`, `dp-toast`, `dp-dialog`, `dp-context-menu`, `dp-upload`, `dp-lightbox`, `dp-chat`.
`dp-chat` (`AppChat.js`) is the Slack-like DM chat widget that fully replaced the old page-controller trio `MessagesLayout.js`/`MessagesSocket.js`/`MessageSearch.js` (deleted) and `static/css/messages.css` (superseded by `static/css/chat.css`) on `/messages`. Unlike every other component here, it does NOT build its DOM from scratch on `connectedCallback` when server-rendered light-DOM children already exist (`mode="page"`) - it **adopts** the existing `.messages-list`/`.messages-main`/`.messages-thread`/`.message-bubble` markup `templates/messages.html` still renders (so no-JS and crawler fallback both keep working) and only builds a from-scratch skeleton when none is present (`mode="embed"`, a standalone `<dp-chat mode="embed" self-uid="..." with-uid="..." send-url="..." ws-url="...">` usable outside `/messages`). It owns its own `ChatSocket` instance, the conversation search dropdown (absorbed from the old `MessageSearch.js`, still built on the shared `ListNav` utility), consecutive-message grouping (`chat/MessageGrouping.js`), a working optimistic send with `.pending`/`.failed` (tap-to-retry) bubble states, and an opt-in (`ai-indicator` attribute) "Adjusted by AI" caption shown when a reconciled echo's `ai_processed` frame flag is true and the content changed from what was locally typed - never a diff/revert UI, the backend keeps no pre-correction copy to diff against. Presence for `mode="page"` needs no component code at all: the adopted markup keeps its `data-presence-uid` attributes, so the page-global `PresenceManager` (below) already drives it; `dp-chat` opens its own scoped `PubSubClient` subscription only in `mode="embed"`, where no page-global instance exists. The message body itself keeps rendering through `<dp-content>` (see "CLIENT-only now" below) - the redesign only removed the historical `no-copy` attribute on message bubbles so `dp-content`'s own existing `.content-copy-btn` doubles as the hover/focus-reveal action toolbar's Copy button (§6.3 of the design doc it was built from), instead of a second copy mechanism being hand-rolled.
`static/js/chat/ChatSocket.js` and `static/js/chat/MessageGrouping.js` are chat-scoped helpers used only by `AppChat.js` - `ChatSocket` is modeled on `PubSubClient.js`'s real exponential backoff (200ms doubling to a 5000ms cap, reset on a successful connect) rather than the flat-delay retry the deleted `MessagesSocket.js` used, and preserves the same `4013` "wrong worker" fast-retry special case. `MessageGrouping.shouldGroup(previous, current)` is the one pure predicate (<=300s gap, same sender) shared by both the initial-history grouping pass and the live-append path, so the two can never disagree. Neither file is a general-purpose "do not reimplement" utility for the rest of the app (see the out-of-scope note in the design document this shipped from) - they are not added to the do-not-reimplement list below; a future non-chat WebSocket feature should keep hand-rolling its own client (as `DeviiSocket.js`/`DeepsearchProgressSocket.js`/`SeoProgressSocket.js`/`AppDeepsearchChat.js`'s inline socket already do) rather than reaching into `chat/`.
Conventions:

View File

@ -1,81 +0,0 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { DomUtils } from "./DomUtils.js";
import { ListNav } from "./ListNav.js";
export class MessageSearch {
constructor() {
this.initMessageSearch();
}
initMessageSearch() {
const searchInput = document.getElementById("message-search");
if (!searchInput) {
return;
}
const wrap = searchInput.parentElement;
const dropdown = document.createElement("div");
dropdown.className = "search-dropdown";
dropdown.setAttribute("role", "listbox");
wrap.appendChild(dropdown);
const hide = () => {
DomUtils.hide(dropdown);
nav.closed();
};
const nav = new ListNav(searchInput, dropdown, {
itemSelector: ".search-dropdown-item",
activeClass: "active",
chooseOnTab: false,
isOpen: () => DomUtils.isShown(dropdown),
onChoose: (item) => { window.location.href = item.href; },
onEscape: hide,
});
let debounceTimer = null;
searchInput.addEventListener("input", () => {
clearTimeout(debounceTimer);
const q = searchInput.value.trim();
if (q.length < 1) {
dropdown.innerHTML = "";
hide();
return;
}
debounceTimer = setTimeout(async () => {
try {
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
const results = data.results || [];
if (results.length === 0) {
hide();
return;
}
dropdown.innerHTML = "";
for (const r of results) {
const item = document.createElement("a");
item.className = "search-dropdown-item";
item.href = `/messages?with_uid=${r.uid}`;
const label = document.createElement("span");
label.textContent = r.username;
item.append(Avatar.imgElement(r.username), label);
dropdown.appendChild(item);
}
DomUtils.show(dropdown);
nav.opened();
} catch (e) {
hide();
}
}, 200);
});
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) {
hide();
}
});
}
}

View File

@ -1,527 +0,0 @@
// retoor <retoor@molodetz.nl>
import { MessagesSocket } from "./MessagesSocket.js";
import { contentRenderer } from "./ContentRenderer.js";
const TYPING_THROTTLE_MS = 1500;
const TYPING_HIDE_MS = 4000;
const AUTO_SCROLL_MARGIN_PX = 100;
const STABILIZE_MAX_FRAMES = 300;
export class MessagesLayout {
constructor() {
this.layout = document.querySelector(".messages-layout");
if (!this.layout) {
return;
}
this.thread = document.querySelector(".messages-thread");
this.form = document.querySelector(".messages-input-area");
this.input = this.form ? this.form.querySelector('input[name="content"]') : null;
this.upload = this.form ? this.form.querySelector("dp-upload") : null;
this.sendBtn = this.form ? this.form.querySelector(".messages-send-btn") : null;
this._uploading = false;
this._pendingSends = new Set();
this.typingEl = document.getElementById("typing-indicator");
this.selfUid = this.layout.dataset.selfUid || "";
this.otherUid = this.layout.dataset.otherUid || "";
this._lastTypingSent = 0;
this._typingHideTimer = null;
this._socketReady = false;
this._userAtBottom = true;
this._stabilizeFrames = 0;
this._stabilizePending = false;
// Expose for diagnostics
window.__messagesLayout = this;
this.scrollThreadToEnd();
if (this.input) {
this.input.focus({ preventScroll: true });
}
this.connect();
this.bindForm();
this.bindTyping();
this.bindViewport();
this.bindAutoScroll();
this._startScrollWatcher();
this.markRead();
}
_startScrollWatcher() {
if (!this.thread) return;
const onAnyChange = () => {
if (!this._userAtBottom) return;
if (this._stabilizePending) return;
this._stabilizePending = true;
requestAnimationFrame(() => {
this._stabilizePending = false;
this._stabilizeScroll();
});
};
this._scrollWatcher = new MutationObserver(onAnyChange);
this._scrollWatcher.observe(this.thread, {
childList: true,
subtree: true,
});
this.thread.addEventListener("load", (e) => {
if (e.target.tagName === "IMG" && this._userAtBottom) {
onAnyChange();
}
}, true);
// Kick initial stabilization
onAnyChange();
}
_stabilizeScroll() {
if (!this.thread) {
this._stabilizeFrames = 0;
return;
}
if (!this._userAtBottom) {
this._stabilizeFrames = 0;
return;
}
if (this._stabilizeFrames >= STABILIZE_MAX_FRAMES) {
this._stabilizeFrames = 0;
return;
}
this._stabilizeFrames++;
this.thread.scrollTop = this.thread.scrollHeight;
const atBottom = this.thread.scrollHeight
- this.thread.scrollTop
- this.thread.clientHeight < 10;
if (!atBottom) {
this._stabilizePending = true;
requestAnimationFrame(() => {
this._stabilizePending = false;
this._stabilizeScroll();
});
} else {
this._stabilizeFrames = 0;
}
}
bindAutoScroll() {
if (!this.thread) return;
this.thread.addEventListener("scroll", () => {
if (this._stabilizePending) return;
const atBottom = this.thread.scrollHeight
- this.thread.scrollTop
- this.thread.clientHeight < AUTO_SCROLL_MARGIN_PX;
this._userAtBottom = atBottom;
if (atBottom) {
this._stabilizeScroll();
}
}, { passive: true });
}
bindViewport() {
const page = document.querySelector(".page-messages");
if (!page || !this.input || !this.form) return;
const viewport = window.visualViewport;
const ensureInputVisible = () => {
const vh = viewport ? viewport.height : window.innerHeight;
const formRect = this.form.getBoundingClientRect();
const currentInset = parseInt(page.style.getPropertyValue("--kb-inset")) || 0;
const delta = formRect.bottom - vh;
const newInset = Math.max(0, currentInset + delta);
if (newInset !== currentInset) {
page.style.setProperty("--kb-inset", `${Math.round(newInset)}px`);
this._userAtBottom = true;
this.scrollThreadToEnd();
}
};
if (viewport) {
let insetTimer = null;
const onViewportChange = () => {
cancelAnimationFrame(insetTimer);
insetTimer = requestAnimationFrame(ensureInputVisible);
};
viewport.addEventListener("resize", onViewportChange);
viewport.addEventListener("scroll", onViewportChange);
}
const ro = new ResizeObserver(() => ensureInputVisible());
ro.observe(page);
if (this.layout) ro.observe(this.layout);
if (this.form) {
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) ensureInputVisible();
}
}, { root: null, threshold: [0, 0.5, 1] });
io.observe(this.form);
this._viewportIO = io;
}
this.input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
this.form.requestSubmit();
}
});
this.input.addEventListener("input", () => {
this.input.style.height = "auto";
this.input.style.height = Math.min(this.input.scrollHeight, 160) + "px";
});
this.input.addEventListener("focus", () => {
this._userAtBottom = true;
const doScroll = () => {
this.scrollThreadToEnd();
if (window.innerWidth < 768) {
const retry = (delay) => setTimeout(() => {
ensureInputVisible();
this.scrollThreadToEnd();
}, delay);
retry(100);
retry(350);
retry(600);
}
};
requestAnimationFrame(doScroll);
});
this.input.addEventListener("blur", () => {
page.style.setProperty("--kb-inset", "0px");
});
ensureInputVisible();
}
connect() {
this.socket = new MessagesSocket({
onReady: () => this.onReady(),
onMessage: (frame) => this.onFrame(frame),
onClose: () => { this._socketReady = false; },
});
this.socket.connect();
}
onReady() {
this._socketReady = true;
if (this.otherUid) {
this.markRead();
}
}
onFrame(frame) {
switch (frame.type) {
case "message":
this.handleIncoming(frame);
break;
case "typing":
if (frame.from_uid === this.otherUid) this.showTyping();
break;
case "read":
if (frame.by_uid === this.otherUid) this.markReceipts();
break;
default:
break;
}
}
bindForm() {
if (!this.form || !this.input) return;
this.form.addEventListener("submit", (event) => {
if (!this._socketReady || !this.socket.isOpen()) {
return;
}
event.preventDefault();
this.sendViaSocket();
});
if (this.upload) {
this.upload.addEventListener("dp-upload:busy", (event) => {
this._uploading = !!(event.detail && event.detail.busy);
this.refreshSendButton();
});
}
}
refreshSendButton() {
if (!this.sendBtn) return;
const busy = this._uploading || this._pendingSends.size > 0;
this.sendBtn.disabled = busy;
this.sendBtn.classList.toggle("is-sending", busy);
}
sendViaSocket() {
const content = (this.input.value || "").trim();
const attachmentUids = this.collectAttachments();
if (!content && attachmentUids.length === 0) return;
const clientId = "c" + Date.now() + Math.random().toString(36).slice(2, 8);
this.appendOptimistic(content, clientId, attachmentUids.length);
const ok = this.socket.send({
type: "send",
receiver_uid: this.otherUid,
content,
attachment_uids: attachmentUids,
client_id: clientId,
});
if (!ok) {
this.form.submit();
return;
}
this._pendingSends.add(clientId);
this.refreshSendButton();
window.setTimeout(() => this.clearPendingSend(clientId), 8000);
this.input.value = "";
if (this.upload && typeof this.upload.clear === "function") {
this.upload.clear();
}
this.input.focus({ preventScroll: true });
}
clearPendingSend(clientId) {
if (this._pendingSends.delete(clientId)) {
this.refreshSendButton();
}
}
collectAttachments() {
const hidden = this.form.querySelector('input[name="attachment_uids"]');
if (!hidden || !hidden.value) return [];
return hidden.value.split(",").map((v) => v.trim()).filter(Boolean);
}
bindTyping() {
if (!this.input) return;
this.input.addEventListener("input", () => {
if (!this._socketReady || !this.otherUid) return;
const now = Date.now();
if (now - this._lastTypingSent < TYPING_THROTTLE_MS) return;
this._lastTypingSent = now;
this.socket.send({ type: "typing", receiver_uid: this.otherUid });
});
}
appendOptimistic(content, clientId, attachmentCount) {
// Results in double message. Should be fixed in future..
/*
const bubble = this.buildBubble({
content,
mine: true,
clientId,
time: "now",
iso: new Date().toISOString(),
attachmentCount,
});
bubble.classList.add("pending");*/
//this.insertBubble(bubble);
}
handleIncoming(frame) {
if (frame.uid && this.thread &&
this.thread.querySelector(`.message-bubble[data-msg-uid="${frame.uid}"]`)) {
if (frame.client_id) this.clearPendingSend(frame.client_id);
return;
}
if (frame.sender_uid === this.selfUid && frame.client_id) {
this.clearPendingSend(frame.client_id);
const pending = this.thread.querySelector(`.message-bubble[data-client-id="${frame.client_id}"]`);
if (pending) {
pending.classList.remove("pending");
pending.dataset.msgUid = frame.uid;
const oldBody = pending.querySelector(".rendered-content");
if (oldBody && frame.content !== undefined) {
const content = document.createElement("dp-content");
content.setAttribute("no-copy", "");
if (frame.sender_role === "Admin") content.setAttribute("data-author-admin", "");
content.textContent = frame.content || "";
oldBody.replaceWith(content);
}
const time = pending.querySelector(".message-time");
if (time && frame.created_at) {
time.setAttribute("datetime", frame.created_at);
time.dataset.dt = "";
time.dataset.dtMode = "ago";
if (window.app && window.app.localTime) window.app.localTime.apply(time);
else time.textContent = frame.time_ago;
} else if (time) {
time.textContent = frame.time_ago;
}
const placeholder = pending.querySelector(".attachment-pending");
if (placeholder) placeholder.remove();
const gallery = this.renderAttachments(frame.attachments);
if (gallery) pending.insertBefore(gallery, time || null);
this.bumpConversation(frame, true);
return;
}
}
const partnerUid = frame.sender_uid === this.selfUid ? frame.receiver_uid : frame.sender_uid;
const inThisThread = this.otherUid && partnerUid === this.otherUid;
if (inThisThread) {
const mine = frame.sender_uid === this.selfUid;
const bubble = this.buildBubble({
content: frame.content,
mine,
time: frame.time_ago,
iso: frame.created_at,
uid: frame.uid,
senderRole: frame.sender_role,
attachments: frame.attachments,
});
this.insertBubble(bubble);
if (!mine && this._socketReady) {
this.socket.send({ type: "read", with_uid: this.otherUid });
}
}
this.bumpConversation(frame, frame.sender_uid === this.selfUid);
}
buildBubble({ content, mine, time, iso, uid, clientId, senderRole, attachments, attachmentCount }) {
const bubble = document.createElement("div");
bubble.className = "message-bubble " + (mine ? "mine" : "theirs");
if (uid) bubble.dataset.msgUid = uid;
if (clientId) bubble.dataset.clientId = clientId;
const el = document.createElement("dp-content");
el.setAttribute("no-copy", "");
if (senderRole === "Admin") el.setAttribute("data-author-admin", "");
el.textContent = content || "";
bubble.appendChild(el);
const gallery = this.renderAttachments(attachments);
if (gallery) {
bubble.appendChild(gallery);
} else if (attachmentCount > 0) {
const placeholder = document.createElement("div");
placeholder.className = "attachment-pending";
placeholder.textContent = attachmentCount === 1
? "Uploading attachment..."
: `Uploading ${attachmentCount} attachments...`;
bubble.appendChild(placeholder);
}
const timeEl = document.createElement(iso ? "time" : "span");
timeEl.className = "message-time";
if (iso) {
timeEl.setAttribute("datetime", iso);
timeEl.dataset.dt = "";
timeEl.dataset.dtMode = "ago";
if (window.app && window.app.localTime) window.app.localTime.apply(timeEl);
else timeEl.textContent = time || "";
} else {
timeEl.textContent = time || "";
}
bubble.appendChild(timeEl);
if (mine) {
const receipt = document.createElement("span");
receipt.className = "message-receipt";
receipt.hidden = true;
receipt.innerHTML = "&#x2713;&#x2713;";
bubble.appendChild(receipt);
}
return bubble;
}
renderAttachments(attachments) {
if (!attachments || !attachments.length) return null;
const gallery = document.createElement("div");
gallery.className = "attachment-gallery";
for (const att of attachments) {
const item = document.createElement("div");
item.className = "attachment-gallery-item";
if (att.is_image) {
const img = document.createElement("img");
img.src = att.thumbnail_url || att.url;
img.alt = att.original_filename || "";
img.loading = "lazy";
img.className = "gallery-thumb";
img.dataset.lightbox = "";
img.dataset.full = att.url;
if (att.mime_type) img.dataset.mime = att.mime_type;
item.appendChild(img);
} else if (att.is_video) {
const video = document.createElement("video");
video.src = att.url;
video.controls = true;
video.preload = "metadata";
video.className = "gallery-video";
item.appendChild(video);
} else {
const link = document.createElement("a");
link.href = att.url;
link.target = "_blank";
link.rel = "noopener";
link.className = "non-image";
link.download = att.original_filename || "file";
link.textContent = att.original_filename || "file";
item.appendChild(link);
}
gallery.appendChild(item);
}
return gallery;
}
insertBubble(bubble) {
if (!this.thread) return;
if (this.typingEl && this.typingEl.parentElement === this.thread) {
this.thread.insertBefore(bubble, this.typingEl);
} else {
this.thread.appendChild(bubble);
}
this.scrollThreadToEnd();
}
markReceipts() {
this.thread.querySelectorAll(".message-bubble.mine .message-receipt").forEach((el) => {
el.hidden = false;
});
}
markRead() {
if (this._socketReady && this.otherUid) {
this.socket.send({ type: "read", with_uid: this.otherUid });
}
}
showTyping() {
if (!this.typingEl) return;
this.typingEl.hidden = false;
this.scrollThreadToEnd();
clearTimeout(this._typingHideTimer);
this._typingHideTimer = setTimeout(() => {
this.typingEl.hidden = true;
}, TYPING_HIDE_MS);
}
bumpConversation(frame, mine) {
const partnerUid = mine ? frame.receiver_uid : frame.sender_uid;
const item = document.querySelector(`.conversation-item[data-conv-uid="${partnerUid}"]`);
if (!item) return;
const preview = item.querySelector(".conversation-preview");
if (preview) preview.textContent = contentRenderer.preview(frame.content, 60);
const dot = item.querySelector(".conversation-unread-dot");
if (dot) {
dot.hidden = mine || partnerUid === this.otherUid;
}
const list = item.parentElement;
if (list && list.firstElementChild !== item) {
list.insertBefore(item, list.firstElementChild);
}
}
scrollThreadToEnd() {
if (!this.thread) return;
if (!this._userAtBottom) return;
this.thread.scrollTop = this.thread.scrollHeight;
}
}

View File

@ -1,70 +0,0 @@
// retoor <retoor@molodetz.nl>
const RECONNECT_DELAY_MS = 1500;
const WRONG_WORKER_RETRY_MS = 200;
const WRONG_WORKER_CODE = 4013;
export class MessagesSocket {
constructor(handlers) {
this.handlers = handlers || {};
this.ws = null;
this.url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/messages/ws";
this._shouldRun = false;
this._settled = false;
}
connect() {
this._shouldRun = true;
this._open();
}
isOpen() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
}
_open() {
this._settled = false;
this.ws = new WebSocket(this.url);
this.ws.addEventListener("open", () => this._emit("onOpen"));
this.ws.addEventListener("close", (event) => {
const transient = !this._settled && event.code === WRONG_WORKER_CODE;
if (!transient) this._emit("onClose");
if (this._shouldRun) {
const delay = transient ? WRONG_WORKER_RETRY_MS : RECONNECT_DELAY_MS;
window.setTimeout(() => this._open(), delay);
}
});
this.ws.addEventListener("error", () => this._emit("onError"));
this.ws.addEventListener("message", (event) => {
let payload;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
if (!this._settled) {
this._settled = true;
this._emit("onReady", payload);
}
this._emit("onMessage", payload);
});
}
send(message) {
if (this.isOpen()) {
this.ws.send(JSON.stringify(message));
return true;
}
return false;
}
close() {
this._shouldRun = false;
if (this.ws) this.ws.close();
}
_emit(name, payload) {
const handler = this.handlers[name];
if (typeof handler === "function") handler(payload);
}
}

View File

@ -0,0 +1,84 @@
// retoor <retoor@molodetz.nl>
const WRONG_WORKER_CODE = 4013;
const WRONG_WORKER_RETRY_MS = 200;
const INITIAL_BACKOFF_MS = 200;
const MAX_BACKOFF_MS = 5000;
export class ChatSocket {
constructor(url, handlers) {
this.url = url;
this.handlers = handlers || {};
this.socket = null;
this._shouldRun = false;
this._settled = false;
this._backoff = INITIAL_BACKOFF_MS;
}
connect() {
this._shouldRun = true;
this._open();
}
isOpen() {
return !!this.socket && this.socket.readyState === WebSocket.OPEN;
}
_open() {
this._settled = false;
const socket = new WebSocket(this.url);
this.socket = socket;
socket.addEventListener("message", (event) => {
let payload;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
if (!this._settled) {
this._settled = true;
this._backoff = INITIAL_BACKOFF_MS;
this._emit("onReady", payload);
}
this._emit("onMessage", payload);
});
socket.addEventListener("close", (event) => {
const wrongWorker = !this._settled && event.code === WRONG_WORKER_CODE;
this._settled = false;
if (wrongWorker) {
if (this._shouldRun) {
window.setTimeout(() => this._open(), WRONG_WORKER_RETRY_MS);
}
return;
}
this._emit("onClose", event);
if (this._shouldRun) {
const delay = this._backoff;
this._backoff = Math.min(this._backoff * 2, MAX_BACKOFF_MS);
window.setTimeout(() => this._open(), delay);
}
});
socket.addEventListener("error", (event) => this._emit("onError", event));
}
send(frame) {
if (this.isOpen()) {
this.socket.send(JSON.stringify(frame));
return true;
}
return false;
}
close() {
this._shouldRun = false;
if (this.socket) this.socket.close();
}
_emit(name, payload) {
const handler = this.handlers[name];
if (typeof handler === "function") handler(payload);
}
}

View File

@ -0,0 +1,21 @@
// retoor <retoor@molodetz.nl>
export const GROUP_GAP_SECONDS = 300;
export function toEpochMs(value) {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (!value) return null;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? null : parsed;
}
export function shouldGroup(previousMessage, currentMessage) {
if (!previousMessage || !currentMessage) return false;
if (previousMessage.senderUid !== currentMessage.senderUid) return false;
const previousMs = toEpochMs(previousMessage.createdAt);
const currentMs = toEpochMs(currentMessage.createdAt);
if (previousMs === null || currentMs === null) return false;
const gapSeconds = (currentMs - previousMs) / 1000;
if (gapSeconds > GROUP_GAP_SECONDS) return false;
return true;
}

File diff suppressed because it is too large Load Diff

View File

@ -12,4 +12,5 @@ import "./AppLightbox.js";
import "./AppDocsChat.js";
import "./AppIsslop.js";
import "./AppIsslopRun.js";
import "./AppChat.js";
import "./ContainerTerminal.js";

View File

@ -28,6 +28,8 @@
<button type="button" class="comment-action-btn" data-action="reply"{{ guest_disabled(user) }}><span class="icon">💬</span><span class="label"> Reply</span></button>
{% if owns(item.comment, user) %}
<button type="button" class="comment-action-btn" data-action="edit" data-edit-url="/comments/edit/{{ item.comment['uid'] }}"><span class="icon">✏️</span><span class="label"> Edit</span></button>
{% endif %}
{% if owns(item.comment, user) or is_admin(user) %}
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form comment-delete-form" data-comment-uid="{{ item.comment['uid'] }}">
<button type="submit" class="comment-action-btn" data-confirm="Delete this comment?"><span class="icon">🗑️</span><span class="label"> Delete</span></button>
</form>

View File

@ -38,6 +38,11 @@
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}"><span aria-hidden="true">&#x1F517;</span> Share</button>
{% endif %}
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _bookmarked = item.bookmarked %}{% include "_bookmark_button.html" %}
{% if owns(item.post, user) or is_admin(user) %}
<form method="POST" action="/posts/delete/{{ item.post['slug'] or item.post['uid'] }}" class="inline-form">
<button type="submit" class="post-action-btn" data-confirm="Delete this post?"><span aria-hidden="true">🗑️</span> Delete</button>
</form>
{% endif %}
</div>
{% if item.recent_comments %}

View File

@ -1,12 +1,22 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/messages.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/chat.css') }}">
{% endblock %}
{% block footer %}{% endblock %}
{% block content %}
<h1 class="sr-only">Messages</h1>
<div class="page-messages">
<div class="messages-layout" data-self-uid="{{ user['uid'] }}"{% if current_conversation %} data-other-uid="{{ current_conversation }}"{% endif %}>
<dp-chat
class="messages-layout"
mode="page"
self-uid="{{ user['uid'] }}"
{% if current_conversation %}with-uid="{{ current_conversation }}"{% endif %}
conversations-url="/messages/conversations"
search-url="/messages/search"
send-url="/messages/send"
ws-url="/messages/ws"
ai-indicator="true"
>
<div class="messages-list" role="navigation" aria-label="Conversations">
<div class="messages-list-header" role="search">
<input type="text" id="message-search" placeholder="Search conversations..." value="{{ search }}" aria-label="Search conversations">
@ -42,14 +52,14 @@
<div class="messages-thread" role="log" aria-label="Message transcript" aria-live="polite" aria-relevant="additions">
{% for msg in messages %}
<div class="message-bubble {% if msg.is_mine %}mine{% else %}theirs{% endif %}" data-msg-uid="{{ msg.message['uid'] }}">
<dp-content no-copy>{{ msg.message['content'] }}</dp-content>
<div class="message-bubble {% if msg.is_mine %}mine{% else %}theirs{% endif %}{% if msg.grouped %} grouped{% endif %}" data-msg-uid="{{ msg.message['uid'] }}" tabindex="0">
<dp-content>{{ msg.message['content'] }}</dp-content>
{% if msg.attachments %}
{% with attachments=msg.attachments %}
{% include "_attachment_display.html" %}
{% endwith %}
{% endif %}
<span class="message-time">{{ dt_ago(msg.created_at) if msg.created_at else msg.time_ago }}</span>
<span class="message-time">{{ dt_ago(msg.message['created_at']) if msg.message.get('created_at') else msg.time_ago }}</span>
{% if msg.is_mine %}<span class="message-receipt"{% if not msg.message['read'] %} hidden{% endif %}>&#x2713;&#x2713;<span class="sr-only">Read</span></span>{% endif %}
</div>
{% endfor %}
@ -71,7 +81,7 @@
</div>
{% endif %}
</div>
</div>
</dp-chat>
</div>
{% endblock %}

View File

@ -0,0 +1,101 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
JSON = {"Accept": "application/json"}
_counter = [0]
def _unique(prefix="mconv"):
_counter[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
def _signup():
name = _unique()
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _uid(name):
return get_table("users").find_one(username=name)["uid"]
def test_conversations_requires_auth_401_for_json(app_server):
r = requests.get(
f"{BASE_URL}/messages/conversations", headers=JSON, allow_redirects=False
)
assert r.status_code == 401
def test_conversations_requires_auth_redirects_browser(app_server):
r = requests.get(f"{BASE_URL}/messages/conversations", allow_redirects=False)
assert r.status_code == 303
def test_conversations_matches_messages_page_shape(app_server):
sender, _ = _signup()
_, other_name = _signup()
other_uid = _uid(other_name)
sent = sender.post(
f"{BASE_URL}/messages/send",
headers=JSON,
data={"receiver_uid": other_uid, "content": "hello from parity test"},
)
assert sent.status_code == 200, sent.text[:300]
page = sender.get(f"{BASE_URL}/messages", headers=JSON)
assert page.status_code == 200, page.text[:300]
page_conversations = page.json()["conversations"]
flat = sender.get(f"{BASE_URL}/messages/conversations")
assert flat.status_code == 200, flat.text[:300]
flat_conversations = flat.json()["conversations"]
assert page_conversations == flat_conversations
match = next(
c for c in flat_conversations if c["other_user"]["uid"] == other_uid
)
assert match["last_message"] == "hello from parity test"
assert match["unread"] is False
def test_conversations_excludes_blocked_user(app_server):
viewer, _ = _signup()
blocked, blocked_name = _signup()
blocked_uid = _uid(blocked_name)
reply = viewer.post(
f"{BASE_URL}/messages/send",
headers=JSON,
data={"receiver_uid": blocked_uid, "content": "before block"},
)
assert reply.status_code == 200, reply.text[:300]
before = viewer.get(f"{BASE_URL}/messages/conversations")
assert any(
c["other_user"]["uid"] == blocked_uid for c in before.json()["conversations"]
)
viewer.post(f"{BASE_URL}/block/{blocked_name}", allow_redirects=False)
after = viewer.get(f"{BASE_URL}/messages/conversations")
assert after.status_code == 200
assert not any(
c["other_user"]["uid"] == blocked_uid for c in after.json()["conversations"]
)

View File

@ -1,9 +1,11 @@
# retoor <retoor@molodetz.nl>
import io
import time
from datetime import datetime, timezone
import pytest
import requests
from PIL import Image
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
@ -127,3 +129,40 @@ def test_message_send_recorded(seeded_db):
).json()["data"]
admin = _admin(seeded_db)
assert _find(admin, "message.send", lambda e: e.get("target_uid") == msg["uid"]) is not None
def _png_bytes():
buf = io.BytesIO()
Image.new("RGB", (4, 4), (0, 128, 255)).save(buf, "PNG")
return buf.getvalue()
def _upload(session, name="attach.png"):
r = session.post(
f"{BASE_URL}/uploads/upload", files={"file": (name, _png_bytes(), "image/png")}
)
assert r.status_code == 201, r.text
return r.json()["uid"]
def test_send_attachment_only_empty_content_succeeds(seeded_db):
s, _ = _member()
receiver = _db_user("bob_test")["uid"]
attachment_uid = _upload(s)
r = s.post(
f"{BASE_URL}/messages/send",
headers=JSON_audit_log,
data={
"content": "",
"receiver_uid": receiver,
"attachment_uids": attachment_uid,
},
)
assert r.status_code == 200, r.text[:300]
msg = r.json()["data"]
assert msg["uid"]
refresh_snapshot()
row = get_table("messages").find_one(uid=msg["uid"])
assert row["content"] == ""

View File

@ -0,0 +1,110 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import time
import pytest
import requests
import websockets
from tests.conftest import BASE_URL, PORT
from devplacepy.database import get_table, refresh_snapshot, set_setting
WS_URL = f"ws://127.0.0.1:{PORT}/messages/ws"
JSON = {"Accept": "application/json"}
_counter = [0]
@pytest.fixture(scope="module", autouse=True)
def _wsticket_settings(app_server):
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
}.items():
set_setting(key, value)
yield
def _unique(prefix="wst"):
_counter[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
def _signup():
name = _unique()
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _uid(name):
refresh_snapshot()
return get_table("users").find_one(username=name)["uid"]
async def _recv_until(ws, frame_type, timeout=5.0):
deadline = asyncio.get_event_loop().time() + timeout
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
raise AssertionError(f"timed out waiting for {frame_type}")
raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
frame = json.loads(raw)
if frame.get("type") == frame_type:
return frame
def test_ws_ticket_requires_auth_401_for_json(app_server):
r = requests.post(
f"{BASE_URL}/messages/ws-ticket", headers=JSON, allow_redirects=False
)
assert r.status_code == 401
def test_ws_ticket_requires_auth_redirects_browser(app_server):
r = requests.post(f"{BASE_URL}/messages/ws-ticket", allow_redirects=False)
assert r.status_code == 303
def test_ws_ticket_issued_for_logged_in_session(app_server):
session, _ = _signup()
r = session.post(f"{BASE_URL}/messages/ws-ticket", headers=JSON)
assert r.status_code == 200, r.text[:300]
data = r.json()
assert data["ticket"]
assert data["expires_in"] == 30
def test_ws_ticket_single_use_and_authenticates_owner(app_server):
session, name = _signup()
owner_uid = _uid(name)
ticket = session.post(f"{BASE_URL}/messages/ws-ticket", headers=JSON).json()[
"ticket"
]
async def first_connect():
async with websockets.connect(f"{WS_URL}?ticket={ticket}") as ws:
ready = await _recv_until(ws, "ready")
assert ready["user_uid"] == owner_uid
asyncio.run(first_connect())
async def second_connect():
async with websockets.connect(f"{WS_URL}?ticket={ticket}") as ws:
with pytest.raises(websockets.exceptions.ConnectionClosed):
await asyncio.wait_for(ws.recv(), timeout=3.0)
asyncio.run(second_connect())

View File

@ -176,7 +176,7 @@ def test_send_message_appears_in_thread(alice):
f"{BASE_URL}/messages?with_uid={bob['uid']}", wait_until="domcontentloaded"
)
msg = f"Hello bob {int(time.time() * 1000)}"
page.fill("input[name='content']", msg)
page.fill("textarea[name='content']", msg)
page.locator(".messages-send-btn").click()
page.wait_for_url("**/messages**", wait_until="domcontentloaded")
page.locator(f".message-bubble:has-text('{msg}')").first.wait_for(state="visible")
@ -290,3 +290,56 @@ def test_messages_page_is_noindex(alice):
meta = page.locator('meta[name="robots"]')
content = meta.get_attribute("content")
assert content and "noindex" in content
def test_optimistic_send_pending_then_reconciles_no_double_render(alice, bob):
page_a, user_a = alice
page_b, user_b = bob
uid_a = get_table("users").find_one(username=user_a["username"])["uid"]
uid_b = get_table("users").find_one(username=user_b["username"])["uid"]
page_a.goto(
f"{BASE_URL}/messages?with_uid={uid_b}", wait_until="domcontentloaded"
)
page_b.goto(
f"{BASE_URL}/messages?with_uid={uid_a}", wait_until="domcontentloaded"
)
msg = f"Optimistic hello {int(time.time() * 1000)}"
textarea = page_a.locator(".messages-input-area textarea[name='content']")
textarea.wait_for(state="visible")
textarea.fill(msg)
page_a.locator(".messages-send-btn").click()
pending = page_a.locator(f".message-bubble.mine.pending:has-text('{msg}')")
pending.wait_for(state="visible", timeout=5000)
client_id = pending.get_attribute("data-client-id")
assert client_id
reconciled = page_a.locator(
f".message-bubble.mine[data-client-id='{client_id}'][data-msg-uid]:not(.pending)"
)
reconciled.wait_for(state="visible", timeout=6000)
received = page_b.locator(f".message-bubble.theirs:has-text('{msg}')")
received.wait_for(state="visible", timeout=6000)
assert received.count() == 1
def test_mobile_single_pane_back_button(mobile_page):
page, user = mobile_page
bob = get_table("users").find_one(username="bob_test")
page.goto(
f"{BASE_URL}/messages?with_uid={bob['uid']}", wait_until="domcontentloaded"
)
thread = page.locator(".messages-main")
conv_list = page.locator(".messages-list")
thread.wait_for(state="visible")
expect(conv_list).to_be_hidden()
page.locator("#messages-back-btn").click()
expect(conv_list).to_be_visible()
expect(thread).to_be_hidden()
assert page.url == f"{BASE_URL}/messages"

View File

@ -440,7 +440,7 @@ def test_message_notification(app_server, browser, seeded_db):
pb.goto(f"{BASE_URL}/messages?search=alice_test", wait_until="domcontentloaded")
pb.wait_for_timeout(2000)
msg_input = pb.locator("input[name='content']").first
msg_input = pb.locator("textarea[name='content']").first
msg_input.wait_for(state="visible", timeout=10000)
msg_input.fill("Hello from bob_test!")
pb.locator("button[type='submit']").last.click()
@ -753,7 +753,7 @@ def test_message_notification_click_opens_conversation(app_server, browser, seed
pb.goto(f"{BASE_URL}/messages?search=alice_test", wait_until="domcontentloaded")
pb.wait_for_timeout(2000)
msg_input = pb.locator("input[name='content']").first
msg_input = pb.locator("textarea[name='content']").first
msg_input.wait_for(state="visible", timeout=10000)
msg_input.fill("Click-through message from bob")
pb.locator("button[type='submit']").last.click()

View File

@ -0,0 +1,47 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
from devplacepy.services.messaging.tickets import issue_ticket, redeem_ticket
def _user_uid():
uid = generate_uid()
get_table("users").insert(
{
"uid": uid,
"username": f"tkt_{uid[:8]}",
"email": f"{uid[:8]}@tkt.dev",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def test_ticket_redeems_once(local_db):
user_uid = _user_uid()
token = issue_ticket(user_uid)
assert redeem_ticket(token) == user_uid
assert redeem_ticket(token) is None
def test_expired_ticket_never_used_still_rejected(local_db):
user_uid = _user_uid()
token = issue_ticket(user_uid)
expired_at = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()
tickets = get_table("ws_tickets")
row = tickets.find_one(token=token)
tickets.update({"uid": row["uid"], "expires_at": expired_at}, ["uid"])
assert redeem_ticket(token) is None
def test_redeem_unknown_token_returns_none(local_db):
assert redeem_ticket("not-a-real-token") is None