feat(room): очередь поднятых рук видна всем + отключаемый модуль

Раньше HandQueueMenu.tsx рендерился только организатору — теперь очередь
видит любой участник, но опустить чужую руку по-прежнему может только
организатор (сервер это уже проверял, менял только фронт). Кнопка
«Опустить» показывается у записи, только если это своя рука или
пользователь — организатор.

Модуль «поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый
в админке (instance_settings.hand_queue, дефолт enabled=true, как у
chat_enabled). Настройка едет участнику в JoinOut ещё до входа в
комнату; выключенный модуль гасит кнопки и на фронте, и на бэке —
raise_hand/lower_hand отклоняются кодом hand_queue_disabled, если
модуль выключен, даже если у клиента на руках старый JoinOut.
This commit is contained in:
2026-08-04 18:27:14 +03:00
parent 65fbcf952c
commit 10a3f8b3b4
21 changed files with 274 additions and 62 deletions

View File

@@ -148,6 +148,15 @@ class ChatService:
raise WrongRoomError
return conference
async def hand_queue_enabled(self) -> bool:
"""Тоггл инстанса `hand_queue.enabled` — снимается один раз при подключении WS
(см. `api/chat.py::chat_websocket`), а не на каждое сообщение: та же
осознанная «застылость» на время жизни соединения, что и у
`is_organizer` в `_pump_websocket_to_service` — переключение модуля
администратором применяется со следующего подключения."""
cfg = await InstanceSettingsService(self._session).get()
return cfg.hand_queue.enabled
async def history(self, conference: Conference) -> list[ChatMessageOut]:
"""Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет)."""
session_record = await self._sessions.get_open_by_conference(conference.id)

View File

@@ -53,6 +53,7 @@ def build_join(
identity: str,
name: str,
chat_enabled: bool,
hand_queue_enabled: bool,
publish_quality_cap: PublishQualityCap,
stage_max_tiles: StageMaxTiles,
avatar_url: str | None = None,
@@ -61,9 +62,10 @@ def build_join(
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
`chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей
стороной значения `instance_settings`: читаются здесь параметрами, а не
заново из БД, чтобы не плодить отдельный запрос настроек на каждый join.
`chat_enabled`/`hand_queue_enabled`/`publish_quality_cap`/`stage_max_tiles`
— снятые вызывающей стороной значения `instance_settings`: читаются здесь
параметрами, а не заново из БД, чтобы не плодить отдельный запрос настроек
на каждый join.
`avatar_url`/`is_organizer`
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
— поля добавляются, только если заданы (гость без аватара и не-организатор
@@ -91,6 +93,7 @@ def build_join(
room_name=conference.slug,
conference_id=conference.id,
chat_enabled=chat_enabled,
hand_queue_enabled=hand_queue_enabled,
publish_quality_cap=publish_quality_cap,
stage_max_tiles=stage_max_tiles,
)

View File

@@ -155,6 +155,7 @@ class ConferenceService:
identity=str(owner_id),
name=owner_name,
chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
@@ -251,6 +252,7 @@ class ConferenceService:
identity=str(user.id),
name=user.name_user,
chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
@@ -275,6 +277,7 @@ class ConferenceService:
identity=f"guest:{guest.id}",
name=data.display_name,
chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
)

View File

@@ -1,7 +1,7 @@
"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап.
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`,
`hand_queue`, `ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`, `contact_email`,
`media_limits`) —
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
@@ -28,6 +28,7 @@ from core.config import Settings
from core.plugins.config import (
AiLevel,
ChatConfig,
HandQueueConfig,
InstanceConfig,
MediaLimitsConfig,
PluginsConfig,
@@ -45,6 +46,7 @@ from services.ai_tiers import TIERS
_KEY_TRANSCRIBER = "transcriber"
_KEY_SUMMARIZER = "summarizer"
_KEY_CHAT = "chat"
_KEY_HAND_QUEUE = "hand_queue"
_KEY_AI_LEVEL = "ai_level"
_KEY_SUMMARY_RECIPIENTS = "summary_recipients"
_KEY_DISPLAY_TIMEZONE = "display_timezone"
@@ -100,6 +102,7 @@ class SettingsUpdateIn(BaseModel):
"""
chat_enabled: bool | None = None
hand_queue_enabled: bool | None = None
transcription_enabled: bool | None = None
ai_level: AiLevel | None = None
summary_recipients: SummaryRecipientsMode | None = None
@@ -154,6 +157,7 @@ def build_bootstrap_defaults(
_KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"),
_KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"),
_KEY_CHAT: plugins.chat.model_dump(mode="json"),
_KEY_HAND_QUEUE: plugins.hand_queue.model_dump(mode="json"),
_KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE),
_KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE),
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
@@ -286,6 +290,10 @@ class InstanceSettingsService:
cfg.chat = ChatConfig(enabled=patch.chat_enabled)
await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json"))
if patch.hand_queue_enabled is not None:
cfg.hand_queue = HandQueueConfig(enabled=patch.hand_queue_enabled)
await self._set(_KEY_HAND_QUEUE, cfg.hand_queue.model_dump(mode="json"))
if patch.registration_team_choice is not None:
cfg.registration_team_choice = patch.registration_team_choice
await self._set(
@@ -399,6 +407,7 @@ async def load_effective_config(session: AsyncSession) -> InstanceConfig:
transcriber=plugins.transcriber,
summarizer=plugins.summarizer,
chat=plugins.chat,
hand_queue=plugins.hand_queue,
)
else:
cfg = _build_config(rows)
@@ -492,6 +501,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})),
summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})),
chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})),
hand_queue=HandQueueConfig.model_validate(rows.get(_KEY_HAND_QUEUE, {})),
ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"),
summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get(
"mode", "all"