feat(invites): приглашения, каталог публичных серверов и публичность сервера

- POST /guilds/{id}/invites (CREATE_INVITES): код из 10 символов, лимит
  использований и срок жизни; GET /guilds/{id}/invites (MANAGE_GUILD);
  DELETE /invites/{code} — создатель или MANAGE_GUILD;
- GET /invites/{code} — предпросмотр с карточкой сервера, 410 для истёкших и
  исчерпанных; POST /invites/{code} — принятие с проверкой лимита участников,
  повторное принятие не расходует использование;
- PATCH /guilds/{id} принимает `public`: сервер попадает в каталог и открыт для
  входа без приглашения (главный сервер публиковать нельзя);
- GET /guilds/public — каталог (имя, описание, иконка, число участников);
- аудит invite.create/invite.revoke/invite.accept, события INVITE_CREATE и
  INVITE_DELETE владельцу сессии;
- тесты: полный жизненный цикл приглашения, исчерпание, отзыв, права,
  публичный каталог и вход в публичный сервер.
This commit is contained in:
2026-09-19 23:33:34 +03:00
parent e4ffbb05a5
commit 61a84bfeae
16 changed files with 1233 additions and 32 deletions
+16
View File
@@ -221,6 +221,9 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
Body struct {
Name *string `json:"name,omitempty" maxLength:"64"`
Description *string `json:"description,omitempty" maxLength:"500"`
// Публичность сервера: попадает в каталог и открывает вход без
// приглашения (AGENT.md 7.20).
Public *bool `json:"public,omitempty"`
}
},
) (*guildOutput, error) {
@@ -233,6 +236,19 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
return nil, err
}
params := store.UpdateGuildParams{Description: input.Body.Description}
if input.Body.Public != nil {
// Главный сервер публичным не делаем: он и так точка входа.
if *input.Body.Public {
guild, err := s.store.GetGuild(ctx, guildID)
if err != nil {
return nil, humaError(err)
}
if guild.IsMain {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "main guild is always open and cannot be published")
}
}
params.IsDiscoverable = input.Body.Public
}
if input.Body.Name != nil {
trimmed := strings.TrimSpace(*input.Body.Name)
if trimmed == "" {
+378
View File
@@ -0,0 +1,378 @@
package server
import (
"context"
"crypto/rand"
"errors"
"net/http"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"glchat/internal/permissions"
"glchat/internal/store"
)
// inviteCodeAlphabet — символы кода приглашения (без похожих друг на друга).
const inviteCodeAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
type invitePayload struct {
Code string `json:"code"`
GuildID string `json:"guild_id"`
ChannelID string `json:"channel_id,omitempty"`
CreatorID string `json:"creator_id,omitempty"`
MaxUses int `json:"max_uses"`
Uses int `json:"uses"`
MaxAgeSec int `json:"max_age_seconds"`
ExpiresAt string `json:"expires_at,omitempty"`
CreatedAt string `json:"created_at"`
URL string `json:"url"`
}
type inviteListOutput struct {
Body struct {
Invites []invitePayload `json:"invites"`
}
}
type inviteOutput struct {
Body struct {
Invite invitePayload `json:"invite"`
Guild struct {
ID string `json:"id"`
Name string `json:"name"`
IconFileID string `json:"icon_file_id,omitempty"`
Description string `json:"description,omitempty"`
MemberCount int `json:"member_count"`
IsMember bool `json:"is_member"`
} `json:"guild"`
}
}
type publicGuildPayload struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IconFileID string `json:"icon_file_id,omitempty"`
MemberCount int `json:"member_count"`
}
type publicGuildListOutput struct {
Body struct {
Guilds []publicGuildPayload `json:"guilds"`
}
}
// registerInviteRoutes описывает приглашения и каталог публичных серверов
// (AGENT.md 7.9, 7.20).
func (s *Server) registerInviteRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "createInvite",
Method: http.MethodPost,
Path: "/guilds/{guild_id}/invites",
Summary: "Создать приглашение",
Tags: []string{"Invites"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
Body struct {
ChannelID string `json:"channel_id,omitempty"`
MaxUses int `json:"max_uses,omitempty" minimum:"0" maximum:"1000"`
MaxAgeSec int `json:"max_age_seconds,omitempty" minimum:"0" maximum:"2592000"`
}
},
) (*inviteOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.CreateInvites)
if err != nil {
return nil, err
}
params := store.CreateInviteParams{
Code: newInviteCode(),
GuildID: guildID,
CreatorID: user.ID,
MaxUses: input.Body.MaxUses,
MaxAgeSec: input.Body.MaxAgeSec,
}
if input.Body.ChannelID != "" {
channelID, err := parseID("channel_id", input.Body.ChannelID)
if err != nil {
return nil, err
}
if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil {
return nil, err
}
params.ChannelID = &channelID
}
invite, err := s.store.CreateInvite(ctx, params)
if err != nil {
return nil, humaError(err)
}
s.recordAudit(ctx, user, guildID, "invite.create", "invite", nil, "")
if s.gateway != nil {
s.gateway.SendToUser(user.ID, "INVITE_CREATE", s.invitePayload(invite))
}
return s.inviteOutput(ctx, user, invite), nil
})
huma.Register(api, huma.Operation{
OperationID: "listGuildInvites",
Method: http.MethodGet,
Path: "/guilds/{guild_id}/invites",
Summary: "Приглашения сервера",
Tags: []string{"Invites"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
},
) (*inviteListOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageGuild)
if err != nil {
return nil, err
}
invites, err := s.store.ListGuildInvites(ctx, guildID, 100)
if err != nil {
return nil, humaError(err)
}
output := &inviteListOutput{}
output.Body.Invites = make([]invitePayload, 0, len(invites))
for i := range invites {
output.Body.Invites = append(output.Body.Invites, s.invitePayload(&invites[i]))
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteInvite",
Method: http.MethodDelete,
Path: "/invites/{code}",
Summary: "Отозвать приглашение",
Tags: []string{"Invites"},
Security: security,
}, func(ctx context.Context, input *struct {
Code string `path:"code"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
invite, err := s.store.GetInvite(ctx, input.Code)
if err != nil {
return nil, humaError(err)
}
isCreator := invite.CreatorID != nil && *invite.CreatorID == user.ID
if !isCreator {
if _, _, err := s.requireGuildPermission(ctx, formatSnowflake(invite.GuildID), user, permissions.ManageGuild); err != nil {
return nil, err
}
}
if err := s.store.DeleteInvite(ctx, input.Code); err != nil {
return nil, humaError(err)
}
s.recordAudit(ctx, user, invite.GuildID, "invite.revoke", "invite", nil, "")
if s.gateway != nil && invite.CreatorID != nil {
s.gateway.SendToUser(*invite.CreatorID, "INVITE_DELETE", map[string]any{"code": input.Code})
}
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "getInvite",
Method: http.MethodGet,
Path: "/invites/{code}",
Summary: "Предпросмотр приглашения",
Tags: []string{"Invites"},
Security: security,
}, func(ctx context.Context, input *struct {
Code string `path:"code"`
},
) (*inviteOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
invite, err := s.store.GetInvite(ctx, input.Code)
if err != nil {
return nil, humaError(err)
}
if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now().UTC()) {
return nil, humaErrorStatus(http.StatusGone, "invite.expired", "invite has expired")
}
if invite.MaxUses > 0 && invite.Uses >= invite.MaxUses {
return nil, humaErrorStatus(http.StatusGone, "invite.exhausted", "invite has no uses left")
}
return s.inviteOutput(ctx, user, invite), nil
})
huma.Register(api, huma.Operation{
OperationID: "acceptInvite",
Method: http.MethodPost,
Path: "/invites/{code}",
Summary: "Принять приглашение",
Tags: []string{"Invites"},
Security: security,
}, func(ctx context.Context, input *struct {
Code string `path:"code"`
},
) (*guildIDOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
invite, err := s.store.GetInvite(ctx, input.Code)
if err != nil {
return nil, humaError(err)
}
// Уже участник: повторное принятие не расходует использование.
if _, err := s.store.GetGuildMember(ctx, invite.GuildID, user.ID); err == nil {
output := &guildIDOutput{}
output.Body.GuildID = formatSnowflake(invite.GuildID)
return output, nil
}
settings, err := s.store.InstanceSettings(ctx)
if err != nil {
return nil, humaError(err)
}
count, err := s.store.CountGuildMembers(ctx, invite.GuildID)
if err != nil {
return nil, humaError(err)
}
if count >= settings.MaxMembersPerGuild && !user.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "limits.members_reached", "guild is full")
}
if _, err := s.store.UseInvite(ctx, input.Code); err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, humaErrorStatus(http.StatusGone, "invite.exhausted", "invite is no longer valid")
}
return nil, humaError(err)
}
if _, err := s.store.AddGuildMember(ctx, invite.GuildID, user.ID, ""); err != nil && !errors.Is(err, store.ErrConflict) {
return nil, humaError(err)
}
s.invalidateGuild(invite.GuildID)
s.recordAudit(ctx, user, invite.GuildID, "invite.accept", "user", &user.ID, "")
if s.gateway != nil {
s.gateway.SendToUser(user.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(invite.GuildID)})
}
output := &guildIDOutput{}
output.Body.GuildID = formatSnowflake(invite.GuildID)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "listPublicGuilds",
Method: http.MethodGet,
Path: "/guilds/public",
Summary: "Каталог публичных серверов",
Tags: []string{"Guilds"},
Security: security,
}, func(ctx context.Context, _ *struct{}) (*publicGuildListOutput, error) {
if _, _, err := requireUser(ctx); err != nil {
return nil, err
}
guilds, err := s.store.ListPublicGuilds(ctx, 50)
if err != nil {
return nil, humaError(err)
}
output := &publicGuildListOutput{}
output.Body.Guilds = make([]publicGuildPayload, 0, len(guilds))
for _, guild := range guilds {
item := publicGuildPayload{
ID: formatSnowflake(guild.ID),
Name: guild.Name,
Description: guild.Description,
}
if guild.IconFileID != nil {
item.IconFileID = formatSnowflake(*guild.IconFileID)
}
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
item.MemberCount = count
}
output.Body.Guilds = append(output.Body.Guilds, item)
}
return output, nil
})
}
type guildIDOutput struct {
Body struct {
GuildID string `json:"guild_id"`
}
}
// invitePayload собирает приглашение для ответа API.
func (s *Server) invitePayload(invite *store.Invite) invitePayload {
payload := invitePayload{
Code: invite.Code,
GuildID: formatSnowflake(invite.GuildID),
MaxUses: invite.MaxUses,
Uses: invite.Uses,
MaxAgeSec: invite.MaxAgeSec,
CreatedAt: invite.CreatedAt.UTC().Format(time.RFC3339),
URL: s.cfg.BaseURL() + "/invite/" + invite.Code,
}
if invite.ChannelID != nil {
payload.ChannelID = formatSnowflake(*invite.ChannelID)
}
if invite.CreatorID != nil {
payload.CreatorID = formatSnowflake(*invite.CreatorID)
}
if invite.ExpiresAt != nil {
payload.ExpiresAt = invite.ExpiresAt.UTC().Format(time.RFC3339)
}
return payload
}
// inviteOutput собирает приглашение вместе с карточкой сервера для страницы
// предпросмотра (AGENT.md 7.9).
func (s *Server) inviteOutput(ctx context.Context, user *store.User, invite *store.Invite) *inviteOutput {
output := &inviteOutput{}
output.Body.Invite = s.invitePayload(invite)
guild, err := s.store.GetGuild(ctx, invite.GuildID)
if err != nil {
return output
}
output.Body.Guild.ID = formatSnowflake(guild.ID)
output.Body.Guild.Name = guild.Name
output.Body.Guild.Description = guild.Description
if guild.IconFileID != nil {
output.Body.Guild.IconFileID = formatSnowflake(*guild.IconFileID)
}
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
output.Body.Guild.MemberCount = count
}
if _, err := s.store.GetGuildMember(ctx, guild.ID, user.ID); err == nil {
output.Body.Guild.IsMember = true
}
return output
}
// newInviteCode генерирует код приглашения криптографическим источником.
func newInviteCode() string {
const length = 10
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
// Откат на время: код остаётся уникальным за счёт монотонности.
seed := uint64(time.Now().UnixNano()) //nolint:gosec // не криптография: запасной путь при отказе rand
for i := range buf {
buf[i] = byte(seed >> (uint(i) * 5))
}
}
var builder strings.Builder
builder.Grow(length)
for _, value := range buf {
builder.WriteByte(inviteCodeAlphabet[int(value)%len(inviteCodeAlphabet)])
}
return builder.String()
}
+173
View File
@@ -0,0 +1,173 @@
package server
import (
"net/http"
"testing"
)
func TestInviteLifecycle(t *testing.T) {
srv, _ := newTestServer(t)
ownerCookie := registerAndLogin(t, srv, "inv_owner", "inv-owner@example.com")
guestCookie := registerAndLogin(t, srv, "inv_guest", "inv-guest@example.com")
// Первый сервер становится главным (в него может войти кто угодно),
// приглашения проверяем на втором — закрытом.
doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Главный"}`, ownerCookie)
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Закрытый"}`, ownerCookie)
guild := decodeResponse[struct {
Guild struct {
ID string `json:"id"`
} `json:"guild"`
}](t, created)
// Посторонний не видит закрытый сервер.
hidden := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID, "", guestCookie)
if hidden.Code != http.StatusNotFound {
t.Fatalf("guest guild access = %d, want 404", hidden.Code)
}
// Приглашение с лимитом в одно использование.
inviteRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/invites",
`{"max_uses":1,"max_age_seconds":3600}`, ownerCookie)
if inviteRec.Code != http.StatusOK {
t.Fatalf("create invite = %d, body = %s", inviteRec.Code, inviteRec.Body.String())
}
invite := decodeResponse[struct {
Invite struct {
Code string `json:"code"`
URL string `json:"url"`
} `json:"invite"`
Guild struct {
Name string `json:"name"`
MemberCount int `json:"member_count"`
IsMember bool `json:"is_member"`
} `json:"guild"`
}](t, inviteRec)
if len(invite.Invite.Code) != 10 {
t.Fatalf("invite code = %q, want 10 символов", invite.Invite.Code)
}
// В ответе на создание IsMember отражает самого создателя (он в сервере).
if invite.Guild.Name != "Закрытый" || !invite.Guild.IsMember {
t.Fatalf("unexpected preview: %+v", invite.Guild)
}
// Предпросмотр доступен гостю и показывает сервер.
preview := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie)
if preview.Code != http.StatusOK {
t.Fatalf("preview = %d, body = %s", preview.Code, preview.Body.String())
}
previewPayload := decodeResponse[struct {
Guild struct {
Name string `json:"name"`
MemberCount int `json:"member_count"`
IsMember bool `json:"is_member"`
} `json:"guild"`
}](t, preview)
if previewPayload.Guild.Name != "Закрытый" || previewPayload.Guild.IsMember || previewPayload.Guild.MemberCount != 1 {
t.Fatalf("guest preview = %+v", previewPayload.Guild)
}
// Принятие: гость становится участником.
accept := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie)
if accept.Code != http.StatusOK {
t.Fatalf("accept invite = %d, body = %s", accept.Code, accept.Body.String())
}
member := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID, "", guestCookie)
if member.Code != http.StatusOK {
t.Fatalf("guild after invite = %d, body = %s", member.Code, member.Body.String())
}
// Повторное принятие тем же пользователем не расходует использования.
again := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie)
if again.Code != http.StatusOK {
t.Fatalf("repeat accept = %d, body = %s", again.Code, again.Body.String())
}
// Третьего участника приглашение уже не пускает: лимит исчерпан.
thirdCookie := registerAndLogin(t, srv, "inv_third", "inv-third@example.com")
exhausted := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", thirdCookie)
if exhausted.Code != http.StatusGone {
t.Fatalf("exhausted invite = %d, want 410", exhausted.Code)
}
// Отзыв приглашения владельцем и проверка списка.
list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/invites", "", ownerCookie)
invites := decodeResponse[struct {
Invites []struct {
Code string `json:"code"`
Uses int `json:"uses"`
} `json:"invites"`
}](t, list)
if len(invites.Invites) != 1 || invites.Invites[0].Uses != 1 {
t.Fatalf("invites = %+v", invites.Invites)
}
revoke := doJSON(t, srv, http.MethodDelete, "/api/v1/invites/"+invite.Invite.Code, "", ownerCookie)
if revoke.Code != http.StatusOK {
t.Fatalf("revoke = %d, body = %s", revoke.Code, revoke.Body.String())
}
afterRevoke := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie)
if afterRevoke.Code != http.StatusNotFound {
t.Fatalf("revoked invite = %d, want 404", afterRevoke.Code)
}
// Обычный участник без CREATE_INVITES приглашение создать не может.
memberInvite := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/invites", `{}`, guestCookie)
if memberInvite.Code != http.StatusForbidden {
t.Fatalf("member invite = %d, want 403", memberInvite.Code)
}
}
func TestPublicGuildsCatalog(t *testing.T) {
srv, _ := newTestServer(t)
ownerCookie := registerAndLogin(t, srv, "pub_owner", "pub-owner@example.com")
guestCookie := registerAndLogin(t, srv, "pub_guest", "pub-guest@example.com")
doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Главный"}`, ownerCookie)
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Публичный"}`, ownerCookie)
guild := decodeResponse[struct {
Guild struct {
ID string `json:"id"`
} `json:"guild"`
}](t, created)
// Пока сервер закрыт, в каталоге его нет.
closed := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/public", "", guestCookie)
closedList := decodeResponse[struct {
Guilds []struct {
Name string `json:"name"`
} `json:"guilds"`
}](t, closed)
for _, item := range closedList.Guilds {
if item.Name == "Публичный" {
t.Fatal("closed guild must not be in the public catalog")
}
}
// Делаем сервер публичным: появляется в каталоге и открыт для входа.
if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guild.Guild.ID,
`{"public":true}`, ownerCookie); rec.Code != http.StatusOK {
t.Fatalf("publish guild = %d, body = %s", rec.Code, rec.Body.String())
}
open := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/public", "", guestCookie)
openList := decodeResponse[struct {
Guilds []struct {
ID string `json:"id"`
Name string `json:"name"`
MemberCount int `json:"member_count"`
} `json:"guilds"`
}](t, open)
found := false
for _, item := range openList.Guilds {
if item.ID == guild.Guild.ID {
found = true
}
}
if !found {
t.Fatalf("public guild is missing from the catalog: %+v", openList.Guilds)
}
join := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", guestCookie)
if join.Code != http.StatusOK {
t.Fatalf("join public guild = %d, body = %s", join.Code, join.Body.String())
}
}
+1
View File
@@ -100,6 +100,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.registerGuildRoutes(s.api)
s.registerInstanceRoutes(s.api)
s.registerMessageRoutes(s.api)
s.registerInviteRoutes(s.api)
}
apiRouter.Get("/openapi.json", s.handleOpenAPI)
})
+154
View File
@@ -0,0 +1,154 @@
package store
import (
"context"
"database/sql"
"time"
)
// Invite — приглашение на сервер (AGENT.md 7.9).
type Invite struct {
Code string
GuildID uint64
ChannelID *uint64
CreatorID *uint64
MaxUses int
Uses int
MaxAgeSec int
ExpiresAt *time.Time
CreatedAt time.Time
}
// CreateInviteParams — параметры приглашения.
type CreateInviteParams struct {
Code string
GuildID uint64
ChannelID *uint64
CreatorID uint64
MaxUses int
MaxAgeSec int
}
const inviteColumns = `code, guild_id, channel_id, creator_id, max_uses, uses,
max_age_sec, expires_at, created_at`
// CreateInvite сохраняет приглашение с кодом, лимитом использований и сроком.
func (s *Store) CreateInvite(ctx context.Context, params CreateInviteParams) (*Invite, error) {
var expiresAt any
if params.MaxAgeSec > 0 {
expiresAt = s.Timestamp(time.Now().UTC().Add(time.Duration(params.MaxAgeSec) * time.Second))
}
_, err := s.writer.ExecContext(ctx, `
INSERT INTO invites (code, guild_id, channel_id, creator_id, max_uses, uses, max_age_sec, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`,
params.Code, int64(params.GuildID), nullableID(params.ChannelID), int64(params.CreatorID),
params.MaxUses, params.MaxAgeSec, expiresAt, s.Now())
if err != nil {
return nil, mapError(err)
}
return s.GetInvite(ctx, params.Code)
}
func (s *Store) GetInvite(ctx context.Context, code string) (*Invite, error) {
row := s.reader.QueryRowContext(ctx, `SELECT `+inviteColumns+` FROM invites WHERE code = ?`, code)
return scanInvite(row)
}
// ListGuildInvites возвращает приглашения сервера (для панели управления).
func (s *Store) ListGuildInvites(ctx context.Context, guildID uint64, limit int) ([]Invite, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.reader.QueryContext(ctx, `
SELECT `+inviteColumns+` FROM invites WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?`,
int64(guildID), limit)
if err != nil {
return nil, err
}
defer rows.Close()
invites := make([]Invite, 0, limit)
for rows.Next() {
invite, err := scanInvite(rows)
if err != nil {
return nil, err
}
invites = append(invites, *invite)
}
return invites, rows.Err()
}
// UseInvite атомарно увеличивает счётчик использований, проверяя лимит и срок.
// Возвращает ErrNotFound, если приглашение исчерпано или истекло (AGENT.md 7.9).
func (s *Store) UseInvite(ctx context.Context, code string) (*Invite, error) {
result, err := s.writer.ExecContext(ctx, `
UPDATE invites SET uses = uses + 1
WHERE code = ?
AND (max_uses = 0 OR uses < max_uses)
AND (expires_at IS NULL OR expires_at > ?)`, code, s.Now())
if err != nil {
return nil, err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return nil, ErrNotFound
}
return s.GetInvite(ctx, code)
}
func (s *Store) DeleteInvite(ctx context.Context, code string) error {
result, err := s.writer.ExecContext(ctx, `DELETE FROM invites WHERE code = ?`, code)
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
// ListPublicGuilds возвращает публичные серверы для каталога (AGENT.md 7.20).
func (s *Store) ListPublicGuilds(ctx context.Context, limit int) ([]Guild, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := s.reader.QueryContext(ctx, `
SELECT `+guildColumns+` FROM guilds
WHERE is_discoverable = 1 ORDER BY id LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
guilds := make([]Guild, 0, limit)
for rows.Next() {
guild, err := scanGuild(rows)
if err != nil {
return nil, err
}
guilds = append(guilds, *guild)
}
return guilds, rows.Err()
}
func scanInvite(scanner interface{ Scan(...any) error }) (*Invite, error) {
var (
invite Invite
channelID sql.NullInt64
creatorID sql.NullInt64
expiresAt sql.NullString
createdAt string
)
err := scanner.Scan(&invite.Code, &invite.GuildID, &channelID, &creatorID, &invite.MaxUses,
&invite.Uses, &invite.MaxAgeSec, &expiresAt, &createdAt)
if err != nil {
return nil, mapError(err)
}
invite.ChannelID = optionalID(channelID)
invite.CreatorID = optionalID(creatorID)
if expiresAt.Valid {
value := parseTimestamp(expiresAt.String)
invite.ExpiresAt = &value
}
invite.CreatedAt = parseTimestamp(createdAt)
return &invite, nil
}