Files
glchat/internal/server/api_dm_icon.go
T
grendervill 0b55fbdfe2 feat(dm): ручки и события звонков в беседах (Фаза 7)
Звонки 1:1 и в групповых беседах до 10 участников поверх той же голосовой
инфраструктуры LiveKit: те же токены, что у серверных комнат, но без
серверных ролей — позвонить может участник беседы.

Ручки: POST /channels/{id}/call (создание, в ответе токен и адрес
сигналинга), accept, decline, end, PATCH /call/@me для флагов микрофона,
камеры и шаринга, GET /call (текущий звонок) и GET /calls (история: кто
звонил, когда, сколько длился, кто пропустил). Постороннему — 404,
существование чужой беседы не подтверждаем; в беседе больше десяти
участников звонить нельзя (dm.call_limit); выключенный голос отвечает
voice.disabled.

События: DM_CALL_RING приходит приглашённым, DM_CALL_UPDATE — участникам
при смене состояния, DM_CALL_END — с итогом и причиной. Звонок добавлен в
READY и в карточку беседы (active_call), поэтому «идёт звонок» переживает
перезагрузку. Вебхук LiveKit разбирает комнаты dm_call_* и синхронизирует
состояние, сторож голосовых состояний закрывает гудки без ответа и
опустевшие комнаты.

Тесты: жизненный цикл 1:1, отклонение и пропущенный, отказ постороннему
на всех ручках, предел десяти участников, поведение при выключенном
голосе, события Gateway и active_call в READY.
2026-09-26 17:02:55 +03:00

183 lines
7.3 KiB
Go

package server
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"glchat/internal/store"
)
// Иконка групповой беседы (AGENT.md 7.8, D-070): обычная загрузка с
// назначением dm_icon, поэтому анимированные GIF/APNG/WebP не перекодируются и
// не теряют анимацию (D-042). Менять иконку может только владелец беседы; у
// 1:1 своей иконки нет — в интерфейсе показывается аватар собеседника.
// registerDMIconRoutes описывает загрузку и снятие иконки беседы: huma не
// принимает multipart, поэтому ручки живут на роутере.
func (s *Server) registerDMIconRoutes(router chi.Router) {
router.Post("/channels/{channel_id}/icon", s.handleDMChannelIconUpload)
router.Delete("/channels/{channel_id}/icon", s.handleDMChannelIconDelete)
}
// groupDMForIcon проверяет, что канал — групповая беседа, а пользователь —
// её владелец. Посторонним отвечаем 404 (существование чужой беседы не
// подтверждаем, D-070), участнику-не-владельцу — 403, у 1:1 своей иконки нет.
func (s *Server) groupDMForIcon(ctx context.Context, rawChannelID string, userID uint64) (*store.Channel, error) {
channelID, err := parseID("channel_id", rawChannelID)
if err != nil {
return nil, err
}
channel, err := s.store.GetChannel(ctx, channelID)
if err != nil {
return nil, humaError(err)
}
if channel.Type != store.ChannelDM || channel.GuildID != nil {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found")
}
participant, err := s.store.IsDMParticipant(ctx, channelID, userID)
if err != nil {
return nil, humaError(err)
}
if !participant {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found")
}
if channel.DMOwnerID == nil {
// 1:1: иконка беседы — аватар собеседника, своей у неё нет.
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "dm.not_group",
"a direct conversation shows the recipient's avatar and has no icon")
}
if *channel.DMOwnerID != userID {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied",
"only the group owner can change the conversation icon")
}
return channel, nil
}
// handleDMChannelIconUpload принимает иконку групповой беседы (владелец).
func (s *Server) handleDMChannelIconUpload(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
channel, err := s.groupDMForIcon(ctx, chi.URLParam(r, "channel_id"), user.ID)
if err != nil {
writeHumaAPIError(w, err)
return
}
// Загрузки: 10 в минуту и 100 в сутки на пользователя (AGENT.md 8.6).
if err := s.checkUploadLimit(user); err != nil {
writeHumaAPIError(w, err)
return
}
// Иконка — медиа аватарного размера (AGENT.md 7.7): лимит тот же, что у
// аватаров и оформления. Тело ограничено и по объёму, и по памяти.
limit := s.mediaLimit(ctx, mediaAvatar)
r.Body = http.MaxBytesReader(w, r.Body, limit+maxMultipartOverhead)
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader выше
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
return
}
defer func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}()
file, header, err := r.FormFile("file")
if err != nil {
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
return
}
defer func() { _ = file.Close() }()
if header.Size > limit {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "conversation icon is too large")
return
}
// Картинку читаем в память: нужно убедиться, что это изображение, а не
// переименованный файл (AGENT.md 9.2).
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil || int64(len(data)) > limit {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "conversation icon is too large")
return
}
contentType := header.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
contentType = http.DetectContentType(data)
}
if !strings.HasPrefix(contentType, "image/") {
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "conversation icon must be an image")
return
}
previous := channel.IconFileID
channelID := channel.ID
stored, err := s.saveUpload(ctx, store.File{
UploaderID: &user.ID,
ChannelID: &channelID,
Filename: sanitizeFilename(header.Filename),
ContentType: contentType,
Purpose: "dm_icon",
}, bytes.NewReader(data))
if err != nil {
writeHumaAPIError(w, err)
return
}
fileID := stored.ID
updated, err := s.store.UpdateChannel(ctx, channel.ID, store.UpdateChannelParams{IconFileID: &fileID})
if err != nil {
// Новая иконка не прижилась: убираем загруженный файл, старая остаётся.
s.deleteStoredFile(ctx, stored.ID)
writeHumaAPIError(w, humaError(err))
return
}
// Предыдущий файл удаляем только после успешной замены: загрузки не должны
// копиться (AGENT.md 7.7), но и терять старую иконку при сбое нельзя.
if previous != nil && *previous != 0 {
s.deleteStoredFile(ctx, *previous)
}
s.dispatchDMUpdate(updated, []uint64{}, 0)
s.writeDMChannel(ctx, w, updated, user.ID)
}
// handleDMChannelIconDelete снимает иконку групповой беседы (владелец).
func (s *Server) handleDMChannelIconDelete(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
channel, err := s.groupDMForIcon(ctx, chi.URLParam(r, "channel_id"), user.ID)
if err != nil {
writeHumaAPIError(w, err)
return
}
previous := channel.IconFileID
updated, err := s.store.UpdateChannel(ctx, channel.ID, store.UpdateChannelParams{ClearIcon: true})
if err != nil {
writeHumaAPIError(w, humaError(err))
return
}
if previous != nil && *previous != 0 {
s.deleteStoredFile(ctx, *previous)
}
s.dispatchDMUpdate(updated, []uint64{}, 0)
s.writeDMChannel(ctx, w, updated, user.ID)
}
// writeDMChannel отдаёт обновлённую беседу в формате `dmChannelPayload` — так
// же, как переименование: клиенту не нужен отдельный запрос за иконкой.
func (s *Server) writeDMChannel(ctx context.Context, w http.ResponseWriter, channel *store.Channel, viewerID uint64) {
summary, err := s.groupDMSummary(ctx, channel, viewerID)
if err != nil {
writeHumaAPIError(w, err)
return
}
httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": s.dmChannelPayload(ctx, summary)})
}