Перейти к содержанию

Chat Module

Real-time чат с WebSocket, PubSub и интеграцией с внешними мессенджерами.

Архитектура

graph TB
    subgraph Clients
        B1[Browser 1]
        B2[Browser 2]
    end

    subgraph "FastAPI Worker 1"
        WS1[WebSocket Handler]
        CM1[ConnectionManager]
    end

    subgraph "FastAPI Worker 2"
        WS2[WebSocket Handler]
        CM2[ConnectionManager]
    end

    B1 <-->|WebSocket| WS1
    B2 <-->|WebSocket| WS2
    CM1 <-->|PubSub| PG[(PostgreSQL<br>LISTEN/NOTIFY)]
    CM2 <-->|PubSub| PG

    style PG fill:#336791,color:white

Cross-process messaging

Сообщения проходят через PostgreSQL LISTEN/NOTIFY (или Redis Pub/Sub) — это гарантирует доставку между всеми worker-процессами.

Типы чатов

Тип Описание Права по умолчанию
direct Личный чат 1-на-1 Чтение, запись, pin
group Групповой чат Чтение, запись
channel Канал (только админы пишут) Только чтение
record Чат привязанный к записи CRM Чтение, запись

API — Messages

Отправка сообщения

POST /chats/{chat_id}/messages

{
    "body": "Hello, World!",
    "attachments": [],
    "reply_to_id": null
}

Получение сообщений

GET /chats/{chat_id}/messages?limit=50&before_id=100

Пагинация курсором: before_id — загрузить сообщения старше указанного ID.

Редактирование

PATCH /chats/{chat_id}/messages/{message_id}

{
    "body": "Edited message text"
}

Удаление

DELETE /chats/{chat_id}/messages/{message_id}

Soft delete — is_deleted = true.

Pin / Unpin

POST /chats/{chat_id}/messages/{message_id}/pin

{"pinned": true}

Реакции

POST /chats/{chat_id}/messages/{message_id}/reactions

{"emoji": "👍"}

Повторный вызов с тем же emoji — toggle (убирает реакцию).

WebSocket Events

Клиент подключается к /ws и получает события:

// Подключение
const ws = new WebSocket(`wss://api.fara.dev/ws?token=${token}`);

ws.onmessage = (event) => {
    const data = JSON.parse(event.data);

    switch (data.type) {
        case "new_message":
            // Новое сообщение
            addMessage(data.chat_id, data.message);
            break;

        case "message_edited":
            updateMessage(data.message_id, data.body);
            break;

        case "message_deleted":
            removeMessage(data.message_id);
            break;

        case "message_pinned":
            togglePin(data.message_id, data.pinned);
            break;

        case "reaction_update":
            updateReaction(data.message_id, data.reactions);
            break;
    }
};

PubSub — Strategy Pattern

PubSub backend выбирается через .env:

.env
PUBSUB__BACKEND=pg

Использует LISTEN/NOTIFY. Просто, без доп. инфраструктуры.

.env
PUBSUB__BACKEND=redis
PUBSUB__REDIS_URL=redis://localhost:6379/0

Выше throughput, не занимает соединение из asyncpg pool.

Переключение backend'а не требует изменения кода — Strategy pattern:

backend/base/crm/chat/websocket/pubsub/
# pubsub/
# ├── __init__.py      # create_pubsub_backend() factory
# ├── base.py          # PubSubBackend (abstract)
# ├── pg_backend.py    # PostgreSQL LISTEN/NOTIFY
# └── redis_backend.py # Redis Pub/Sub

Модели

Chat

class Chat(DotModel):
    __table__ = "chats"

    name: str = Char(max_length=255, required=True)
    chat_type: str = Selection(
        options=[
            ("direct", "Личный"), ("group", "Группа"),
            ("channel", "Канал"), ("record", "Чат записи"),
        ],
        default="group",
    )
    creator_id: "User" = Many2one["User"](relation_table="users")
    is_archived: bool = Boolean(default=False)

ChatMember

class ChatMember(DotModel):
    __table__ = "chat_members"

    chat_id: "Chat" = Many2one["Chat"](relation_table="chats", required=True)
    user_id: "User" = Many2one["User"](relation_table="users", required=True)
    is_active: bool = Boolean(default=True)
    is_admin: bool = Boolean(default=False)
    can_read: bool = Boolean(default=True)
    can_write: bool = Boolean(default=True)
    can_pin: bool = Boolean(default=False)
    can_invite: bool = Boolean(default=False)
    can_delete_others: bool = Boolean(default=False)

Проверка прав

# Shortcut-методы на ChatMember
await ChatMember.check_can_write(chat_id, user_id)
await ChatMember.check_can_pin(chat_id, user_id)
await ChatMember.check_admin(chat_id, user_id)

# Под капотом:
member = await ChatMember.check_membership(chat_id, user_id)  # или 403
if not member.has_permission("can_pin"):
    raise FaraException("PERMISSION_DENIED")