feat: add message reliability foundation
All checks were successful
CI / test (push) Successful in 23s
All checks were successful
CI / test (push) Successful in 23s
- implement idempotent message creation via client_message_id - add persistent delivered/read receipts - expose /messages/status and wire websocket receipt events - update web client to send client ids and auto-ack delivered/read
This commit is contained in:
83
alembic/versions/0002_message_reliability_tables.py
Normal file
83
alembic/versions/0002_message_reliability_tables.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""message reliability tables
|
||||||
|
|
||||||
|
Revision ID: 0002_message_reliability_tables
|
||||||
|
Revises: 0001_initial_schema
|
||||||
|
Create Date: 2026-03-08 01:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0002_message_reliability_tables"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "0001_initial_schema"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"message_idempotency_keys",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("chat_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("sender_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("client_message_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("message_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name=op.f("fk_message_idempotency_keys_chat_id_chats"), ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["sender_id"], ["users.id"], name=op.f("fk_message_idempotency_keys_sender_id_users"), ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["message_id"],
|
||||||
|
["messages.id"],
|
||||||
|
name=op.f("fk_message_idempotency_keys_message_id_messages"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_message_idempotency_keys")),
|
||||||
|
sa.UniqueConstraint("chat_id", "sender_id", "client_message_id", name="uq_msg_idem_chat_sender_client"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_message_idempotency_keys_id"), "message_idempotency_keys", ["id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_message_idempotency_keys_chat_id"), "message_idempotency_keys", ["chat_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_message_idempotency_keys_sender_id"),
|
||||||
|
"message_idempotency_keys",
|
||||||
|
["sender_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_message_idempotency_keys_message_id"),
|
||||||
|
"message_idempotency_keys",
|
||||||
|
["message_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"message_receipts",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("chat_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("last_delivered_message_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("last_read_message_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name=op.f("fk_message_receipts_chat_id_chats"), ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_message_receipts_user_id_users"), ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_message_receipts")),
|
||||||
|
sa.UniqueConstraint("chat_id", "user_id", name="uq_msg_receipts_chat_user"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_message_receipts_id"), "message_receipts", ["id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_message_receipts_chat_id"), "message_receipts", ["chat_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_message_receipts_user_id"), "message_receipts", ["user_id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_message_receipts_user_id"), table_name="message_receipts")
|
||||||
|
op.drop_index(op.f("ix_message_receipts_chat_id"), table_name="message_receipts")
|
||||||
|
op.drop_index(op.f("ix_message_receipts_id"), table_name="message_receipts")
|
||||||
|
op.drop_table("message_receipts")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_message_idempotency_keys_message_id"), table_name="message_idempotency_keys")
|
||||||
|
op.drop_index(op.f("ix_message_idempotency_keys_sender_id"), table_name="message_idempotency_keys")
|
||||||
|
op.drop_index(op.f("ix_message_idempotency_keys_chat_id"), table_name="message_idempotency_keys")
|
||||||
|
op.drop_index(op.f("ix_message_idempotency_keys_id"), table_name="message_idempotency_keys")
|
||||||
|
op.drop_table("message_idempotency_keys")
|
||||||
@@ -2,7 +2,7 @@ from app.auth.models import EmailVerificationToken, PasswordResetToken
|
|||||||
from app.chats.models import Chat, ChatMember
|
from app.chats.models import Chat, ChatMember
|
||||||
from app.email.models import EmailLog
|
from app.email.models import EmailLog
|
||||||
from app.media.models import Attachment
|
from app.media.models import Attachment
|
||||||
from app.messages.models import Message
|
from app.messages.models import Message, MessageIdempotencyKey, MessageReceipt
|
||||||
from app.notifications.models import NotificationLog
|
from app.notifications.models import NotificationLog
|
||||||
from app.users.models import User
|
from app.users.models import User
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ __all__ = [
|
|||||||
"EmailLog",
|
"EmailLog",
|
||||||
"EmailVerificationToken",
|
"EmailVerificationToken",
|
||||||
"Message",
|
"Message",
|
||||||
|
"MessageIdempotencyKey",
|
||||||
|
"MessageReceipt",
|
||||||
"NotificationLog",
|
"NotificationLog",
|
||||||
"PasswordResetToken",
|
"PasswordResetToken",
|
||||||
"User",
|
"User",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Text, func
|
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, String, Text, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.base import Base
|
from app.database.base import Base
|
||||||
@@ -42,3 +42,34 @@ class Message(Base):
|
|||||||
chat: Mapped["Chat"] = relationship(back_populates="messages")
|
chat: Mapped["Chat"] = relationship(back_populates="messages")
|
||||||
sender: Mapped["User"] = relationship(back_populates="sent_messages")
|
sender: Mapped["User"] = relationship(back_populates="sent_messages")
|
||||||
attachments: Mapped[list["Attachment"]] = relationship(back_populates="message", cascade="all, delete-orphan")
|
attachments: Mapped[list["Attachment"]] = relationship(back_populates="message", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class MessageIdempotencyKey(Base):
|
||||||
|
__tablename__ = "message_idempotency_keys"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("chat_id", "sender_id", "client_message_id", name="uq_msg_idem_chat_sender_client"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
sender_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
client_message_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageReceipt(Base):
|
||||||
|
__tablename__ = "message_receipts"
|
||||||
|
__table_args__ = (UniqueConstraint("chat_id", "user_id", name="uq_msg_receipts_chat_user"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
last_delivered_message_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||||
|
last_read_message_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.messages.models import Message, MessageType
|
from app.messages.models import Message, MessageIdempotencyKey, MessageReceipt, MessageType
|
||||||
|
|
||||||
|
|
||||||
async def create_message(
|
async def create_message(
|
||||||
@@ -18,6 +18,45 @@ async def create_message(
|
|||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
async def get_message_by_client_message_id(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
sender_id: int,
|
||||||
|
client_message_id: str,
|
||||||
|
) -> Message | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Message)
|
||||||
|
.join(MessageIdempotencyKey, MessageIdempotencyKey.message_id == Message.id)
|
||||||
|
.where(
|
||||||
|
MessageIdempotencyKey.chat_id == chat_id,
|
||||||
|
MessageIdempotencyKey.sender_id == sender_id,
|
||||||
|
MessageIdempotencyKey.client_message_id == client_message_id,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_message_idempotency_key(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
sender_id: int,
|
||||||
|
client_message_id: str,
|
||||||
|
message_id: int,
|
||||||
|
) -> MessageIdempotencyKey:
|
||||||
|
key = MessageIdempotencyKey(
|
||||||
|
chat_id=chat_id,
|
||||||
|
sender_id=sender_id,
|
||||||
|
client_message_id=client_message_id,
|
||||||
|
message_id=message_id,
|
||||||
|
)
|
||||||
|
db.add(key)
|
||||||
|
await db.flush()
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
async def get_message_by_id(db: AsyncSession, message_id: int) -> Message | None:
|
async def get_message_by_id(db: AsyncSession, message_id: int) -> Message | None:
|
||||||
result = await db.execute(select(Message).where(Message.id == message_id))
|
result = await db.execute(select(Message).where(Message.id == message_id))
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
@@ -39,3 +78,32 @@ async def list_chat_messages(
|
|||||||
|
|
||||||
async def delete_message(db: AsyncSession, message: Message) -> None:
|
async def delete_message(db: AsyncSession, message: Message) -> None:
|
||||||
await db.delete(message)
|
await db.delete(message)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_message_receipt(db: AsyncSession, *, chat_id: int, user_id: int) -> MessageReceipt | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(MessageReceipt).where(
|
||||||
|
MessageReceipt.chat_id == chat_id,
|
||||||
|
MessageReceipt.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_message_receipt(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
user_id: int,
|
||||||
|
last_delivered_message_id: int | None,
|
||||||
|
last_read_message_id: int | None,
|
||||||
|
) -> MessageReceipt:
|
||||||
|
receipt = MessageReceipt(
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=user_id,
|
||||||
|
last_delivered_message_id=last_delivered_message_id,
|
||||||
|
last_read_message_id=last_read_message_id,
|
||||||
|
)
|
||||||
|
db.add(receipt)
|
||||||
|
await db.flush()
|
||||||
|
return receipt
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.auth.service import get_current_user
|
from app.auth.service import get_current_user
|
||||||
from app.database.session import get_db
|
from app.database.session import get_db
|
||||||
from app.messages.schemas import MessageCreateRequest, MessageRead, MessageUpdateRequest
|
from app.messages.schemas import MessageCreateRequest, MessageRead, MessageStatusUpdateRequest, MessageUpdateRequest
|
||||||
from app.messages.service import create_chat_message, delete_message, get_messages, update_message
|
from app.messages.service import create_chat_message, delete_message, get_messages, update_message
|
||||||
|
from app.realtime.schemas import MessageStatusPayload
|
||||||
from app.realtime.service import realtime_gateway
|
from app.realtime.service import realtime_gateway
|
||||||
from app.users.models import User
|
from app.users.models import User
|
||||||
|
|
||||||
@@ -18,7 +19,11 @@ async def create_message(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> MessageRead:
|
) -> MessageRead:
|
||||||
message = await create_chat_message(db, sender_id=current_user.id, payload=payload)
|
message = await create_chat_message(db, sender_id=current_user.id, payload=payload)
|
||||||
await realtime_gateway.publish_message_created(message=message, sender_id=current_user.id)
|
await realtime_gateway.publish_message_created(
|
||||||
|
message=message,
|
||||||
|
sender_id=current_user.id,
|
||||||
|
client_message_id=payload.client_message_id,
|
||||||
|
)
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
@@ -50,3 +55,17 @@ async def remove_message(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> None:
|
) -> None:
|
||||||
await delete_message(db, message_id=message_id, user_id=current_user.id)
|
await delete_message(db, message_id=message_id, user_id=current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/status")
|
||||||
|
async def update_status(
|
||||||
|
payload: MessageStatusUpdateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> dict[str, int]:
|
||||||
|
return await realtime_gateway.handle_message_status(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
payload=MessageStatusPayload(chat_id=payload.chat_id, message_id=payload.message_id),
|
||||||
|
event=payload.status,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@@ -21,7 +22,14 @@ class MessageCreateRequest(BaseModel):
|
|||||||
chat_id: int
|
chat_id: int
|
||||||
type: MessageType = MessageType.TEXT
|
type: MessageType = MessageType.TEXT
|
||||||
text: str | None = Field(default=None, max_length=4096)
|
text: str | None = Field(default=None, max_length=4096)
|
||||||
|
client_message_id: str | None = Field(default=None, min_length=8, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
class MessageUpdateRequest(BaseModel):
|
class MessageUpdateRequest(BaseModel):
|
||||||
text: str = Field(min_length=1, max_length=4096)
|
text: str = Field(min_length=1, max_length=4096)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageStatusUpdateRequest(BaseModel):
|
||||||
|
chat_id: int
|
||||||
|
message_id: int
|
||||||
|
status: Literal["message_delivered", "message_read"]
|
||||||
|
|||||||
@@ -1,29 +1,60 @@
|
|||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.chats.service import ensure_chat_membership
|
from app.chats.service import ensure_chat_membership
|
||||||
from app.messages import repository
|
from app.messages import repository
|
||||||
from app.messages.models import Message
|
from app.messages.models import Message
|
||||||
from app.messages.spam_guard import enforce_message_spam_policy
|
from app.messages.spam_guard import enforce_message_spam_policy
|
||||||
from app.messages.schemas import MessageCreateRequest, MessageUpdateRequest
|
from app.messages.schemas import MessageCreateRequest, MessageStatusUpdateRequest, MessageUpdateRequest
|
||||||
from app.notifications.service import dispatch_message_notifications
|
from app.notifications.service import dispatch_message_notifications
|
||||||
|
|
||||||
|
|
||||||
async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: MessageCreateRequest) -> Message:
|
async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: MessageCreateRequest) -> Message:
|
||||||
await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=sender_id)
|
await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=sender_id)
|
||||||
|
if payload.client_message_id:
|
||||||
|
existing = await repository.get_message_by_client_message_id(
|
||||||
|
db,
|
||||||
|
chat_id=payload.chat_id,
|
||||||
|
sender_id=sender_id,
|
||||||
|
client_message_id=payload.client_message_id,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
if payload.type.value == "text" and not (payload.text and payload.text.strip()):
|
if payload.type.value == "text" and not (payload.text and payload.text.strip()):
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Text message cannot be empty")
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Text message cannot be empty")
|
||||||
await enforce_message_spam_policy(user_id=sender_id, chat_id=payload.chat_id, text=payload.text)
|
await enforce_message_spam_policy(user_id=sender_id, chat_id=payload.chat_id, text=payload.text)
|
||||||
|
|
||||||
message = await repository.create_message(
|
try:
|
||||||
db,
|
message = await repository.create_message(
|
||||||
chat_id=payload.chat_id,
|
db,
|
||||||
sender_id=sender_id,
|
chat_id=payload.chat_id,
|
||||||
message_type=payload.type,
|
sender_id=sender_id,
|
||||||
text=payload.text,
|
message_type=payload.type,
|
||||||
)
|
text=payload.text,
|
||||||
await db.commit()
|
)
|
||||||
await db.refresh(message)
|
if payload.client_message_id:
|
||||||
|
await repository.create_message_idempotency_key(
|
||||||
|
db,
|
||||||
|
chat_id=payload.chat_id,
|
||||||
|
sender_id=sender_id,
|
||||||
|
client_message_id=payload.client_message_id,
|
||||||
|
message_id=message.id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(message)
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
if payload.client_message_id:
|
||||||
|
existing = await repository.get_message_by_client_message_id(
|
||||||
|
db,
|
||||||
|
chat_id=payload.chat_id,
|
||||||
|
sender_id=sender_id,
|
||||||
|
client_message_id=payload.client_message_id,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
raise
|
||||||
try:
|
try:
|
||||||
await dispatch_message_notifications(db, message)
|
await dispatch_message_notifications(db, message)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -73,3 +104,43 @@ async def delete_message(db: AsyncSession, *, message_id: int, user_id: int) ->
|
|||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can delete only your own messages")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can delete only your own messages")
|
||||||
await repository.delete_message(db, message)
|
await repository.delete_message(db, message)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_message_status(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
payload: MessageStatusUpdateRequest,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=user_id)
|
||||||
|
message = await repository.get_message_by_id(db, payload.message_id)
|
||||||
|
if not message or message.chat_id != payload.chat_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found")
|
||||||
|
|
||||||
|
receipt = await repository.get_message_receipt(db, chat_id=payload.chat_id, user_id=user_id)
|
||||||
|
if not receipt:
|
||||||
|
last_delivered = payload.message_id if payload.status in {"message_delivered", "message_read"} else None
|
||||||
|
last_read = payload.message_id if payload.status == "message_read" else None
|
||||||
|
receipt = await repository.create_message_receipt(
|
||||||
|
db,
|
||||||
|
chat_id=payload.chat_id,
|
||||||
|
user_id=user_id,
|
||||||
|
last_delivered_message_id=last_delivered,
|
||||||
|
last_read_message_id=last_read,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if payload.status in {"message_delivered", "message_read"}:
|
||||||
|
current = receipt.last_delivered_message_id or 0
|
||||||
|
receipt.last_delivered_message_id = max(current, payload.message_id)
|
||||||
|
if payload.status == "message_read":
|
||||||
|
current_read = receipt.last_read_message_id or 0
|
||||||
|
receipt.last_read_message_id = max(current_read, payload.message_id)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(receipt)
|
||||||
|
return {
|
||||||
|
"chat_id": payload.chat_id,
|
||||||
|
"message_id": payload.message_id,
|
||||||
|
"last_delivered_message_id": receipt.last_delivered_message_id or 0,
|
||||||
|
"last_read_message_id": receipt.last_read_message_id or 0,
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class SendMessagePayload(BaseModel):
|
|||||||
type: MessageType = MessageType.TEXT
|
type: MessageType = MessageType.TEXT
|
||||||
text: str | None = Field(default=None, max_length=4096)
|
text: str | None = Field(default=None, max_length=4096)
|
||||||
temp_id: str | None = None
|
temp_id: str | None = None
|
||||||
|
client_message_id: str | None = Field(default=None, min_length=8, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
class ChatEventPayload(BaseModel):
|
class ChatEventPayload(BaseModel):
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.chats.repository import list_user_chat_ids
|
from app.chats.repository import list_user_chat_ids
|
||||||
from app.chats.service import ensure_chat_membership
|
from app.chats.service import ensure_chat_membership
|
||||||
from app.messages.schemas import MessageCreateRequest, MessageRead
|
from app.messages.schemas import MessageCreateRequest, MessageRead, MessageStatusUpdateRequest
|
||||||
from app.messages.service import create_chat_message
|
from app.messages.service import create_chat_message, mark_message_status
|
||||||
from app.realtime.models import ConnectionContext
|
from app.realtime.models import ConnectionContext
|
||||||
from app.realtime.presence import mark_user_offline, mark_user_online
|
from app.realtime.presence import mark_user_offline, mark_user_online
|
||||||
from app.realtime.repository import RedisRealtimeRepository
|
from app.realtime.repository import RedisRealtimeRepository
|
||||||
@@ -81,9 +81,19 @@ class RealtimeGateway:
|
|||||||
message = await create_chat_message(
|
message = await create_chat_message(
|
||||||
db,
|
db,
|
||||||
sender_id=user_id,
|
sender_id=user_id,
|
||||||
payload=MessageCreateRequest(chat_id=payload.chat_id, type=payload.type, text=payload.text),
|
payload=MessageCreateRequest(
|
||||||
|
chat_id=payload.chat_id,
|
||||||
|
type=payload.type,
|
||||||
|
text=payload.text,
|
||||||
|
client_message_id=payload.client_message_id or payload.temp_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await self.publish_message_created(
|
||||||
|
message=message,
|
||||||
|
sender_id=user_id,
|
||||||
|
temp_id=payload.temp_id,
|
||||||
|
client_message_id=payload.client_message_id,
|
||||||
)
|
)
|
||||||
await self.publish_message_created(message=message, sender_id=user_id, temp_id=payload.temp_id)
|
|
||||||
|
|
||||||
async def handle_typing_event(self, db: AsyncSession, user_id: int, payload: ChatEventPayload, event: str) -> None:
|
async def handle_typing_event(self, db: AsyncSession, user_id: int, payload: ChatEventPayload, event: str) -> None:
|
||||||
await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=user_id)
|
await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=user_id)
|
||||||
@@ -99,8 +109,12 @@ class RealtimeGateway:
|
|||||||
user_id: int,
|
user_id: int,
|
||||||
payload: MessageStatusPayload,
|
payload: MessageStatusPayload,
|
||||||
event: str,
|
event: str,
|
||||||
) -> None:
|
) -> dict[str, int]:
|
||||||
await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=user_id)
|
receipt_state = await mark_message_status(
|
||||||
|
db,
|
||||||
|
user_id=user_id,
|
||||||
|
payload=MessageStatusUpdateRequest(chat_id=payload.chat_id, message_id=payload.message_id, status=event),
|
||||||
|
)
|
||||||
await self._publish_chat_event(
|
await self._publish_chat_event(
|
||||||
payload.chat_id,
|
payload.chat_id,
|
||||||
event=event,
|
event=event,
|
||||||
@@ -108,8 +122,11 @@ class RealtimeGateway:
|
|||||||
"chat_id": payload.chat_id,
|
"chat_id": payload.chat_id,
|
||||||
"message_id": payload.message_id,
|
"message_id": payload.message_id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
|
"last_delivered_message_id": receipt_state["last_delivered_message_id"],
|
||||||
|
"last_read_message_id": receipt_state["last_read_message_id"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
return receipt_state
|
||||||
|
|
||||||
async def load_user_chat_ids(self, db: AsyncSession, user_id: int) -> list[int]:
|
async def load_user_chat_ids(self, db: AsyncSession, user_id: int) -> list[int]:
|
||||||
return await list_user_chat_ids(db, user_id=user_id)
|
return await list_user_chat_ids(db, user_id=user_id)
|
||||||
@@ -139,7 +156,14 @@ class RealtimeGateway:
|
|||||||
return
|
return
|
||||||
await self._handle_redis_event(f"chat:{chat_id}", event_payload)
|
await self._handle_redis_event(f"chat:{chat_id}", event_payload)
|
||||||
|
|
||||||
async def publish_message_created(self, *, message, sender_id: int, temp_id: str | None = None) -> None:
|
async def publish_message_created(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
message,
|
||||||
|
sender_id: int,
|
||||||
|
temp_id: str | None = None,
|
||||||
|
client_message_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
message_data = MessageRead.model_validate(message).model_dump(mode="json")
|
message_data = MessageRead.model_validate(message).model_dump(mode="json")
|
||||||
await self._publish_chat_event(
|
await self._publish_chat_event(
|
||||||
message.chat_id,
|
message.chat_id,
|
||||||
@@ -148,6 +172,7 @@ class RealtimeGateway:
|
|||||||
"chat_id": message.chat_id,
|
"chat_id": message.chat_id,
|
||||||
"message": message_data,
|
"message": message_data,
|
||||||
"temp_id": temp_id,
|
"temp_id": temp_id,
|
||||||
|
"client_message_id": client_message_id,
|
||||||
"sender_id": sender_id,
|
"sender_id": sender_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,21 @@ export async function sendMessage(chatId: number, text: string, type: MessageTyp
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendMessageWithClientId(
|
||||||
|
chatId: number,
|
||||||
|
text: string,
|
||||||
|
type: MessageType,
|
||||||
|
clientMessageId: string
|
||||||
|
): Promise<Message> {
|
||||||
|
const { data } = await http.post<Message>("/messages", {
|
||||||
|
chat_id: chatId,
|
||||||
|
text,
|
||||||
|
type,
|
||||||
|
client_message_id: clientMessageId
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UploadUrlResponse {
|
export interface UploadUrlResponse {
|
||||||
upload_url: string;
|
upload_url: string;
|
||||||
file_url: string;
|
file_url: string;
|
||||||
@@ -77,3 +92,15 @@ export async function attachFile(messageId: number, fileUrl: string, fileType: s
|
|||||||
file_size: fileSize
|
file_size: fileSize
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateMessageStatus(
|
||||||
|
chatId: number,
|
||||||
|
messageId: number,
|
||||||
|
status: "message_delivered" | "message_read"
|
||||||
|
): Promise<void> {
|
||||||
|
await http.post("/messages/status", {
|
||||||
|
chat_id: chatId,
|
||||||
|
message_id: messageId,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { attachFile, requestUploadUrl, sendMessage, uploadToPresignedUrl } from "../api/chats";
|
import { attachFile, requestUploadUrl, sendMessageWithClientId, uploadToPresignedUrl } from "../api/chats";
|
||||||
import { useAuthStore } from "../store/authStore";
|
import { useAuthStore } from "../store/authStore";
|
||||||
import { useChatStore } from "../store/chatStore";
|
import { useChatStore } from "../store/chatStore";
|
||||||
import { buildWsUrl } from "../utils/ws";
|
import { buildWsUrl } from "../utils/ws";
|
||||||
@@ -28,6 +28,13 @@ export function MessageComposer() {
|
|||||||
};
|
};
|
||||||
}, [previewUrl]);
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
function makeClientMessageId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function getWs(): WebSocket | null {
|
function getWs(): WebSocket | null {
|
||||||
if (!accessToken || !activeChatId) {
|
if (!accessToken || !activeChatId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -44,7 +51,7 @@ export function MessageComposer() {
|
|||||||
if (!activeChatId || !text.trim()) {
|
if (!activeChatId || !text.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const message = await sendMessage(activeChatId, text.trim(), "text");
|
const message = await sendMessageWithClientId(activeChatId, text.trim(), "text", makeClientMessageId());
|
||||||
prependMessage(activeChatId, message);
|
prependMessage(activeChatId, message);
|
||||||
setText("");
|
setText("");
|
||||||
const ws = getWs();
|
const ws = getWs();
|
||||||
@@ -61,7 +68,7 @@ export function MessageComposer() {
|
|||||||
try {
|
try {
|
||||||
const upload = await requestUploadUrl(file);
|
const upload = await requestUploadUrl(file);
|
||||||
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file, setUploadProgress);
|
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file, setUploadProgress);
|
||||||
const message = await sendMessage(activeChatId, upload.file_url, messageType);
|
const message = await sendMessageWithClientId(activeChatId, upload.file_url, messageType, makeClientMessageId());
|
||||||
await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size);
|
await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size);
|
||||||
prependMessage(activeChatId, message);
|
prependMessage(activeChatId, message);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function useRealtime() {
|
|||||||
const prependMessage = useChatStore((s) => s.prependMessage);
|
const prependMessage = useChatStore((s) => s.prependMessage);
|
||||||
const loadChats = useChatStore((s) => s.loadChats);
|
const loadChats = useChatStore((s) => s.loadChats);
|
||||||
const chats = useChatStore((s) => s.chats);
|
const chats = useChatStore((s) => s.chats);
|
||||||
|
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||||
const typingByChat = useRef<Record<number, Set<number>>>({});
|
const typingByChat = useRef<Record<number, Set<number>>>({});
|
||||||
|
|
||||||
const wsUrl = useMemo(() => {
|
const wsUrl = useMemo(() => {
|
||||||
@@ -34,6 +35,12 @@ export function useRealtime() {
|
|||||||
const chatId = Number(event.payload.chat_id);
|
const chatId = Number(event.payload.chat_id);
|
||||||
const message = event.payload.message as Message;
|
const message = event.payload.message as Message;
|
||||||
prependMessage(chatId, message);
|
prependMessage(chatId, message);
|
||||||
|
if (message.sender_id !== me?.id) {
|
||||||
|
ws.send(JSON.stringify({ event: "message_delivered", payload: { chat_id: chatId, message_id: message.id } }));
|
||||||
|
if (chatId === activeChatId) {
|
||||||
|
ws.send(JSON.stringify({ event: "message_read", payload: { chat_id: chatId, message_id: message.id } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!chats.some((chat) => chat.id === chatId)) {
|
if (!chats.some((chat) => chat.id === chatId)) {
|
||||||
void loadChats();
|
void loadChats();
|
||||||
}
|
}
|
||||||
@@ -59,7 +66,7 @@ export function useRealtime() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return () => ws.close();
|
return () => ws.close();
|
||||||
}, [wsUrl, prependMessage, loadChats, chats, me?.id]);
|
}, [wsUrl, prependMessage, loadChats, chats, me?.id, activeChatId]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { getChats, getMessages } from "../api/chats";
|
import { getChats, getMessages, updateMessageStatus } from "../api/chats";
|
||||||
import type { Chat, Message } from "../chat/types";
|
import type { Chat, Message } from "../chat/types";
|
||||||
|
|
||||||
interface ChatState {
|
interface ChatState {
|
||||||
@@ -26,12 +26,17 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
setActiveChatId: (chatId) => set({ activeChatId: chatId }),
|
setActiveChatId: (chatId) => set({ activeChatId: chatId }),
|
||||||
loadMessages: async (chatId) => {
|
loadMessages: async (chatId) => {
|
||||||
const messages = await getMessages(chatId);
|
const messages = await getMessages(chatId);
|
||||||
|
const sorted = [...messages].reverse();
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messagesByChat: {
|
messagesByChat: {
|
||||||
...state.messagesByChat,
|
...state.messagesByChat,
|
||||||
[chatId]: [...messages].reverse()
|
[chatId]: sorted
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
const lastMessage = sorted[sorted.length - 1];
|
||||||
|
if (lastMessage) {
|
||||||
|
void updateMessageStatus(chatId, lastMessage.id, "message_read");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
prependMessage: (chatId, message) => {
|
prependMessage: (chatId, message) => {
|
||||||
const old = get().messagesByChat[chatId] ?? [];
|
const old = get().messagesByChat[chatId] ?? [];
|
||||||
|
|||||||
Reference in New Issue
Block a user