f9464130cd
Беседа на несколько участников живёт в тех же таблицах, что и 1:1
(channels.type = 'dm' + dm_participants), добавляется только владелец
(channels.dm_owner_id, миграция 00020).
Сервер: POST /users/@me/channels/group (2–9 приглашённых, имя необязательно —
собирается из имён), PUT/DELETE /channels/{id}/recipients/{user_id}
(добавляет любой участник, удаляет других и переименовывает только владелец,
выйти может каждый сам), PATCH /channels/{id}. Правила состава: группа — это
3+ участника; при двух беседа снова обычная личная (имя и владелец
сбрасываются), при одном — удаляется вместе с перепиской. Заблокированного
нельзя ни пригласить, ни добавить; посторонним беседа не видна (404).
READY и REST отдают is_group, name, member_count, owner_id и состав;
участники получают DM_CHANNEL_CREATE/DM_CHANNEL_UPDATE, удалённый —
DM_CHANNEL_DELETE.
Клиент: группа в сайдбаре с числом участников (без точки чужого статуса),
шапка беседы с числом участников и именами в подсказке, создание группы из
списка друзей (минимум двое) с переходом в новую беседу.
Тесты: Go — полный жизненный цикл (создание, состав у каждого участника,
добавление, запрет посторонним, удаление владельцем, выход, превращение в
1:1, удаление последней беседы), валидация (меньше трёх, дубликаты,
заблокированный) и группа в READY; web — 4 vitest (сайдбар, шапка, создание,
выключенная кнопка).
1006 lines
35 KiB
Go
1006 lines
35 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
"glchat/internal/store"
|
|
)
|
|
|
|
// relationshipUser — профиль собеседника вместе с типом связи (AGENT.md 7.8).
|
|
type relationshipUser struct {
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
|
Status string `json:"status"`
|
|
CustomStatus string `json:"custom_status,omitempty"`
|
|
IsInstanceAdmin bool `json:"is_instance_admin"`
|
|
Badges []string `json:"badges"`
|
|
// VisibleStatus — статус, который видят другие: «невидимка» выглядит как
|
|
// офлайн, а без активности дольше двух минут показываем офлайн.
|
|
VisibleStatus string `json:"visible_status"`
|
|
LastSeenAt string `json:"last_seen_at,omitempty"`
|
|
Timezone string `json:"timezone"`
|
|
// Relationship: friend | incoming | outgoing | blocked | none
|
|
Relationship string `json:"relationship"`
|
|
// Cosmetics — оформление «для друзей»: в DM и списке друзей применяется
|
|
// именно оно (AGENT.md 7.2).
|
|
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
|
|
}
|
|
|
|
type relationshipListOutput struct {
|
|
Body struct {
|
|
Friends []relationshipUser `json:"friends"`
|
|
Incoming []relationshipUser `json:"incoming"`
|
|
Outgoing []relationshipUser `json:"outgoing"`
|
|
Blocked []relationshipUser `json:"blocked"`
|
|
}
|
|
}
|
|
|
|
type userSearchOutput struct {
|
|
Body struct {
|
|
Users []relationshipUser `json:"users"`
|
|
}
|
|
}
|
|
|
|
type dmChannelPayload struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
CanSend bool `json:"can_send"`
|
|
CanView bool `json:"can_view"`
|
|
Recipient struct {
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
|
Status string `json:"status"`
|
|
VisibleStatus string `json:"visible_status"`
|
|
LastSeenAt string `json:"last_seen_at,omitempty"`
|
|
Timezone string `json:"timezone"`
|
|
} `json:"recipient"`
|
|
LastMessageID string `json:"last_message_id,omitempty"`
|
|
LastMessageAt string `json:"last_message_at,omitempty"`
|
|
// Групповая беседа (AGENT.md 7.8): имя, участники и владелец. У 1:1
|
|
// is_group = false, а данные собеседника лежат в recipient.
|
|
IsGroup bool `json:"is_group"`
|
|
Name string `json:"name,omitempty"`
|
|
MemberCount int `json:"member_count,omitempty"`
|
|
OwnerID string `json:"owner_id,omitempty"`
|
|
Recipients []dmRecipientPayload `json:"recipients,omitempty"`
|
|
}
|
|
|
|
// dmRecipientPayload — участник групповой беседы.
|
|
type dmRecipientPayload struct {
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
|
Status string `json:"status"`
|
|
VisibleStatus string `json:"visible_status"`
|
|
IsOwner bool `json:"is_owner"`
|
|
IsSelf bool `json:"is_self"`
|
|
}
|
|
|
|
type dmChannelListOutput struct {
|
|
Body struct {
|
|
Channels []dmChannelPayload `json:"channels"`
|
|
}
|
|
}
|
|
|
|
type dmChannelOutput struct {
|
|
Body struct {
|
|
Channel dmChannelPayload `json:"channel"`
|
|
}
|
|
}
|
|
|
|
// registerSocialRoutes описывает друзей, поиск пользователей и личные беседы
|
|
// (AGENT.md 7.8; раздел запрошен пользователем вне очереди).
|
|
func (s *Server) registerSocialRoutes(api huma.API) {
|
|
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "searchUsers",
|
|
Method: http.MethodGet,
|
|
Path: "/users/search",
|
|
Summary: "Поиск пользователей по логину",
|
|
Tags: []string{"Friends"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
Query string `query:"q" minLength:"2" maxLength:"32"`
|
|
Limit int `query:"limit" default:"20" minimum:"1" maximum:"50"`
|
|
},
|
|
) (*userSearchOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
users, err := s.store.SearchUsersByUsername(ctx, strings.TrimSpace(input.Query), user.ID, input.Limit)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
output := &userSearchOutput{}
|
|
output.Body.Users = make([]relationshipUser, 0, len(users))
|
|
for i := range users {
|
|
payload, err := s.relationshipUser(ctx, user.ID, &users[i])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
output.Body.Users = append(output.Body.Users, payload)
|
|
}
|
|
return output, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "listRelationships",
|
|
Method: http.MethodGet,
|
|
Path: "/users/@me/relationships",
|
|
Summary: "Друзья, входящие и исходящие заявки",
|
|
Tags: []string{"Friends"},
|
|
Security: security,
|
|
}, func(ctx context.Context, _ *struct{}) (*relationshipListOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
output := &relationshipListOutput{}
|
|
output.Body.Friends, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipFriend)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if output.Body.Incoming, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipIncoming); err != nil {
|
|
return nil, err
|
|
}
|
|
if output.Body.Outgoing, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipOutgoing); err != nil {
|
|
return nil, err
|
|
}
|
|
if output.Body.Blocked, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipBlocked); err != nil {
|
|
return nil, err
|
|
}
|
|
return output, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "sendFriendRequest",
|
|
Method: http.MethodPost,
|
|
Path: "/users/@me/relationships",
|
|
Summary: "Отправить заявку в друзья по логину",
|
|
Tags: []string{"Friends"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
Body struct {
|
|
Username string `json:"username,omitempty" maxLength:"32"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
}
|
|
},
|
|
) (*okOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
target, err := s.relationshipTarget(ctx, input.Body.UserID, input.Body.Username, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Блокировка сильнее заявки: переписка и дружба с ней невозможны.
|
|
if blocked, err := s.store.IsBlocked(ctx, user.ID, target.ID); err != nil {
|
|
return nil, humaError(err)
|
|
} else if blocked {
|
|
return nil, humaErrorStatus(http.StatusForbidden, "relationship.blocked", "user is blocked")
|
|
}
|
|
// Если встречная заявка уже есть — сразу становимся друзьями.
|
|
reverse, err := s.store.GetRelationship(ctx, target.ID, user.ID)
|
|
switch {
|
|
case err == nil && reverse.Type == store.RelationshipIncoming:
|
|
if err := s.makeFriends(ctx, user.ID, target.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
return newOKOutput(), nil
|
|
case err == nil && reverse.Type == store.RelationshipFriend:
|
|
return newOKOutput(), nil
|
|
case err != nil && !errors.Is(err, store.ErrNotFound):
|
|
return nil, humaError(err)
|
|
}
|
|
if err := s.store.SetRelationship(ctx, user.ID, target.ID, store.RelationshipOutgoing); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
if err := s.store.SetRelationship(ctx, target.ID, user.ID, store.RelationshipIncoming); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
s.dispatchRelationshipUpdate(user.ID, target.ID)
|
|
return newOKOutput(), nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "acceptFriendRequest",
|
|
Method: http.MethodPost,
|
|
Path: "/users/@me/relationships/{user_id}/accept",
|
|
Summary: "Принять заявку в друзья",
|
|
Tags: []string{"Friends"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
UserID string `path:"user_id"`
|
|
},
|
|
) (*okOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
incoming, err := s.store.GetRelationship(ctx, user.ID, targetID)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
if incoming.Type != store.RelationshipIncoming {
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "no incoming friend request from this user")
|
|
}
|
|
if err := s.makeFriends(ctx, user.ID, targetID); err != nil {
|
|
return nil, err
|
|
}
|
|
return newOKOutput(), nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "removeRelationship",
|
|
Method: http.MethodDelete,
|
|
Path: "/users/@me/relationships/{user_id}",
|
|
Summary: "Удалить из друзей, отклонить или отменить заявку",
|
|
Tags: []string{"Friends"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
UserID string `path:"user_id"`
|
|
},
|
|
) (*okOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.store.RemoveRelationship(ctx, user.ID, targetID); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
if err := s.store.RemoveRelationship(ctx, targetID, user.ID); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
s.dispatchRelationshipUpdate(user.ID, targetID)
|
|
return newOKOutput(), nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "blockUser",
|
|
Method: http.MethodPut,
|
|
Path: "/users/@me/blocks/{user_id}",
|
|
Summary: "Заблокировать пользователя",
|
|
Tags: []string{"Friends"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
UserID string `path:"user_id"`
|
|
},
|
|
) (*okOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if targetID == user.ID {
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot block yourself")
|
|
}
|
|
if _, err := s.store.GetUser(ctx, targetID); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
// Блокировка снимает дружбу и заявки в обе стороны (AGENT.md 7.2).
|
|
if err := s.store.BlockUser(ctx, user.ID, targetID); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
s.dispatchRelationshipUpdate(user.ID, targetID)
|
|
return newOKOutput(), nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "unblockUser",
|
|
Method: http.MethodDelete,
|
|
Path: "/users/@me/blocks/{user_id}",
|
|
Summary: "Снять блокировку",
|
|
Tags: []string{"Friends"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
UserID string `path:"user_id"`
|
|
},
|
|
) (*okOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.store.UnblockUser(ctx, user.ID, targetID); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
s.dispatchRelationshipUpdate(user.ID, targetID)
|
|
return newOKOutput(), nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "openDirectChannel",
|
|
Method: http.MethodPost,
|
|
Path: "/users/@me/channels",
|
|
Summary: "Открыть личную беседу с пользователем",
|
|
Tags: []string{"Friends"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
Body struct {
|
|
RecipientID string `json:"recipient_id" minLength:"1"`
|
|
}
|
|
},
|
|
) (*dmChannelOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
recipientID, err := parseID("recipient_id", input.Body.RecipientID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if recipientID == user.ID {
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot open a direct channel with yourself")
|
|
}
|
|
recipient, err := s.store.GetUser(ctx, recipientID)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
// С заблокированным беседа не открывается (AGENT.md 7.2).
|
|
if blocked, err := s.store.IsBlocked(ctx, user.ID, recipientID); err != nil {
|
|
return nil, humaError(err)
|
|
} else if blocked {
|
|
return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked")
|
|
}
|
|
channel, err := s.store.FindDMChannel(ctx, user.ID, recipientID)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
channel, err = s.store.CreateDMChannel(ctx, user.ID, recipientID)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
// Оба участника сразу видят беседу в списке (AGENT.md 8.3).
|
|
for _, participant := range []uint64{user.ID, recipientID} {
|
|
if s.gateway != nil {
|
|
s.gateway.SendToUser(participant, "DM_CHANNEL_CREATE", map[string]any{
|
|
"channel_id": formatSnowflake(channel.ID),
|
|
})
|
|
}
|
|
}
|
|
} else if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
|
|
summary := store.DMChannelSummary{
|
|
Channel: *channel,
|
|
RecipientID: recipient.ID,
|
|
RecipientName: recipient.DisplayName,
|
|
RecipientLogin: recipient.Username,
|
|
AvatarFileID: recipient.AvatarFileID,
|
|
Status: recipient.Status,
|
|
LastSeenAt: recipient.LastSeenAt,
|
|
Timezone: recipient.Timezone,
|
|
}
|
|
output := &dmChannelOutput{}
|
|
output.Body.Channel = s.dmChannelPayload(summary)
|
|
return output, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "listDirectChannels",
|
|
Method: http.MethodGet,
|
|
Path: "/users/@me/channels",
|
|
Summary: "Личные беседы пользователя",
|
|
Tags: []string{"Friends"},
|
|
Security: security,
|
|
}, func(ctx context.Context, _ *struct{}) (*dmChannelListOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
channels, err := s.store.ListDMChannels(ctx, user.ID)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
output := &dmChannelListOutput{}
|
|
output.Body.Channels = make([]dmChannelPayload, 0, len(channels))
|
|
for _, summary := range channels {
|
|
output.Body.Channels = append(output.Body.Channels, s.dmChannelPayload(summary))
|
|
}
|
|
return output, nil
|
|
})
|
|
|
|
// Групповые личные беседы (AGENT.md 7.8, Фаза 7): создание, добавление и
|
|
// выход участников, переименование. Права простые и явные: добавлять может
|
|
// любой участник, удалять других и переименовывать — владелец, выйти может
|
|
// каждый сам.
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "createGroupDirectChannel",
|
|
Method: http.MethodPost,
|
|
Path: "/users/@me/channels/group",
|
|
Summary: "Создать групповую беседу",
|
|
Tags: []string{"Friends"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
Body struct {
|
|
Name string `json:"name,omitempty" maxLength:"64"`
|
|
RecipientIDs []string `json:"recipient_ids"`
|
|
}
|
|
},
|
|
) (*dmChannelOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
members, err := s.groupDMMembers(ctx, user.ID, input.Body.RecipientIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Группа — это минимум три участника: двое и меньше остаются обычной
|
|
// личной беседой (см. store.RemoveDMParticipant).
|
|
if len(members) < 2 {
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed",
|
|
"a group conversation needs at least two other members")
|
|
}
|
|
name := strings.TrimSpace(input.Body.Name)
|
|
if name == "" {
|
|
name = s.defaultGroupDMName(ctx, members)
|
|
}
|
|
if len([]rune(name)) > maxGroupDMNameLength {
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "group name is too long")
|
|
}
|
|
channel, err := s.store.CreateGroupDMChannel(ctx, user.ID, name, members)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
s.dispatchDMCreate(channel.ID, append([]uint64{user.ID}, members...))
|
|
summary, err := s.groupDMSummary(ctx, channel, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
output := &dmChannelOutput{}
|
|
output.Body.Channel = s.dmChannelPayload(summary)
|
|
return output, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "addDirectChannelRecipient",
|
|
Method: http.MethodPut,
|
|
Path: "/channels/{channel_id}/recipients/{user_id}",
|
|
Summary: "Добавить участника в групповую беседу",
|
|
Tags: []string{"Friends"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
ChannelID string `path:"channel_id"`
|
|
UserID string `path:"user_id"`
|
|
},
|
|
) (*dmChannelOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if targetID == user.ID {
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you are already a member")
|
|
}
|
|
if _, err := s.store.GetUser(ctx, targetID); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
// Блокировка запрещает и добавление в общую беседу (AGENT.md 7.2).
|
|
if blocked, err := s.store.IsBlocked(ctx, user.ID, targetID); err != nil {
|
|
return nil, humaError(err)
|
|
} else if blocked {
|
|
return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked")
|
|
}
|
|
count, err := s.store.DMParticipantCount(ctx, channel.ID)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
if count >= maxGroupDMMembers {
|
|
return nil, humaErrorStatus(http.StatusConflict, "dm.members_limit", "group conversation is full")
|
|
}
|
|
if err := s.store.AddDMParticipant(ctx, channel.ID, targetID); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
// Новый участник получает событие создания беседы, остальные — обновление
|
|
// состава (AGENT.md 8.3).
|
|
if s.gateway != nil {
|
|
s.gateway.SendToUser(targetID, "DM_CHANNEL_CREATE", map[string]any{
|
|
"channel_id": formatSnowflake(channel.ID),
|
|
})
|
|
}
|
|
s.dispatchDMUpdate(channel, []uint64{}, targetID)
|
|
summary, err := s.groupDMSummary(ctx, channel, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
output := &dmChannelOutput{}
|
|
output.Body.Channel = s.dmChannelPayload(summary)
|
|
return output, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "removeDirectChannelRecipient",
|
|
Method: http.MethodDelete,
|
|
Path: "/channels/{channel_id}/recipients/{user_id}",
|
|
Summary: "Выйти из групповой беседы или удалить участника",
|
|
Tags: []string{"Friends"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
ChannelID string `path:"channel_id"`
|
|
UserID string `path:"user_id"`
|
|
},
|
|
) (*okOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if targetID != user.ID {
|
|
// Удалять других может только владелец беседы.
|
|
if channel.DMOwnerID == nil || *channel.DMOwnerID != user.ID {
|
|
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied",
|
|
"only the group owner can remove other members")
|
|
}
|
|
member, err := s.store.IsDMParticipant(ctx, channel.ID, targetID)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
if !member {
|
|
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "user is not a member of this conversation")
|
|
}
|
|
}
|
|
if err := s.store.RemoveDMParticipant(ctx, channel.ID, targetID); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
// Удалённый (или вышедший) теряет доступ: ему уходит CHANNEL_DELETE,
|
|
// остальным — обновление состава.
|
|
if s.gateway != nil {
|
|
s.gateway.SendToUser(targetID, "DM_CHANNEL_DELETE", map[string]any{
|
|
"channel_id": formatSnowflake(channel.ID),
|
|
})
|
|
}
|
|
s.dispatchDMUpdate(channel, []uint64{targetID}, 0)
|
|
return newOKOutput(), nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "renameDirectChannel",
|
|
Method: http.MethodPatch,
|
|
Path: "/channels/{channel_id}",
|
|
Summary: "Переименовать групповую беседу (владелец)",
|
|
Tags: []string{"Friends"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
ChannelID string `path:"channel_id"`
|
|
Body struct {
|
|
Name string `json:"name" maxLength:"64"`
|
|
}
|
|
},
|
|
) (*dmChannelOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if channel.DMOwnerID == nil || *channel.DMOwnerID != user.ID {
|
|
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "only the group owner can rename the conversation")
|
|
}
|
|
name := strings.TrimSpace(input.Body.Name)
|
|
if name == "" || len([]rune(name)) > maxGroupDMNameLength {
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "group name must be 1-64 characters")
|
|
}
|
|
if err := s.store.RenameDMChannel(ctx, channel.ID, name); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
channel.Name = name
|
|
s.dispatchDMUpdate(channel, []uint64{}, 0)
|
|
summary, err := s.groupDMSummary(ctx, channel, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
output := &dmChannelOutput{}
|
|
output.Body.Channel = s.dmChannelPayload(summary)
|
|
return output, nil
|
|
})
|
|
}
|
|
|
|
// Пределы групповой беседы (AGENT.md 7.8): не больше десяти участников и
|
|
// 64 символа в названии — группа не должна превращаться в сервер.
|
|
const (
|
|
maxGroupDMMembers = 10
|
|
maxGroupDMNameLength = 64
|
|
)
|
|
|
|
// groupDMMembers проверяет список приглашённых: пользователи существуют, это
|
|
// не сам приглашающий, никто не заблокирован.
|
|
func (s *Server) groupDMMembers(ctx context.Context, selfID uint64, rawIDs []string) ([]uint64, error) {
|
|
if len(rawIDs) > maxGroupDMMembers-1 {
|
|
return nil, humaErrorStatus(http.StatusConflict, "dm.members_limit", "group conversation is full")
|
|
}
|
|
seen := map[uint64]bool{}
|
|
members := make([]uint64, 0, len(rawIDs))
|
|
for _, rawID := range rawIDs {
|
|
targetID, err := parseID("recipient_ids", rawID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if targetID == selfID || seen[targetID] {
|
|
continue
|
|
}
|
|
if _, err := s.store.GetUser(ctx, targetID); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
if blocked, err := s.store.IsBlocked(ctx, selfID, targetID); err != nil {
|
|
return nil, humaError(err)
|
|
} else if blocked {
|
|
return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked")
|
|
}
|
|
seen[targetID] = true
|
|
members = append(members, targetID)
|
|
}
|
|
return members, nil
|
|
}
|
|
|
|
// defaultGroupDMName собирает имя по участникам: «Алиса, Боб и ещё 2».
|
|
func (s *Server) defaultGroupDMName(ctx context.Context, members []uint64) string {
|
|
names := make([]string, 0, len(members))
|
|
for _, memberID := range members {
|
|
member, err := s.store.GetUser(ctx, memberID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
name := member.DisplayName
|
|
if name == "" {
|
|
name = member.Username
|
|
}
|
|
names = append(names, name)
|
|
}
|
|
if len(names) == 0 {
|
|
return "Групповая беседа"
|
|
}
|
|
// В имя попадает не больше двух имён: длинный список обрезается.
|
|
if len(names) > 2 {
|
|
return fmt.Sprintf("%s, %s и ещё %d", names[0], names[1], len(names)-2)
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
// groupDMForMember проверяет, что канал — личная беседа, а пользователь в ней
|
|
// состоит. Посторонним отвечаем 404: существование чужой беседы не подтверждаем.
|
|
func (s *Server) groupDMForMember(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")
|
|
}
|
|
return channel, nil
|
|
}
|
|
|
|
// groupDMSummary собирает карточку групповой беседы для ответа API.
|
|
func (s *Server) groupDMSummary(ctx context.Context, channel *store.Channel, viewerID uint64) (store.DMChannelSummary, error) {
|
|
participants, err := s.store.DMParticipantProfiles(ctx, channel.ID, viewerID)
|
|
if err != nil {
|
|
return store.DMChannelSummary{}, humaError(err)
|
|
}
|
|
ownerID := channel.DMOwnerID
|
|
return store.DMChannelSummary{
|
|
Channel: *channel,
|
|
IsGroup: true,
|
|
GroupName: channel.Name,
|
|
MemberCount: len(participants),
|
|
Participants: participants,
|
|
OwnerID: ownerID,
|
|
}, nil
|
|
}
|
|
|
|
// dispatchDMCreate сообщает участникам о новой беседе (AGENT.md 8.3).
|
|
func (s *Server) dispatchDMCreate(channelID uint64, userIDs []uint64) {
|
|
if s.gateway == nil {
|
|
return
|
|
}
|
|
for _, userID := range userIDs {
|
|
s.gateway.SendToUser(userID, "DM_CHANNEL_CREATE", map[string]any{
|
|
"channel_id": formatSnowflake(channelID),
|
|
})
|
|
}
|
|
}
|
|
|
|
// dispatchDMUpdate сообщает участникам об изменении состава или имени: сам
|
|
// канал отдаётся в событии, чтобы клиент не делал лишний запрос.
|
|
func (s *Server) dispatchDMUpdate(channel *store.Channel, except []uint64, include uint64) {
|
|
if s.gateway == nil {
|
|
return
|
|
}
|
|
participants, err := s.store.DMParticipants(context.Background(), channel.ID)
|
|
if err != nil {
|
|
s.logger.WarnContext(context.Background(), "не удалось прочитать участников беседы",
|
|
slog.Any("error", err))
|
|
return
|
|
}
|
|
skip := map[uint64]bool{}
|
|
for _, userID := range except {
|
|
skip[userID] = true
|
|
}
|
|
recipients := make([]uint64, 0, len(participants)+1)
|
|
for _, userID := range participants {
|
|
if !skip[userID] {
|
|
recipients = append(recipients, userID)
|
|
}
|
|
}
|
|
if include != 0 {
|
|
recipients = append(recipients, include)
|
|
}
|
|
payload := map[string]any{"channel_id": formatSnowflake(channel.ID)}
|
|
for _, userID := range recipients {
|
|
s.gateway.SendToUser(userID, "DM_CHANNEL_UPDATE", payload)
|
|
}
|
|
}
|
|
|
|
// relationshipTarget находит пользователя по id или логину.
|
|
func (s *Server) relationshipTarget(ctx context.Context, rawID, username string, selfID uint64) (*store.User, error) {
|
|
if rawID != "" {
|
|
targetID, err := parseID("user_id", rawID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if targetID == selfID {
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot add yourself as a friend")
|
|
}
|
|
target, err := s.store.GetUser(ctx, targetID)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
return target, nil
|
|
}
|
|
username = strings.TrimSpace(username)
|
|
if username == "" {
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "username or user_id is required")
|
|
}
|
|
target, err := s.store.GetUserByUsername(ctx, username)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, humaErrorStatus(http.StatusNotFound, "user.not_found", "пользователь с таким логином не найден")
|
|
}
|
|
return nil, humaError(err)
|
|
}
|
|
if target.ID == selfID {
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot add yourself as a friend")
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
// makeFriends делает пользователей друзьями в обе стороны.
|
|
func (s *Server) makeFriends(ctx context.Context, firstID, secondID uint64) error {
|
|
if err := s.store.SetRelationship(ctx, firstID, secondID, store.RelationshipFriend); err != nil {
|
|
return humaError(err)
|
|
}
|
|
if err := s.store.SetRelationship(ctx, secondID, firstID, store.RelationshipFriend); err != nil {
|
|
return humaError(err)
|
|
}
|
|
s.dispatchRelationshipUpdate(firstID, secondID)
|
|
return nil
|
|
}
|
|
|
|
// dispatchRelationshipUpdate уведомляет обоих участников об изменении связи.
|
|
func (s *Server) dispatchRelationshipUpdate(firstID, secondID uint64) {
|
|
if s.gateway == nil {
|
|
return
|
|
}
|
|
for _, userID := range []uint64{firstID, secondID} {
|
|
s.gateway.SendToUser(userID, "RELATIONSHIP_UPDATE", map[string]any{
|
|
"user_id": formatSnowflake(userID),
|
|
})
|
|
}
|
|
}
|
|
|
|
// relationshipProfiles собирает список связей указанного типа.
|
|
func (s *Server) relationshipProfiles(ctx context.Context, userID uint64, kind store.RelationshipType) ([]relationshipUser, error) {
|
|
profiles, err := s.store.ListRelationships(ctx, userID, kind)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
// Оформление «для друзей» (AGENT.md 7.2) — одним запросом на весь список.
|
|
ids := make([]uint64, 0, len(profiles))
|
|
for i := range profiles {
|
|
ids = append(ids, profiles[i].UserID)
|
|
}
|
|
cosmetics, err := s.store.EffectiveCosmeticsForUsers(ctx, ids)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
result := make([]relationshipUser, 0, len(profiles))
|
|
for i := range profiles {
|
|
payload := s.relationshipPayload(userID, &profiles[i], string(kind))
|
|
payload.Cosmetics = cosmeticsFromEffective(cosmetics[profiles[i].UserID])
|
|
result = append(result, payload)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// relationshipUser собирает профиль пользователя со связью (для поиска).
|
|
func (s *Server) relationshipUser(ctx context.Context, viewerID uint64, user *store.User) (relationshipUser, error) {
|
|
kind := "none"
|
|
relation, err := s.store.GetRelationship(ctx, viewerID, user.ID)
|
|
switch {
|
|
case err == nil:
|
|
kind = string(relation.Type)
|
|
case errors.Is(err, store.ErrNotFound):
|
|
default:
|
|
return relationshipUser{}, humaError(err)
|
|
}
|
|
profile := store.RelationshipProfile{
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
DisplayName: user.DisplayName,
|
|
AvatarFileID: user.AvatarFileID,
|
|
Status: user.Status,
|
|
CustomStatus: user.CustomStatus,
|
|
Badges: user.Badges,
|
|
IsInstanceAdmin: user.IsInstanceAdmin,
|
|
LastSeenAt: user.LastSeenAt,
|
|
Timezone: user.Timezone,
|
|
}
|
|
return s.relationshipPayload(viewerID, &profile, kind), nil
|
|
}
|
|
|
|
// relationshipPayload переводит профиль в формат API, вычисляя видимый статус.
|
|
func (s *Server) relationshipPayload(_ uint64, profile *store.RelationshipProfile, kind string) relationshipUser {
|
|
payload := relationshipUser{
|
|
UserID: formatSnowflake(profile.UserID),
|
|
Username: profile.Username,
|
|
DisplayName: profile.DisplayName,
|
|
Status: profile.Status,
|
|
CustomStatus: profile.CustomStatus,
|
|
IsInstanceAdmin: profile.IsInstanceAdmin,
|
|
Badges: profile.Badges,
|
|
VisibleStatus: visibleStatus(profile.Status, profile.LastSeenAt),
|
|
Timezone: profile.Timezone,
|
|
Relationship: kind,
|
|
}
|
|
if payload.Badges == nil {
|
|
payload.Badges = []string{}
|
|
}
|
|
if payload.Timezone == "" {
|
|
payload.Timezone = "Europe/Moscow"
|
|
}
|
|
if profile.AvatarFileID != nil {
|
|
payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID)
|
|
}
|
|
if profile.LastSeenAt != nil {
|
|
payload.LastSeenAt = profile.LastSeenAt.UTC().Format(timeLayout)
|
|
}
|
|
return payload
|
|
}
|
|
|
|
// dmChannelPayload собирает личную беседу для API.
|
|
func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPayload {
|
|
payload := dmChannelPayload{
|
|
ID: formatSnowflake(summary.Channel.ID),
|
|
Type: string(store.ChannelDM),
|
|
CanSend: true,
|
|
CanView: true,
|
|
}
|
|
if summary.IsGroup {
|
|
payload.IsGroup = true
|
|
payload.Name = summary.GroupName
|
|
payload.MemberCount = summary.MemberCount
|
|
if summary.OwnerID != nil {
|
|
payload.OwnerID = formatSnowflake(*summary.OwnerID)
|
|
}
|
|
payload.Recipients = make([]dmRecipientPayload, 0, len(summary.Participants))
|
|
for _, participant := range summary.Participants {
|
|
item := dmRecipientPayload{
|
|
UserID: formatSnowflake(participant.UserID),
|
|
Username: participant.Username,
|
|
DisplayName: participant.DisplayName,
|
|
Status: participant.Status,
|
|
VisibleStatus: visibleStatus(participant.Status, participant.LastSeenAt),
|
|
IsOwner: participant.IsOwner,
|
|
IsSelf: participant.IsCurrentUser,
|
|
}
|
|
if participant.AvatarFileID != nil {
|
|
item.AvatarFileID = formatSnowflake(*participant.AvatarFileID)
|
|
}
|
|
payload.Recipients = append(payload.Recipients, item)
|
|
}
|
|
if summary.LastMessageAt != nil {
|
|
payload.LastMessageAt = summary.LastMessageAt.UTC().Format(timeLayout)
|
|
}
|
|
if summary.LastMessageID != 0 {
|
|
payload.LastMessageID = formatSnowflake(summary.LastMessageID)
|
|
}
|
|
return payload
|
|
}
|
|
payload.Recipient.UserID = formatSnowflake(summary.RecipientID)
|
|
payload.Recipient.Username = summary.RecipientLogin
|
|
payload.Recipient.DisplayName = summary.RecipientName
|
|
payload.Recipient.Status = summary.Status
|
|
payload.Recipient.Timezone = summary.Timezone
|
|
if payload.Recipient.Timezone == "" {
|
|
payload.Recipient.Timezone = "Europe/Moscow"
|
|
}
|
|
if summary.LastMessageAt != nil {
|
|
payload.LastMessageAt = summary.LastMessageAt.UTC().Format(timeLayout)
|
|
}
|
|
if summary.LastMessageID != 0 {
|
|
payload.LastMessageID = formatSnowflake(summary.LastMessageID)
|
|
}
|
|
if summary.AvatarFileID != nil {
|
|
payload.Recipient.AvatarFileID = formatSnowflake(*summary.AvatarFileID)
|
|
}
|
|
if summary.LastSeenAt != nil {
|
|
payload.Recipient.LastSeenAt = summary.LastSeenAt.UTC().Format(timeLayout)
|
|
}
|
|
payload.Recipient.VisibleStatus = visibleStatus(summary.Status, summary.LastSeenAt)
|
|
return payload
|
|
}
|
|
|
|
// timeLayout — единый формат времени в API.
|
|
const timeLayout = "2006-01-02T15:04:05Z07:00"
|
|
|
|
// visibleStatus вычисляет статус, который видят другие пользователи:
|
|
// «невидимка» отображается как офлайн, как и давно неактивный пользователь
|
|
// (AGENT.md 7.2).
|
|
func visibleStatus(status string, lastSeen *time.Time) string {
|
|
if status == "invisible" {
|
|
return "offline"
|
|
}
|
|
if status == "" {
|
|
status = "online"
|
|
}
|
|
if lastSeen != nil && time.Since(*lastSeen) > 2*time.Minute {
|
|
return "offline"
|
|
}
|
|
return status
|
|
}
|