Private
Public Access
Add emoji picker and message reactions (Gitea issue #11)
Composer gains an emoji picker (static curated unicode list, insert at cursor position) and messages gain Slack/Mattermost-style reactions: react with any emoji, toggle off by reacting again, see who reacted via a tooltip on each pill. Backend: MessageReaction model (unique on message_id+user_id+emoji backs toggle semantics), WS "reaction" envelope broadcasts the full recomputed reaction list per message (same approach as message edits), REST message list embeds reactions so a reload doesn't lose state that only arrived over WS. Frontend: shared EmojiPicker component (anchored popover, Escape/outside- click dismiss via new useEscapeKey hook) used by both the composer and a new hover-revealed reaction trigger on each message row.
This commit is contained in:
+33
-6
@@ -1,4 +1,4 @@
|
||||
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads)
|
||||
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, emoji & reactions)
|
||||
|
||||
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
|
||||
CRUD (open and private), room roles (owner/admin/member) and invites, a
|
||||
@@ -6,8 +6,9 @@ WebSocket chat endpoint that fans out across multiple app-server instances
|
||||
via Redis pub/sub, Web Push notifications for offline room members, a
|
||||
site-admin portal (user/room/bot management + an audit log), a bot/
|
||||
extension layer (scoped API tokens, live bot WebSocket access, incoming and
|
||||
outgoing webhooks, message editing), and image uploads in chat messages. See
|
||||
`../ARCHITECTURE.md` for the full system design and the phased build plan.
|
||||
outgoing webhooks, message editing), image uploads in chat messages, and
|
||||
emoji reactions on messages. See `../ARCHITECTURE.md` for the full system
|
||||
design and the phased build plan.
|
||||
|
||||
This is an **invite-only site**: there is no public registration endpoint.
|
||||
Accounts are created by an operator on the app server — see step 4 below.
|
||||
@@ -119,9 +120,10 @@ app/
|
||||
and on-disk save/read -- see Image uploads below
|
||||
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
|
||||
models/ SQLAlchemy models (users, rooms, room_memberships,
|
||||
messages, message_images, room_invites,
|
||||
push_subscriptions, admin_audit_log, api_tokens,
|
||||
webhooks_incoming, event_subscriptions)
|
||||
messages, message_images, message_reactions,
|
||||
room_invites, push_subscriptions,
|
||||
admin_audit_log, api_tokens, webhooks_incoming,
|
||||
event_subscriptions)
|
||||
schemas/ Pydantic request/response models
|
||||
routers/ auth, rooms, invites, push, admin, bots, webhooks, health
|
||||
services/ business logic called by routers
|
||||
@@ -349,6 +351,31 @@ cleanup job for this yet. Not a security issue, since serving still goes
|
||||
through the same room-membership gate as everything else; just an eventual
|
||||
disk-space housekeeping item.
|
||||
|
||||
## Emoji & reactions
|
||||
|
||||
An emoji picker in the frontend composer is purely client-side (a static
|
||||
curated unicode list, no backend involvement). Message **reactions** are
|
||||
full-stack: `message_reactions` (`app/models/message_reaction.py`) has
|
||||
`message_id`, `user_id`, `emoji`, and a `UniqueConstraint` on all three
|
||||
backing toggle semantics — the same user reacting with the same emoji on
|
||||
the same message twice removes it (Slack/Mattermost convention).
|
||||
`message_service.toggle_reaction` is a plain select-then-delete-or-insert,
|
||||
no upsert needed.
|
||||
|
||||
WS `"reaction"` envelope (`room_id`, `message_id`, `emoji`) toggles a
|
||||
reaction; the server broadcasts the message's **full recomputed** reaction
|
||||
list (`{"type": "reaction_update", "id", "room_id", "reactions": [...]}`),
|
||||
not an add/remove delta — same approach `message_update` already uses for
|
||||
edits, keeping client-side state a simple replace rather than a merge.
|
||||
`GET /{room_id}/messages` embeds each message's `reactions` too
|
||||
(`message_service.get_reactions_for_messages`, batched, not N+1), so a page
|
||||
reload doesn't lose reaction state that only ever arrived over WS.
|
||||
|
||||
Scope cuts: no outgoing-webhook event type for reactions (`VALID_EVENT_TYPES`
|
||||
in `webhook_service.py` is unchanged — same restraint as image uploads), no
|
||||
reaction-count limit or rate limiting, no custom/uploaded emoji (unicode
|
||||
only, curated client-side list in `frontend/src/lib/emoji.ts`).
|
||||
|
||||
## Notes / scope decisions
|
||||
|
||||
- Invite-only site registration: no `POST /api/auth/register`. Accounts are
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""message reactions
|
||||
|
||||
Revision ID: 1d355add7299
|
||||
Revises: c610bdb04567
|
||||
Create Date: 2026-08-14 12:59:40.013407
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1d355add7299'
|
||||
down_revision: Union[str, Sequence[str], None] = 'c610bdb04567'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('message_reactions',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('message_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('emoji', sa.String(length=32), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['message_id'], ['messages.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('message_id', 'user_id', 'emoji', name='uq_message_reactions_message_user_emoji')
|
||||
)
|
||||
op.create_index(op.f('ix_message_reactions_message_id'), 'message_reactions', ['message_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_message_reactions_message_id'), table_name='message_reactions')
|
||||
op.drop_table('message_reactions')
|
||||
# ### end Alembic commands ###
|
||||
@@ -6,6 +6,7 @@ from app.models.invite import InviteStatus, RoomInvite
|
||||
from app.models.membership import RoomMembership, RoomRole
|
||||
from app.models.message import Message
|
||||
from app.models.message_image import MessageImage
|
||||
from app.models.message_reaction import MessageReaction
|
||||
from app.models.push_subscription import PushSubscription
|
||||
from app.models.room import Room
|
||||
from app.models.user import User
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
"RoomRole",
|
||||
"Message",
|
||||
"MessageImage",
|
||||
"MessageReaction",
|
||||
"RoomInvite",
|
||||
"InviteStatus",
|
||||
"PushSubscription",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class MessageReaction(Base):
|
||||
__tablename__ = "message_reactions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"message_id", "user_id", "emoji",
|
||||
name="uq_message_reactions_message_user_emoji",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("messages.id"), index=True, nullable=False)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
emoji: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
message = relationship("Message")
|
||||
user = relationship("User")
|
||||
@@ -42,7 +42,7 @@ from app.schemas.webhook import (
|
||||
WebhookIncomingCreate,
|
||||
WebhookIncomingRead,
|
||||
)
|
||||
from app.services.message_service import list_recent_messages
|
||||
from app.services.message_service import get_reactions_for_messages, list_recent_messages
|
||||
from app.services.room_service import (
|
||||
CannotRemoveOwnerError,
|
||||
DuplicateRoomError,
|
||||
@@ -295,6 +295,7 @@ async def get_room_messages_endpoint(
|
||||
require_scope(request, "read:messages")
|
||||
await require_room_member(room_id, current_user, db)
|
||||
messages = await list_recent_messages(db, room_id, limit)
|
||||
reactions_by_message = await get_reactions_for_messages(db, [m.id for m in messages])
|
||||
return [
|
||||
MessageRead(
|
||||
id=m.id,
|
||||
@@ -303,6 +304,7 @@ async def get_room_messages_endpoint(
|
||||
username=m.user.username,
|
||||
content=m.content,
|
||||
image_id=m.image_id,
|
||||
reactions=reactions_by_message.get(m.id, []),
|
||||
created_at=m.created_at,
|
||||
edited_at=m.edited_at,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,12 @@ from datetime import datetime
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ReactionSummary(BaseModel):
|
||||
emoji: str
|
||||
count: int
|
||||
user_ids: list[str]
|
||||
|
||||
|
||||
class MessageRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -13,5 +19,6 @@ class MessageRead(BaseModel):
|
||||
username: str
|
||||
content: str | None
|
||||
image_id: uuid.UUID | None
|
||||
reactions: list[ReactionSummary]
|
||||
created_at: datetime
|
||||
edited_at: datetime | None
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Message, Room, RoomMembership, User
|
||||
from app.schemas.message import ReactionSummary
|
||||
from app.services.push_service import send_push_to_user
|
||||
from app.services.webhook_service import dispatch_event
|
||||
from app.ws.broadcaster import RoomBroadcaster
|
||||
@@ -49,6 +50,7 @@ def _message_payload(message: Message, username: str) -> dict:
|
||||
"username": username,
|
||||
"content": message.content,
|
||||
"image_id": str(message.image_id) if message.image_id else None,
|
||||
"reactions": [],
|
||||
"created_at": message.created_at.isoformat(),
|
||||
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
||||
}
|
||||
@@ -83,3 +85,21 @@ async def broadcast_message_update(
|
||||
}
|
||||
await broadcaster.publish(room_id, payload)
|
||||
await dispatch_event(db, "message.updated", room_id, payload)
|
||||
|
||||
|
||||
async def broadcast_reaction_update(
|
||||
broadcaster: RoomBroadcaster,
|
||||
room_id: uuid.UUID,
|
||||
message_id: uuid.UUID,
|
||||
reactions: list[ReactionSummary],
|
||||
) -> None:
|
||||
payload = {
|
||||
"type": "reaction_update",
|
||||
"id": str(message_id),
|
||||
"room_id": str(room_id),
|
||||
"reactions": [r.model_dump() for r in reactions],
|
||||
}
|
||||
await broadcaster.publish(room_id, payload)
|
||||
# Deliberately no dispatch_event() call -- reactions don't get an
|
||||
# outgoing-webhook event type, matching the same scope cut made for
|
||||
# image uploads (see backend/README.md).
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Message
|
||||
from app.models import Message, MessageReaction
|
||||
from app.schemas.message import ReactionSummary
|
||||
|
||||
|
||||
class MessageNotFoundError(Exception):
|
||||
@@ -59,3 +61,53 @@ async def list_recent_messages(
|
||||
messages = list(result.scalars().all())
|
||||
messages.reverse()
|
||||
return messages
|
||||
|
||||
|
||||
async def get_reactions_for_messages(
|
||||
db: AsyncSession, message_ids: list[uuid.UUID]
|
||||
) -> dict[uuid.UUID, list[ReactionSummary]]:
|
||||
if not message_ids:
|
||||
return {}
|
||||
|
||||
result = await db.execute(
|
||||
select(MessageReaction)
|
||||
.where(MessageReaction.message_id.in_(message_ids))
|
||||
.order_by(MessageReaction.created_at)
|
||||
)
|
||||
# Grouped in Python rather than a GROUP BY/array_agg query -- the row
|
||||
# count per room-history page is small, and this keeps the ordering
|
||||
# (first-reacted emoji first, first-reacted user first within it)
|
||||
# trivial instead of relying on Postgres-specific aggregate ordering.
|
||||
by_message: dict[uuid.UUID, dict[str, list[str]]] = defaultdict(dict)
|
||||
for reaction in result.scalars().all():
|
||||
emoji_map = by_message[reaction.message_id]
|
||||
emoji_map.setdefault(reaction.emoji, []).append(str(reaction.user_id))
|
||||
|
||||
return {
|
||||
message_id: [
|
||||
ReactionSummary(emoji=emoji, count=len(user_ids), user_ids=user_ids)
|
||||
for emoji, user_ids in emoji_map.items()
|
||||
]
|
||||
for message_id, emoji_map in by_message.items()
|
||||
}
|
||||
|
||||
|
||||
async def toggle_reaction(
|
||||
db: AsyncSession, message_id: uuid.UUID, user_id: uuid.UUID, emoji: str
|
||||
) -> list[ReactionSummary]:
|
||||
result = await db.execute(
|
||||
select(MessageReaction).where(
|
||||
MessageReaction.message_id == message_id,
|
||||
MessageReaction.user_id == user_id,
|
||||
MessageReaction.emoji == emoji,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is not None:
|
||||
await db.delete(existing)
|
||||
else:
|
||||
db.add(MessageReaction(message_id=message_id, user_id=user_id, emoji=emoji))
|
||||
await db.commit()
|
||||
|
||||
reactions = await get_reactions_for_messages(db, [message_id])
|
||||
return reactions.get(message_id, [])
|
||||
|
||||
+40
-2
@@ -6,14 +6,19 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import ApiToken, MessageImage, RoomMembership, User
|
||||
from app.models import ApiToken, Message, MessageImage, RoomMembership, User
|
||||
from app.services.bot_service import resolve_token
|
||||
from app.services.message_events import broadcast_message_update, broadcast_new_message
|
||||
from app.services.message_events import (
|
||||
broadcast_message_update,
|
||||
broadcast_new_message,
|
||||
broadcast_reaction_update,
|
||||
)
|
||||
from app.services.message_service import (
|
||||
MessageNotFoundError,
|
||||
NotMessageAuthorError,
|
||||
create_message,
|
||||
edit_message,
|
||||
toggle_reaction,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["ws"])
|
||||
@@ -27,6 +32,7 @@ class ClientEnvelope(BaseModel):
|
||||
content: str | None = None
|
||||
image_id: uuid.UUID | None = None
|
||||
message_id: uuid.UUID | None = None
|
||||
emoji: str | None = None
|
||||
|
||||
|
||||
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
|
||||
@@ -163,6 +169,38 @@ 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 == "reaction":
|
||||
if (
|
||||
envelope.room_id is None
|
||||
or envelope.message_id is None
|
||||
or not envelope.emoji
|
||||
or len(envelope.emoji) > 8
|
||||
):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "room_id, message_id, and emoji 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
|
||||
target_message = await db.get(Message, envelope.message_id)
|
||||
if target_message is None or target_message.room_id != envelope.room_id:
|
||||
await websocket.send_json({"type": "error", "detail": "Message not found"})
|
||||
continue
|
||||
reactions = await toggle_reaction(db, envelope.message_id, user.id, envelope.emoji)
|
||||
await broadcast_reaction_update(
|
||||
broadcaster, envelope.room_id, envelope.message_id, reactions
|
||||
)
|
||||
|
||||
else:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": f"Unknown message type: {envelope.type}"}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import uuid
|
||||
|
||||
from app.models import User
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
await register_user(
|
||||
session,
|
||||
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||
)
|
||||
|
||||
ws_client.portal.call(_seed)
|
||||
resp = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _make_admin_ws(ws_client, user_id: str) -> None:
|
||||
async def _promote():
|
||||
async with ws_client.session_factory() as session:
|
||||
user = await session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await session.commit()
|
||||
|
||||
ws_client.portal.call(_promote)
|
||||
|
||||
|
||||
def test_ws_reaction_toggle_add(ws_client):
|
||||
_register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
assert message["reactions"] == []
|
||||
|
||||
ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "👍"})
|
||||
update = ws.receive_json()
|
||||
assert update["type"] == "reaction_update"
|
||||
assert update["id"] == message["id"]
|
||||
assert len(update["reactions"]) == 1
|
||||
assert update["reactions"][0]["emoji"] == "👍"
|
||||
assert update["reactions"][0]["count"] == 1
|
||||
|
||||
|
||||
def test_ws_reaction_toggle_remove(ws_client):
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
|
||||
ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "👍"})
|
||||
first = ws.receive_json()
|
||||
assert len(first["reactions"]) == 1
|
||||
|
||||
ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "👍"})
|
||||
second = ws.receive_json()
|
||||
assert second["reactions"] == []
|
||||
|
||||
|
||||
def test_reaction_appears_in_rest_message_list(ws_client):
|
||||
_register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "🔥"})
|
||||
assert ws.receive_json()["type"] == "reaction_update"
|
||||
|
||||
history = ws_client.get(f"/api/rooms/{room['id']}/messages").json()
|
||||
persisted = next(m for m in history if m["id"] == message["id"])
|
||||
assert persisted["reactions"] == [{"emoji": "🔥", "count": 1, "user_ids": [message["user_id"]]}]
|
||||
|
||||
|
||||
def test_reaction_broadcasts_to_other_room_members(ws_client):
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bob = _register_ws(ws_client, _unique("bob"))
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
|
||||
message = alice_ws.receive_json()
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username_or_email": alice["username"], "password": "password123"},
|
||||
)
|
||||
alice_ws.send_json(
|
||||
{"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "🎉"}
|
||||
)
|
||||
assert alice_ws.receive_json()["type"] == "reaction_update"
|
||||
update = bob_ws.receive_json()
|
||||
assert update["type"] == "reaction_update"
|
||||
assert update["reactions"][0]["emoji"] == "🎉"
|
||||
|
||||
|
||||
def test_reaction_requires_room_membership(ws_client_factory):
|
||||
owner_client = ws_client_factory()
|
||||
outsider_client = ws_client_factory()
|
||||
|
||||
_register_ws(owner_client, _unique("alice"))
|
||||
room = owner_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with owner_client.websocket_connect("/ws/chat") as owner_ws:
|
||||
owner_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert owner_ws.receive_json()["type"] == "joined"
|
||||
owner_ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = owner_ws.receive_json()
|
||||
|
||||
_register_ws(outsider_client, _unique("mallory"))
|
||||
with outsider_client.websocket_connect("/ws/chat") as outsider_ws:
|
||||
outsider_ws.send_json(
|
||||
{"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "👍"}
|
||||
)
|
||||
resp = outsider_ws.receive_json()
|
||||
assert resp["type"] == "error"
|
||||
|
||||
|
||||
def test_bot_reaction_without_write_scope_rejected(ws_client_factory):
|
||||
ws_client = ws_client_factory()
|
||||
admin = _register_ws(ws_client, _unique("admin"))
|
||||
_make_admin_ws(ws_client, admin["id"])
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
|
||||
bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
|
||||
token = ws_client.post(
|
||||
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages"]}
|
||||
).json()["token"]
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat", headers={"Authorization": f"Bearer {token}"}) as bot_ws:
|
||||
bot_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bot_ws.receive_json()["type"] == "joined"
|
||||
bot_ws.send_json(
|
||||
{"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "👍"}
|
||||
)
|
||||
resp = bot_ws.receive_json()
|
||||
assert resp["type"] == "error"
|
||||
assert "write:messages" in resp["detail"]
|
||||
|
||||
|
||||
def test_reaction_empty_emoji_rejected(ws_client):
|
||||
_register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
|
||||
ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": ""})
|
||||
resp = ws.receive_json()
|
||||
assert resp["type"] == "error"
|
||||
|
||||
|
||||
def test_reaction_invalid_message_id_rejected(ws_client):
|
||||
_register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json(
|
||||
{"type": "reaction", "room_id": room["id"], "message_id": str(uuid.uuid4()), "emoji": "👍"}
|
||||
)
|
||||
resp = ws.receive_json()
|
||||
assert resp["type"] == "error"
|
||||
assert "Message not found" in resp["detail"]
|
||||
|
||||
|
||||
def test_reaction_wrong_room_rejected(ws_client):
|
||||
_register_ws(ws_client, _unique("alice"))
|
||||
room_a = ws_client.post("/api/rooms", json={"name": _unique("room-a")}).json()
|
||||
room_b = ws_client.post("/api/rooms", json={"name": _unique("room-b")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room_a["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room_a["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
|
||||
ws.send_json({"type": "join", "room_id": room_b["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json(
|
||||
{"type": "reaction", "room_id": room_b["id"], "message_id": message["id"], "emoji": "👍"}
|
||||
)
|
||||
resp = ws.receive_json()
|
||||
assert resp["type"] == "error"
|
||||
assert "Message not found" in resp["detail"]
|
||||
Reference in New Issue
Block a user