Add the ability to delete a message (#53)

Message.deleted_at has existed since the initial schema but was never
wired up -- no WS envelope, no permission check, no frontend concept of
it at all. Soft delete, author-only (mirrors the existing edit
permission exactly): content and any attached image/file are cleared
and the underlying MessageImage/MessageFile row and stored file are
actually removed, not just detached, so the message becomes a "This
message was deleted" tombstone with nothing left to recover through a
stale attachment URL. A deleted message can no longer be edited or
reacted to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 16:35:00 -06:00
co-authored by Claude Sonnet 5
parent 157f1e30ac
commit ef615e1ef4
13 changed files with 477 additions and 8 deletions
+37 -1
View File
@@ -11,6 +11,7 @@ from app.services.bot_service import resolve_token
from app.services.message_events import (
broadcast_dm_presence_update,
broadcast_member_updated,
broadcast_message_delete,
broadcast_message_update,
broadcast_new_message,
broadcast_reaction_update,
@@ -19,6 +20,7 @@ from app.services.message_service import (
MessageNotFoundError,
NotMessageAuthorError,
create_message,
delete_message,
edit_message,
toggle_reaction,
)
@@ -261,6 +263,36 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
continue
await broadcast_message_update(db, broadcaster, envelope.room_id, message)
elif envelope.type == "delete":
if envelope.room_id is None or envelope.message_id is None:
await websocket.send_json(
{"type": "error", "detail": "room_id and message_id required"}
)
continue
if _missing_scope(api_token, "write:messages"):
await websocket.send_json(
{"type": "error", "detail": "Token missing required scope: write:messages"}
)
continue
if envelope.room_id not in joined_rooms or not await _is_room_member(
db, envelope.room_id, user.id
):
await websocket.send_json(
{"type": "error", "detail": "Not a member of this room"}
)
continue
try:
await delete_message(db, envelope.message_id, user.id)
except MessageNotFoundError:
await websocket.send_json({"type": "error", "detail": "Message not found"})
continue
except NotMessageAuthorError:
await websocket.send_json(
{"type": "error", "detail": "You can only delete your own messages"}
)
continue
await broadcast_message_delete(broadcaster, envelope.room_id, envelope.message_id)
elif envelope.type == "reaction":
if (
envelope.room_id is None
@@ -285,7 +317,11 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
)
continue
target_message = await db.get(Message, envelope.message_id)
if target_message is None or target_message.room_id != envelope.room_id:
if (
target_message is None
or target_message.room_id != envelope.room_id
or target_message.deleted_at is not None
):
await websocket.send_json({"type": "error", "detail": "Message not found"})
continue
reactions = await toggle_reaction(db, envelope.message_id, user.id, envelope.emoji)