openapi: 3.1.0
info:
  title: Кличат API
  version: 1.0.0-draft.1
  summary: REST API платформы сообществ «Кличат»
  description: |
    Контракт REST API по спецификации, раздел 6.4. Формы объектов, коды ошибок и заголовки лимитов
    совпадают с документированным Bot API Discord v10, чтобы библиотеки ботов работали через
    подмену базового URL (раздел 6.7). Пути `/api/v9` и `/api/v10` — псевдонимы `/api/v1`.

    Идентификаторы — snowflake, передаются строками. Даты — ISO 8601 с часовым поясом.
    Ошибки — объект `{code, message, errors}`; 429 — `{message, retry_after, global}`.

    Статус: черновик фазы 0. Ресурсы помечены тегами по фазам, в которых они реализуются.
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary
  contact:
    name: Кличат
    url: https://klichat.ru
servers:
  - url: https://api.klichat.ru/api/v1
    description: Продакшен (после запуска)
  - url: https://staging.klichat.ru/api/v1
    description: Staging
  - url: http://localhost:8080/api/v1
    description: Локальная разработка
security:
  - bearerAuth: []
  - botAuth: []
tags:
  - name: Service
    description: Служебные маршруты (фаза 0)
  - name: Auth
    description: Регистрация по номеру телефона, вход, сессии, 2FA (фаза 1)
  - name: Users
    description: Профиль, настройки, приватность (фаза 1)
  - name: Relationships
    description: Друзья, заявки, блокировки (фаза 1)
  - name: Guilds
    description: Серверы, участники, роли, баны, аудит, эмодзи (фаза 2)
  - name: Channels
    description: Каналы, перекрытия прав, закрепы, треды (фаза 2)
  - name: Messages
    description: Сообщения, реакции, вложения, поиск (фазы 1–2)
  - name: Invites
    description: Приглашения (фаза 2)
  - name: Voice
    description: Голосовые каналы и регионы (фаза 3)
  - name: Reports
    description: Жалобы (фаза 2)
  - name: Applications
    description: Приложения разработчиков, боты и OAuth2-авторизация бота на сервер (спецификация 6.7)
  - name: Discovery
    description: "Каталог публичных серверов: витрина без входа, предпросмотр, публикация владельцем и очередь модерации"
  - name: Billing
    description: «Кличат Плюс» и бусты серверов. Оплата включается KLICHAT_BILLING_PROVIDER=yookassa; при none ручки оплаты отвечают 501, а права выдаются вручную из панели модерации.
  - name: Webhooks
    description: Вебхуки (фаза 5)

paths:
  /icons/{guildId}/{hash}:
    get:
      tags: [Guilds]
      summary: Иконка сервера
      description: Публично, без авторизации; кэш бессрочный — смена иконки меняет hash. Загрузка — PATCH /guilds/{id} с полем icon (data URI).
      operationId: getGuildIcon
      security: []
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string } }
        - { name: hash, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Картинка }
        "404": { description: Нет такой иконки }
  /banners/{guildId}/{hash}:
    get:
      tags: [Guilds]
      summary: Баннер сервера
      operationId: getGuildBanner
      security: []
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string } }
        - { name: hash, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Картинка }
        "404": { description: Нет такого баннера }
  /oauth2/token:
    post:
      tags: [Applications]
      summary: Обменять код на токен или обновить токен
      description: >-
        OAuth2 authorization code с PKCE (RFC 7636). Тело — application/x-www-form-urlencoded.
        Публичные клиенты подтверждают себя code_verifier, серверные — client_secret
        (в теле или через Basic). Токен выдаётся с префиксом klo_ и принимается только на GET /users/@me.
      operationId: oauth2Token
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id]
              properties:
                grant_type: { type: string, enum: [authorization_code, refresh_token] }
                client_id: { type: string }
                client_secret: { type: string }
                code: { type: string }
                code_verifier: { type: string }
                redirect_uri: { type: string }
                refresh_token: { type: string }
      responses:
        "200":
          description: Токены
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  token_type: { type: string, example: Bearer }
                  expires_in: { type: integer }
                  refresh_token: { type: string }
                  scope: { type: string }
        "400": { description: "invalid_grant или unsupported_grant_type" }
        "401": { description: invalid_client }
  /oauth2/token/revoke:
    post:
      tags: [Applications]
      summary: Отозвать токен
      description: RFC 7009 — всегда отвечает успехом. Подходит и access, и refresh.
      operationId: oauth2Revoke
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        "200": { description: Отозван }
  /applications/{appId}/secret/reset:
    post:
      tags: [Applications]
      summary: Новый client_secret
      description: Показывается один раз; прежний перестаёт работать.
      operationId: resetClientSecret
      parameters:
        - { name: appId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
      responses:
        "200":
          description: Секрет
          content:
            application/json:
              schema:
                type: object
                properties:
                  client_secret: { type: string }
        "403": { description: Только владелец приложения }
  /gifs/trending:
    get:
      tags: [Gifs]
      summary: Популярные гифки
      description: Прокси к Klipy. Ключ приложения живёт на сервере; вместо идентификатора пользователя наружу уходит его отпечаток.
      operationId: getTrendingGifs
      parameters:
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 24 } }
      responses:
        "200":
          description: Страница выдачи
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GifPage" }
        "502": { description: Сервис гифок недоступен }
  /gifs/search:
    get:
      tags: [Gifs]
      summary: Поиск гифок
      operationId: searchGifs
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 24 } }
      responses:
        "200":
          description: Страница выдачи
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GifPage" }
        "502": { description: Сервис гифок недоступен }
  /gifs/categories:
    get:
      tags: [Gifs]
      summary: Плитки категорий на первом экране вкладки
      operationId: getGifCategories
      responses:
        "200":
          description: Категории
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string }
                        query: { type: string }
                        preview: { type: string, format: uri }
        "502": { description: Сервис гифок недоступен }
  /discovery/categories:
    get:
      tags: [Discovery]
      summary: Категории каталога и условия публикации
      operationId: getDiscoveryCategories
      security: []
      responses:
        "200":
          description: Категории и пороги публикации
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                  limits:
                    type: object
                    additionalProperties: { type: integer }
  /discovery/guilds:
    get:
      tags: [Discovery]
      summary: Каталог публичных серверов
      description: Без авторизации. Поиск по названию, описанию и тегам, фильтр по категории; сортировка по онлайну.
      operationId: listDiscovery
      security: []
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: category, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 48, default: 24 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0 } }
      responses:
        "200":
          description: Карточки серверов
          content:
            application/json:
              schema:
                type: object
                properties:
                  guilds: { type: array, items: { $ref: "#/components/schemas/DiscoveryCard" } }
  /discovery/guilds/{slug}:
    get:
      tags: [Discovery]
      summary: Карточка сервера по адресу каталога
      operationId: getDiscoveryBySlug
      security: []
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Предпросмотр сервера
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GuildPreview" }
        "404": { description: Сервер не опубликован }
  /discovery/guilds/{guildId}/join:
    post:
      tags: [Discovery]
      summary: Вступить в сервер из каталога
      description: Без приглашения; работает только для серверов в каталоге. Проверки те же, что при вступлении по приглашению.
      operationId: joinDiscovery
      parameters:
        - { name: guildId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
      responses:
        "200":
          description: Сервер, как в GUILD_CREATE
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Guild" }
        "403": { description: Пользователь забанен на сервере }
        "404": { description: Сервер не опубликован в каталоге }
  /guilds/{guildId}/preview:
    get:
      tags: [Discovery]
      summary: Предпросмотр сервера без вступления
      description: Доступно без входа для серверов из каталога.
      operationId: getGuildPreview
      security: []
      parameters:
        - { name: guildId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
      responses:
        "200":
          description: Карточка сервера
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GuildPreview" }
        "404": { description: Сервер не опубликован }
  /guilds/{guildId}/discovery:
    get:
      tags: [Discovery]
      summary: Карточка каталога глазами владельца
      operationId: getDiscoverySettings
      parameters:
        - { name: guildId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
      responses:
        "200":
          description: Состояние публикации и невыполненные условия
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DiscoverySettings" }
        "403": { description: Только владелец сервера }
    put:
      tags: [Discovery]
      summary: Опубликовать сервер или обновить карточку
      operationId: publishDiscovery
      parameters:
        - { name: guildId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [category, description]
              properties:
                category: { type: string }
                tags: { type: array, items: { type: string }, maxItems: 3 }
                description: { type: string, minLength: 40, maxLength: 300 }
      responses:
        "200":
          description: Карточка опубликована
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DiscoverySettings" }
        "400": { description: "Условия публикации не выполнены (поля ответа — что поправить)" }
        "409": { description: Больше трёх публичных серверов на владельца }
    delete:
      tags: [Discovery]
      summary: Убрать сервер из каталога
      operationId: unpublishDiscovery
      parameters:
        - { name: guildId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
      responses:
        "204": { description: Убран }
  /moderation/stats:
    get:
      tags: [Moderation]
      summary: Статистика для владельца
      description: Люди, онлайн и пик за день, активные, сообщения, голос, серверы, деньги, воронка регистрации, ряды за 30 дней по Москве. Только сотрудники платформы.
      operationId: moderationStats
      responses:
        "200":
          description: Срез на сейчас и ряды
          content:
            application/json:
              schema:
                type: object
                required: [at, users, online, active, messages, voice, guilds, money, funnel, push_users, series]
                properties:
                  at: { type: string, format: date-time }
                  users: { type: object, properties: { total: { type: integer }, new_day: { type: integer }, new_week: { type: integer }, new_month: { type: integer }, with_email: { type: integer }, deleted: { type: integer } } }
                  online: { type: object, properties: { now: { type: integer }, peak_day: { type: integer } } }
                  active: { type: object, properties: { day: { type: integer }, week: { type: integer }, month: { type: integer } } }
                  messages: { type: object, properties: { day: { type: integer }, week: { type: integer }, dm_day: { type: integer } } }
                  voice: { type: object, properties: { now: { type: integer }, streaming_now: { type: integer }, minutes_day: { type: integer }, stream_minutes_day: { type: integer }, call_minutes_day: { type: integer } } }
                  guilds: { type: object, properties: { total: { type: integer }, new_week: { type: integer }, active_week: { type: integer }, listed: { type: integer }, top: { type: array, items: { type: object, properties: { id: { $ref: "#/components/schemas/Snowflake" }, name: { type: string }, messages: { type: integer }, members: { type: integer } } } } } }
                  money: { type: object, properties: { plus_active: { type: integer }, boosts: { type: integer }, paid_month_rub: { type: integer }, referrals_qualified: { type: integer }, referrals_week: { type: integer }, sms_balance: { type: [number, "null"] } } }
                  funnel: { type: object, properties: { started: { type: integer }, confirmed: { type: integer }, recover_started: { type: integer } } }
                  push_users: { type: integer }
                  series: { type: object, properties: { days: { type: array, items: { type: string } }, new_users: { type: array, items: { type: integer } }, messages: { type: array, items: { type: integer } }, active: { type: array, items: { type: integer } }, peak_online: { type: array, items: { type: integer } } } }
        "403": { $ref: "#/components/responses/Forbidden" }

  /moderation/discovery:
    get:
      tags: [Discovery]
      summary: Очередь каталога (сотрудник)
      operationId: staffDiscovery
      parameters:
        - { name: state, in: query, schema: { type: string, enum: [listed, auto_hidden, removed] } }
        - { name: limit, in: query, schema: { type: integer, maximum: 100 } }
      responses:
        "200":
          description: Карточки с состоянием и числом жалоб
          content:
            application/json:
              schema:
                type: object
                properties:
                  guilds: { type: array, items: { $ref: "#/components/schemas/DiscoveryCard" } }
        "403": { description: Только для сотрудников платформы }
  /moderation/discovery/{guildId}:
    patch:
      tags: [Discovery]
      summary: Скрыть, вернуть или снять сервер (сотрудник)
      operationId: setDiscoveryState
      parameters:
        - { name: guildId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [state]
              properties:
                state: { type: string, enum: [listed, auto_hidden, removed] }
                reason: { type: string, maxLength: 200 }
      responses:
        "204": { description: Состояние изменено }
  /billing/plans:
    get:
      tags: [Billing]
      summary: Витрина тарифов и лимитов
      operationId: getBillingPlans
      responses:
        "200":
          description: Тарифы, лимиты уровней сервера и права «Плюса»
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean, description: Подключена ли касса }
                  currency: { type: string, example: RUB }
                  plans:
                    type: array
                    items:
                      type: object
                      properties:
                        ID: { type: string, example: plus_1m }
                        Kind: { type: string, enum: [plus, boost] }
                        Name: { type: string }
                        PriceRub: { type: integer }
                        Months: { type: integer }
                  boosts: { type: array, items: { type: integer }, description: "Бустов для уровней 1, 2 и 3" }
                  tiers: { type: array, items: { $ref: "#/components/schemas/GuildLimits" } }
                  plus: { $ref: "#/components/schemas/UserLimits" }
                  free: { $ref: "#/components/schemas/UserLimits" }
  /billing/status:
    get:
      tags: [Billing]
      summary: Своя подписка, бусты и платежи
      operationId: getBillingStatus
      responses:
        "200":
          description: Состояние подписки
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /billing/checkout:
    post:
      tags: [Billing]
      summary: Оплатить тариф
      description: Создаёт платёж в кассе и возвращает ссылку подтверждения. При выключенной оплате — 501.
      operationId: billingCheckout
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan: { type: string, example: plus_1m }
                phone: { type: string, description: Для чека по 54-ФЗ }
      responses:
        "200":
          description: Ссылка оплаты
          content:
            application/json:
              schema:
                type: object
                properties:
                  confirmation_url: { type: string, format: uri }
        "400": { $ref: "#/components/responses/ValidationError" }
        "501": { description: Оплата не подключена }
  /billing/notifications:
    post:
      tags: [Billing]
      summary: Уведомление кассы
      description: Вебхук ЮKassa, без авторизации. Статус платежа перечитывается из API кассы, повторные уведомления безопасны.
      operationId: billingNotification
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                event: { type: string, example: payment.succeeded }
                object:
                  type: object
                  properties:
                    id: { type: string }
      responses:
        "200": { description: Принято }
  /guilds/{guildId}/premium:
    get:
      tags: [Billing]
      summary: Бусты и уровень сервера
      operationId: getGuildPremium
      parameters:
        - { name: guildId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
      responses:
        "200":
          description: Уровень, число бустов и те, кто их поставил
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GuildPremium" }
        "403": { description: Нужно быть участником сервера }
  /guilds/{guildId}/boosts:
    post:
      tags: [Billing]
      summary: Поставить свободный буст серверу
      operationId: boostGuild
      parameters:
        - { name: guildId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
      responses:
        "200":
          description: Применённый слот
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Boost" }
        "409": { description: Нет свободного буста }
  /guilds/{guildId}/boosts/{boostId}:
    delete:
      tags: [Billing]
      summary: Снять свой буст с сервера
      operationId: unboostGuild
      parameters:
        - { name: guildId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
        - { name: boostId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
      responses:
        "204": { description: Слот освобождён и остаётся у пользователя до конца срока }
        "403": { description: Чужой буст }
  /moderation/billing/users/{userId}/plus:
    post:
      tags: [Billing]
      summary: Выдать «Плюс» вручную (сотрудник)
      operationId: grantPlus
      parameters:
        - { name: userId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                months: { type: integer, minimum: 1, maximum: 24 }
      responses:
        "200":
          description: Новая дата окончания
          content:
            application/json:
              schema:
                type: object
                properties:
                  premium_until: { type: string, format: date-time }
        "403": { description: Только для сотрудников платформы }
  /moderation/billing/users/{userId}/boosts:
    post:
      tags: [Billing]
      summary: Выдать бусты вручную (сотрудник)
      operationId: grantBoosts
      parameters:
        - { name: userId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                count: { type: integer, minimum: 1, maximum: 14 }
                months: { type: integer, minimum: 1, maximum: 24 }
      responses:
        "200":
          description: Сколько слотов выдано
          content:
            application/json:
              schema:
                type: object
                properties:
                  granted: { type: integer }
        "403": { description: Только для сотрудников платформы }
  /version:
    get:
      tags: [Service]
      summary: Версия сервера
      operationId: getVersion
      security: []
      responses:
        "200":
          description: Версия сборки и версия API
          content:
            application/json:
              schema:
                type: object
                required: [version, api, env]
                properties:
                  version: { type: string, examples: ["0.1.0"] }
                  api: { type: integer, const: 1 }
                  env: { type: string, enum: [dev, staging, prod] }

  /gateway:
    get:
      tags: [Service]
      summary: Адрес WebSocket-шлюза
      operationId: getGateway
      security: []
      responses:
        "200":
          description: URL шлюза для клиента
          content:
            application/json:
              schema:
                type: object
                required: [url]
                properties:
                  url: { type: string, format: uri, examples: ["wss://gateway.klichat.ru"] }

  /gateway/bot:
    get:
      tags: [Service]
      summary: Адрес шлюза и число шардов для бота
      operationId: getGatewayBot
      security:
        - botAuth: []
      responses:
        "200":
          description: Параметры подключения бота
          content:
            application/json:
              schema:
                type: object
                required: [url, shards, session_start_limit]
                properties:
                  url: { type: string, format: uri }
                  shards: { type: integer, minimum: 1 }
                  session_start_limit:
                    type: object
                    required: [total, remaining, reset_after, max_concurrency]
                    properties:
                      total: { type: integer }
                      remaining: { type: integer }
                      reset_after: { type: integer, description: Миллисекунды }
                      max_concurrency: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ---------------------------------------------------------------- Auth
  /auth/methods:
    get:
      tags: [Auth]
      summary: Какие способы регистрации доступны с этого адреса
      description: По почте — только не из России (страна по реестру RIPE), сервер решает сам по адресу клиента. Ответ не кэшируется.
      operationId: authMethods
      security: []
      responses:
        "200":
          description: Способы
          content:
            application/json:
              schema:
                type: object
                required: [phone, email, country]
                properties:
                  phone: { type: boolean }
                  email: { type: boolean }
                  country: { type: string, description: RU или пусто }

  /auth/register/email:
    post:
      tags: [Auth]
      summary: Начать регистрацию по почте
      description: Код письмом вместо SMS; доступно только с нероссийских адресов (403 иначе). Капча — по тому же правилу, что и для номера. Дальше — тот же POST /auth/register с verification_id и кодом; у такой учётной записи нет номера, почта подтверждена сразу и служит для входа.
      operationId: registerEmailStart
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                captcha_token: { type: string }
      responses:
        "200":
          description: Код отправлен
          content:
            application/json:
              schema:
                type: object
                properties:
                  verification_id: { type: string }
                  expires_in: { type: integer }
                  email: { type: string, description: Адрес маской }
                  channel: { type: string, enum: [email] }
                  code_length: { type: integer }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403":
          description: С российского адреса
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: Письмо не отправить
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /auth/register/phone:
    post:
      tags: [Auth]
      summary: Отправить код подтверждения на номер
      description: Принимаются только номера российских операторов (+7). Лимит — 3 SMS на номер в час.
      operationId: registerPhone
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string, pattern: "^\\+7\\d{10}$", examples: ["+79161234567"] }
                captcha_token: { type: string, description: "Токен SmartCaptcha, обязателен после первой попытки" }
      responses:
        "200":
          description: Код отправлен
          content:
            application/json:
              schema:
                type: object
                required: [verification_id, expires_in, phone, channel]
                properties:
                  verification_id: { $ref: "#/components/schemas/Snowflake" }
                  expires_in: { type: integer, description: Секунды, examples: [600] }
                  phone: { type: string, description: Замаскированный номер, examples: ["+7916***4567"] }
                  channel: { type: string, enum: [sms, telegram, console], description: Как доставлен код }
        "400": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/register/verify:
    post:
      tags: [Auth]
      summary: Подтвердить код и создать аккаунт
      operationId: registerVerify
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [verification_id, code, username, password, date_of_birth, consent]
              properties:
                verification_id: { $ref: "#/components/schemas/Snowflake" }
                code: { type: string, pattern: "^\\d{6}$" }
                username: { type: string, minLength: 2, maxLength: 32, pattern: "^[a-z0-9_.]+$" }
                display_name: { type: string, maxLength: 32 }
                password: { type: string, minLength: 8, maxLength: 128, format: password }
                date_of_birth: { type: string, format: date, description: "ГГГГ-ММ-ДД, порог 14+" }
                consent: { type: boolean, const: true, description: Согласие на обработку ПДн и принятие условий }
                email: { type: string, format: email, description: Необязательно; после регистрации придёт письмо со ссылкой для подтверждения }
      responses:
        "201":
          description: Аккаунт создан, выданы токены
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenPair" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403":
          description: Код неверен или истёк (70003)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /auth/login:
    post:
      tags: [Auth]
      summary: Вход по номеру и паролю
      operationId: login
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone, password]
              properties:
                phone: { type: string, pattern: "^\\+7\\d{10}$" }
                password: { type: string, format: password }
                code: { type: string, description: "Код 2FA (6 цифр из приложения или резервный код), если включена" }
                captcha_token: { type: string }
      responses:
        "200":
          description: Токены сессии
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenPair" }
        "401":
          description: "Неверные учётные данные (50014), требуется 2FA (60003, в теле `mfa: true`) или неверный код 2FA (60008)"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/refresh:
    post:
      tags: [Auth]
      summary: Обновить access-токен по refresh-токену
      description: Refresh-токен ротируется; повторное использование отозванного токена отзывает всю цепочку.
      operationId: refreshToken
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        "200":
          description: Новая пара токенов
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenPair" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/recover/phone:
    post:
      tags: [Auth]
      summary: Отправить код восстановления пароля
      description: Ответ одинаков для существующего и несуществующего номера; SMS уходит только владельцу аккаунта. Лимиты как у регистрации.
      operationId: recoverPhone
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string, pattern: "^\\+7\\d{10}$" }
                captcha_token: { type: string }
      responses:
        "200":
          description: Код отправлен (или сделан вид, что отправлен)
          content:
            application/json:
              schema:
                type: object
                required: [verification_id, expires_in, phone, channel]
                properties:
                  verification_id: { $ref: "#/components/schemas/Snowflake" }
                  expires_in: { type: integer }
                  phone: { type: string }
                  channel: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/recover/verify:
    post:
      tags: [Auth]
      summary: Подтвердить код и задать новый пароль
      description: Все сессии пользователя отзываются, выдаётся новая пара токенов.
      operationId: recoverVerify
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [verification_id, code, new_password]
              properties:
                verification_id: { $ref: "#/components/schemas/Snowflake" }
                code: { type: string, pattern: "^\\d{6}$" }
                new_password: { type: string, format: password, minLength: 8, maxLength: 128 }
      responses:
        "200":
          description: Новая сессия
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenPair" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403":
          description: Код неверен, истёк или уже использован (70003)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /auth/verify/{verificationId}:
    get:
      tags: [Auth]
      summary: Состояние подтверждения номера
      description: Нужно мобильной авторизации — оператор подтверждает номер сам, и узнать об этом можно только спросив. Попытку не расходует. Если оператор отказал, сервер сам переводит проверку на наш SMS-код и отдаёт channel sms и шесть цифр.
      operationId: verifyStatus
      security: []
      parameters:
        - { name: verificationId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
        - { name: purpose, in: query, schema: { type: string, enum: [register, recover, change_phone], default: register } }
      responses:
        "200":
          description: Состояние
          content:
            application/json:
              schema:
                type: object
                required: [status, code_length, channel]
                properties:
                  status: { type: string, enum: [pending, needs_code, ok, failed] }
                  code_length: { type: integer, description: "Сколько цифр набирать — у оператора четыре, у нас шесть" }
                  channel: { type: string, description: "mobileid — код и SMS у оператора, sms — наш код" }
        "403":
          description: Запись не найдена, чужая цель или код истёк (код 70003)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /auth/verify/{verificationId}/sms:
    post:
      tags: [Auth]
      summary: Не приходит код — перевести проверку на SMS от Кличата
      description: Для мобильной авторизации — оператор мог пообещать SMS и не прислать. Та же проверка продолжается нашим шестизначным кодом; повторный вызов второго SMS не шлёт. Для обычного SMS ничего не меняет.
      operationId: verifyForceSMS
      security: []
      parameters:
        - { name: verificationId, in: path, required: true, schema: { $ref: "#/components/schemas/Snowflake" } }
        - { name: purpose, in: query, schema: { type: string, enum: [register, recover, change_phone], default: register } }
      responses:
        "200":
          description: Проверка идёт по SMS
          content:
            application/json:
              schema:
                type: object
                required: [status, code_length, channel]
                properties:
                  status: { type: string, enum: [pending, needs_code, ok, failed] }
                  code_length: { type: integer }
                  channel: { type: string }
        "403":
          description: Запись не найдена, чужая цель или код истёк (код 70003)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /auth/email/verify:
    post:
      tags: [Auth]
      summary: Подтвердить почту по ссылке из письма
      description: Сессия не нужна — письмо могли открыть на другом устройстве. Ссылка одноразовая, действует сутки; адрес привязывается, если его не подтвердил кто-то другой.
      operationId: emailVerify
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        "200":
          description: Почта привязана
          content:
            application/json:
              schema:
                type: object
                required: [email]
                properties:
                  email: { type: string, format: email }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403":
          description: Ссылка недействительна, использована или устарела (код 70004)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /auth/recover/email:
    post:
      tags: [Auth]
      summary: Письмо со ссылкой на смену пароля
      description: Ответ одинаков для любого адреса; письмо уходит только на подтверждённую почту владельца, в фоне. Капча после первой попытки с IP, 10 попыток на IP и 3 на адрес в час.
      operationId: recoverEmail
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                captcha_token: { type: string }
      responses:
        "200":
          description: Принято (письмо отправлено или сделан вид, что отправлено)
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }
        "400": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/recover/email/verify:
    post:
      tags: [Auth]
      summary: Новый пароль по ссылке из письма
      description: Ссылка одноразовая, действует час и только пока адрес привязан к этому пользователю. Все сессии отзываются, выдаётся новая пара токенов.
      operationId: recoverEmailVerify
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, new_password]
              properties:
                token: { type: string }
                new_password: { type: string, format: password, minLength: 8, maxLength: 128 }
      responses:
        "200":
          description: Пароль изменён, вход выполнен
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenPair" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403":
          description: Ссылка недействительна, использована или устарела (код 70004)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /auth/logout:
    post:
      tags: [Auth]
      summary: Завершить текущую сессию
      operationId: logout
      responses:
        "204": { description: Сессия отозвана }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/sessions:
    get:
      tags: [Auth]
      summary: Список активных сессий (устройств)
      operationId: listSessions
      responses:
        "200":
          description: Сессии пользователя
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Session" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    delete:
      tags: [Auth]
      summary: Выйти на всех устройствах, кроме текущего
      operationId: revokeOtherSessions
      responses:
        "204": { description: Остальные сессии отозваны }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/mfa:
    get:
      tags: [Auth]
      summary: Состояние 2FA
      operationId: getMfaStatus
      responses:
        "200":
          description: Включён ли TOTP и сколько резервных кодов не использовано
          content:
            application/json:
              schema:
                type: object
                required: [totp_enabled, backup_codes_left]
                properties:
                  totp_enabled: { type: boolean }
                  backup_codes_left: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/mfa/codes:
    post:
      tags: [Auth]
      summary: Новые резервные коды
      description: Старые коды перестают действовать. Требует пароль и включённый TOTP (иначе 60002).
      operationId: regenerateBackupCodes
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: { type: string, format: password }
      responses:
        "200":
          description: Десять новых кодов вида xxxxxxxx-xxxx
          content:
            application/json:
              schema:
                type: object
                required: [backup_codes]
                properties:
                  backup_codes:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/mfa/totp:
    post:
      tags: [Auth]
      summary: Включить TOTP
      description: "Секрет генерирует клиент (20 байт, base32) и показывает как QR `otpauth://totp/Кличат:username?secret=…&issuer=Кличат` (SHA1, 6 цифр, 30 с). Сервер хранит секрет зашифрованным."
      operationId: enableTotp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password, code, secret]
              properties:
                password: { type: string, format: password }
                secret: { type: string, description: "Секрет base32 без «=», не короче 16 символов" }
                code: { type: string, pattern: "^\\d{6}$", description: "Текущий код из приложения — подтверждает, что секрет сохранён" }
      responses:
        "200":
          description: TOTP включён, выданы резервные коды
          content:
            application/json:
              schema:
                type: object
                required: [backup_codes]
                properties:
                  backup_codes:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    delete:
      tags: [Auth]
      summary: Выключить TOTP
      operationId: disableTotp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, description: "Код из приложения или резервный код" }
      responses:
        "204": { description: TOTP выключен }
        "400": { description: "TOTP не включён (60002)" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ---------------------------------------------------------------- Users
  /avatars/{user}/{hash}:
    get:
      tags: [Media]
      summary: Аватар пользователя
      description: Публично, без авторизации. Кэш бессрочный (immutable) — смена аватара меняет hash. Расширение в hash допускается и игнорируется.
      operationId: getAvatar
      parameters:
        - { name: user, in: path, required: true, schema: { type: string } }
        - { name: hash, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Картинка, content: { image/png: {}, image/jpeg: {}, image/gif: {}, image/webp: {} } }
        "404": { $ref: "#/components/responses/NotFound" }
  /user-banners/{user}/{hash}:
    get:
      tags: [Media]
      summary: Баннер профиля
      description: Публично, без авторизации. Кэш бессрочный (immutable) — смена баннера меняет hash. У серверов свой путь /banners/{guild}/{hash}.
      operationId: getUserBanner
      parameters:
        - { name: user, in: path, required: true, schema: { type: string } }
        - { name: hash, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Картинка, content: { image/png: {}, image/jpeg: {}, image/gif: {}, image/webp: {} } }
        "404": { $ref: "#/components/responses/NotFound" }
  /users/@me/phone:
    post:
      tags: [Users]
      summary: Начать смену номера
      description: Проверяет пароль и шлёт код на новый номер. Лимиты и cooldown те же, что при регистрации.
      operationId: changePhoneStart
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password, phone]
              properties:
                password: { type: string, format: password }
                phone: { type: string, pattern: "^\\+7\\d{10}$" }
      responses:
        "200":
          description: Код отправлен на новый номер
          content:
            application/json:
              schema:
                type: object
                required: [verification_id, expires_in, phone, channel]
                properties:
                  verification_id: { $ref: "#/components/schemas/Snowflake" }
                  expires_in: { type: integer, description: Секунд до истечения кода }
                  phone: { type: string, description: Маскированный номер }
                  channel: { type: string, enum: [sms, telegram, console] }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { description: "На новый номер уже зарегистрирован аккаунт" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Users]
      summary: Подтвердить новый номер
      operationId: changePhoneComplete
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [verification_id, code]
              properties:
                verification_id: { $ref: "#/components/schemas/Snowflake" }
                code: { type: string, pattern: "^\\d{6}$" }
      responses:
        "200":
          description: Пользователь с новым (маскированным) номером
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CurrentUser" }
        "403": { description: "Код неверен или истёк (70003)" }

  /users/@me/delete:
    post:
      tags: [Users]
      summary: Заявка на удаление учётной записи
      description: "Удаление с отсрочкой в 14 дней — всё это время можно отменить. Нужен текущий пароль. Отказ, если на человеке остаются серверы с другими участниками: их надо передать или распустить."
      operationId: requestAccountDeletion
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: { type: string, format: password }
      responses:
        "200":
          description: Заявка принята
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeletionStatus" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    delete:
      tags: [Users]
      summary: Отменить удаление
      operationId: cancelAccountDeletion
      responses:
        "200":
          description: Заявка отменена
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeletionStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /users/@me/export:
    get:
      tags: [Users]
      summary: Состояние выгрузки данных
      description: Последняя выгрузка. Готовая отдаётся со свежей ссылкой на скачивание, которая живёт час; сам архив хранится неделю.
      operationId: dataExportStatus
      responses:
        "200":
          description: Состояние
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataExport" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Users]
      summary: Заказать выгрузку данных
      description: Архив собирается в фоне. Не чаще раза в сутки; пока предыдущая готовится — 409.
      operationId: requestDataExport
      responses:
        "200":
          description: Выгрузка поставлена в очередь
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DataExport" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: Выгрузка уже готовится
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /users/@me/email:
    post:
      tags: [Users]
      summary: Добавить или сменить почту
      description: Проверяет пароль и шлёт на адрес письмо со ссылкой; почта привяжется после перехода по ней (email_pending до тех пор). Не чаще раза в минуту, 5 писем на адрес и на пользователя в час.
      operationId: emailStart
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password, email]
              properties:
                password: { type: string, format: password }
                email: { type: string, format: email }
      responses:
        "200":
          description: Письмо отправлено
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CurrentUser" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: Письмо не отправилось (почтовый сервер недоступен)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [Users]
      summary: Отвязать почту
      description: "Нужен текущий пароль. Гасит и все ссылки из писем: восстановиться через эту почту больше нельзя."
      operationId: emailRemove
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: { type: string, format: password }
      responses:
        "200":
          description: Почта отвязана
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CurrentUser" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /users/@me/email/resend:
    post:
      tags: [Users]
      summary: Отправить письмо для подтверждения ещё раз
      description: На адрес, который ждёт подтверждения; прежняя ссылка гаснет. Те же лимиты, что у добавления.
      operationId: emailResend
      responses:
        "200":
          description: Письмо отправлено
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CurrentUser" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /users/@me:
    get:
      tags: [Users]
      summary: Текущий пользователь
      operationId: getCurrentUser
      responses:
        "200":
          description: Полный объект пользователя (с телефоном в замаскированном виде)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CurrentUser" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [Users]
      summary: Изменить профиль
      description: >-
        Только пользователи (не боты). display_name — пустая строка сбрасывает до ника;
        bio — строчка о себе до 190 символов (пустая убирает);
        avatar — data URI картинки (PNG/GIF/JPEG/WebP до 1 МБ, не больше 1024×1024) или null, чтобы убрать;
        banner — так же, но до 3 МБ и 2048×2048.
        Картинки кладутся в хранилище под ключи avatars/{user}/{hash} и user-banners/{user}/{hash},
        в ответе — новые хэши; отдаются без авторизации: GET /avatars/{user}/{hash} и
        GET /user-banners/{user}/{hash}. Изменение рассылается
        событиями GUILD_MEMBER_UPDATE (в серверы пользователя) и USER_UPDATE (себе, друзьям, собеседникам ЛС).
      operationId: updateCurrentUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name: { type: string, maxLength: 32 }
                bio: { type: string, maxLength: 190, description: "Строчка о себе; пустая убирает" }
                avatar: { type: [string, "null"], description: "data URI или null" }
                banner: { type: [string, "null"], description: "data URI или null" }
      responses:
        "200":
          description: Обновлённый пользователь
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CurrentUser" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "501": { description: Хранилище картинок профиля не подключено }

  /users/@me/guild-order:
    patch:
      tags: [Users]
      summary: Порядок серверов в колонке слева
      description: >-
        Список id серверов в том порядке, в каком человек расставил их перетаскиванием.
        Повторы отбрасываются; серверы, которых в списке нет, показываются после него
        в порядке вступления. Хранится в настройках пользователя и приходит обратно
        в объекте CurrentUser полем guild_order.
      operationId: setGuildOrder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [guilds]
              properties:
                guilds:
                  type: array
                  maxItems: 200
                  items: { $ref: "#/components/schemas/Snowflake" }
      responses:
        "204": { description: Порядок сохранён }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /users/@me/settings:
    get:
      tags: [Users]
      summary: Настройки пользователя
      description: >-
        Сейчас сервер хранит `status`, `custom_status` (только `text`), `theme` и `locale`;
        они же приходят в READY (`user_settings`). Только пользователям, не ботам.
      operationId: getUserSettings
      responses:
        "200":
          description: Настройки
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserSettings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [Users]
      summary: Изменить настройки
      description: >-
        Меняются `status` (online, idle, dnd, invisible) и `custom_status`: `{text}` до 128 символов
        одной строкой; `null` или пустой текст убирают свой статус. Остальные поля принимаются и
        пропускаются. Выбранное переживает переподключения: новые сессии шлюза приходят с ним.
        После изменения своим сессиям уходит `USER_SETTINGS_UPDATE` (тело — как ответ), друзьям и
        серверам — `PRESENCE_UPDATE` (невидимый для них не в сети, свой статус — активность type 4).
      operationId: updateUserSettings
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UserSettings" }
      responses:
        "200":
          description: Настройки после изменения
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserSettings" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /users/@me/relationships:
    get:
      tags: [Relationships]
      summary: Друзья, заявки и блокировки
      operationId: listRelationships
      responses:
        "200":
          description: Список связей
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Relationship" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Relationships]
      summary: Отправить заявку в друзья по username
      operationId: sendFriendRequestByUsername
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username]
              properties:
                username: { type: string }
      responses:
        "204": { description: "Заявка отправлена (или принята, если была встречная)" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /users/@me/relationships/{userId}:
    parameters:
      - $ref: "#/components/parameters/userId"
    put:
      tags: [Relationships]
      summary: Отправить или принять заявку, либо заблокировать
      operationId: putRelationship
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                type:
                  type: integer
                  enum: [1, 2]
                  description: "1 — друг (заявка/принятие), 2 — блокировка; по умолчанию 1"
      responses:
        "204": { description: Связь создана или обновлена }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Relationships]
      summary: Удалить из друзей, отозвать заявку или разблокировать
      operationId: deleteRelationship
      responses:
        "204": { description: Связь удалена }
        "404": { $ref: "#/components/responses/NotFound" }

  /users/@me/channels:
    get:
      tags: [Relationships]
      summary: Личные и групповые каналы
      operationId: listDMChannels
      responses:
        "200":
          description: Каналы ЛС
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Channel" }
    post:
      tags: [Relationships]
      summary: Открыть ЛС или создать группу
      operationId: createDMChannel
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recipient_ids]
              properties:
                recipient_ids:
                  type: array
                  minItems: 1
                  maxItems: 9
                  description: Один получатель — ЛС (существующее или новое); от 2 до 9 друзей — новая группа (тип 3, до 10 участников)
                  items: { $ref: "#/components/schemas/Snowflake" }
      responses:
        "200":
          description: Существующий или новый канал
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Channel" }
        "400":
          description: Кто-то из получателей не в друзьях (50033) или превышен предел участников (50035)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Получатель запретил ЛС (50007) или заблокировал (50033)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /users/@me/guilds:
    get:
      tags: [Users]
      summary: Серверы текущего пользователя
      operationId: listCurrentUserGuilds
      responses:
        "200":
          description: Краткие объекты серверов
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/GuildPartial" }

  /users/@me/guilds/{guildId}/settings:
    parameters:
      - name: guildId
        in: path
        required: true
        description: "Id сервера или `@me` — личные беседы: запись с `guild_id: null`, в ней только `channel_overrides` ЛС и групп"
        schema: { type: string }
    get:
      tags: [Users]
      summary: Настройки уведомлений сервера
      description: "Как `user_guild_settings` у Discord; приходят в READY и событии USER_GUILD_SETTINGS_UPDATE. Запись личных бесед (`guild_id: null`) в READY есть, только если в ЛС что-то настроено."
      operationId: getUserGuildSettings
      responses:
        "200":
          description: Настройки с переопределениями каналов
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserGuildSettings" }
        "403": { $ref: "#/components/responses/Forbidden" }
    patch:
      tags: [Users]
      summary: Изменить настройки уведомлений сервера
      description: "Частичное обновление; `channel_overrides` — объект channel_id → {muted, mute_config, message_notifications, collapsed}. Переопределение канала без отличий от сервера удаляется. Mute категории глушит все каналы в ней. Для `@me` принимаются только беседы, где вы получатель; поля уровня сервера там ничего не меняют."
      operationId: updateUserGuildSettings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                message_notifications: { type: integer, enum: [0, 1, 2, 3], description: "0 все, 1 только упоминания, 2 ничего, 3 как на сервере" }
                muted: { type: boolean }
                mute_config: { type: [object, "null"], properties: { end_time: { type: [string, "null"], format: date-time } } }
                suppress_everyone: { type: boolean, description: "Не считать @everyone и @here упоминаниями" }
                suppress_roles: { type: boolean }
                channel_overrides:
                  type: object
                  additionalProperties:
                    type: object
                    properties:
                      message_notifications: { type: integer, enum: [0, 1, 2, 3] }
                      muted: { type: boolean }
                      mute_config: { type: [object, "null"], properties: { end_time: { type: [string, "null"], format: date-time } } }
                      collapsed: { type: boolean }
      responses:
        "200":
          description: Настройки после изменения
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserGuildSettings" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /users/@me/guilds/{guildId}:
    parameters:
      - $ref: "#/components/parameters/guildId"
    delete:
      tags: [Users]
      summary: Покинуть сервер
      description: "Владелец сначала передаёт сервер (`owner_id` в PATCH /guilds/{guildId}) или удаляет его."
      operationId: leaveGuild
      responses:
        "204": { description: "Вышли; остальным участникам приходит GUILD_MEMBER_REMOVE, вам — GUILD_DELETE" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /users/{userId}:
    parameters:
      - $ref: "#/components/parameters/userId"
    get:
      tags: [Users]
      summary: Публичный профиль пользователя
      operationId: getUser
      responses:
        "200":
          description: Карточка профиля — пользователь плюс баннер, «о себе», дата регистрации и признак «Плюса»
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Profile" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---------------------------------------------------------------- Guilds
  /guilds:
    post:
      tags: [Guilds]
      summary: Создать сервер
      description: Лимит — 100 серверов на пользователя (30001) и 2 создания в час. Ботам недоступно (20001).
      operationId: createGuild
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 2, maxLength: 100 }
                icon: { type: [string, "null"] }
                template: { type: string, enum: [default, gaming, study, community, friends], description: "Шаблон каналов; по умолчанию default (общий + голосовой)" }
      responses:
        "201":
          description: Созданный сервер с каналами и ролями шаблона
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Guild" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /guilds/{guildId}:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Guilds]
      summary: Сервер
      operationId: getGuild
      parameters:
        - name: with_counts
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: Сервер
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Guild" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Guilds]
      summary: Изменить сервер
      description: Требует MANAGE_GUILD.
      operationId: updateGuild
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 2, maxLength: 100 }
                icon: { type: [string, "null"] }
                banner: { type: [string, "null"] }
                description: { type: [string, "null"], maxLength: 300 }
                verification_level: { type: integer, enum: [0, 1, 2, 3] }
                default_message_notifications: { type: integer, enum: [0, 1] }
                system_channel_id: { $ref: "#/components/schemas/SnowflakeNullable" }
                rules_channel_id: { $ref: "#/components/schemas/SnowflakeNullable" }
                afk_channel_id: { $ref: "#/components/schemas/SnowflakeNullable" }
                afk_timeout: { type: integer, enum: [60, 300, 900, 1800, 3600] }
                owner_id: { $ref: "#/components/schemas/Snowflake" }
      responses:
        "200":
          description: Сервер после изменения
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Guild" }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Guilds]
      summary: Удалить сервер (только владелец)
      operationId: deleteGuild
      responses:
        "204": { description: Сервер удалён }
        "403": { $ref: "#/components/responses/Forbidden" }

  /guilds/{guildId}/channels:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Channels]
      summary: Каналы сервера
      operationId: listGuildChannels
      responses:
        "200":
          description: Каналы, включая категории
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Channel" }
    post:
      tags: [Channels]
      summary: Создать канал
      description: Требует MANAGE_CHANNELS. Лимит — 500 каналов (30013).
      operationId: createGuildChannel
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChannelCreate" }
      responses:
        "201":
          description: Созданный канал
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Channel" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403": { $ref: "#/components/responses/Forbidden" }
    patch:
      tags: [Channels]
      summary: Изменить порядок и родителей каналов
      operationId: reorderGuildChannels
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                type: object
                required: [id]
                properties:
                  id: { $ref: "#/components/schemas/Snowflake" }
                  position: { type: integer, minimum: 0 }
                  parent_id: { $ref: "#/components/schemas/SnowflakeNullable" }
      responses:
        "204": { description: Порядок обновлён }
        "403": { $ref: "#/components/responses/Forbidden" }

  /guilds/{guildId}/roles:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Guilds]
      summary: Роли сервера
      operationId: listGuildRoles
      responses:
        "200":
          description: Роли
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Role" }
    post:
      tags: [Guilds]
      summary: Создать роль
      description: Требует MANAGE_ROLES; выдать можно только права, которыми обладаешь. Лимит — 250 ролей (30005).
      operationId: createGuildRole
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RoleCreate" }
      responses:
        "201":
          description: Созданная роль
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Role" }
        "403": { $ref: "#/components/responses/Forbidden" }
    patch:
      tags: [Guilds]
      summary: Изменить порядок ролей
      operationId: reorderGuildRoles
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                type: object
                required: [id, position]
                properties:
                  id: { $ref: "#/components/schemas/Snowflake" }
                  position: { type: integer, minimum: 1 }
      responses:
        "200":
          description: Роли в новом порядке
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Role" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /guilds/{guildId}/roles/{roleId}:
    parameters:
      - $ref: "#/components/parameters/guildId"
      - $ref: "#/components/parameters/roleId"
    patch:
      tags: [Guilds]
      summary: Изменить роль
      operationId: updateGuildRole
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RoleCreate" }
      responses:
        "200":
          description: Роль после изменения
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Role" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Guilds]
      summary: Удалить роль
      operationId: deleteGuildRole
      responses:
        "204": { description: Роль удалена }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /guilds/{guildId}/members:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Guilds]
      summary: Участники сервера (постранично)
      operationId: listGuildMembers
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 1000, default: 1 }
        - name: after
          in: query
          schema: { $ref: "#/components/schemas/Snowflake" }
      responses:
        "200":
          description: Участники, отсортированные по user id
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Member" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /guilds/{guildId}/members/search:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Guilds]
      summary: Поиск участников по имени или нику
      operationId: searchGuildMembers
      parameters:
        - name: query
          in: query
          required: true
          schema: { type: string, minLength: 1, maxLength: 100 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 1000, default: 1 }
      responses:
        "200":
          description: Найденные участники
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Member" }

  /guilds/{guildId}/members/@me:
    parameters:
      - $ref: "#/components/parameters/guildId"
    patch:
      tags: [Guilds]
      summary: Изменить свой ник
      description: Требует CHANGE_NICKNAME.
      operationId: updateCurrentMember
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                nick: { type: [string, "null"], maxLength: 32 }
      responses:
        "200":
          description: Участник после изменения
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Member" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /guilds/{guildId}/members/{userId}:
    parameters:
      - $ref: "#/components/parameters/guildId"
      - $ref: "#/components/parameters/userId"
    get:
      tags: [Guilds]
      summary: Участник
      operationId: getGuildMember
      responses:
        "200":
          description: Участник
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Member" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Guilds]
      summary: Изменить участника (ник, роли, таймаут, голос)
      description: MANAGE_NICKNAMES для ника, MANAGE_ROLES для ролей, MODERATE_MEMBERS для таймаута, MUTE/DEAFEN/MOVE_MEMBERS для голоса.
      operationId: updateGuildMember
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                nick: { type: [string, "null"], maxLength: 32 }
                roles:
                  type: array
                  items: { $ref: "#/components/schemas/Snowflake" }
                mute: { type: boolean }
                deaf: { type: boolean }
                channel_id: { $ref: "#/components/schemas/SnowflakeNullable" }
                communication_disabled_until:
                  type: [string, "null"]
                  format: date-time
                  description: Таймаут до указанного момента, не больше 28 дней
      responses:
        "200":
          description: Участник после изменения
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Member" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Guilds]
      summary: Выгнать участника
      description: Требует KICK_MEMBERS и более высокую роль, чем у цели.
      operationId: kickGuildMember
      parameters:
        - $ref: "#/components/parameters/auditReason"
      responses:
        "204": { description: Участник исключён }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /guilds/{guildId}/members/{userId}/roles/{roleId}:
    parameters:
      - $ref: "#/components/parameters/guildId"
      - $ref: "#/components/parameters/userId"
      - $ref: "#/components/parameters/roleId"
    put:
      tags: [Guilds]
      summary: Выдать роль
      operationId: addMemberRole
      responses:
        "204": { description: Роль выдана }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Guilds]
      summary: Снять роль
      operationId: removeMemberRole
      responses:
        "204": { description: Роль снята }
        "403": { $ref: "#/components/responses/Forbidden" }

  /guilds/{guildId}/bans:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Guilds]
      summary: Список банов
      operationId: listGuildBans
      parameters:
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/before"
        - $ref: "#/components/parameters/after"
      responses:
        "200":
          description: Баны
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Ban" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /guilds/{guildId}/bans/{userId}:
    parameters:
      - $ref: "#/components/parameters/guildId"
      - $ref: "#/components/parameters/userId"
    get:
      tags: [Guilds]
      summary: Бан пользователя
      operationId: getGuildBan
      responses:
        "200":
          description: Бан
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ban" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Guilds]
      summary: Забанить
      description: Требует BAN_MEMBERS. Удаляет сообщения за указанное число секунд (до 7 дней).
      operationId: createGuildBan
      parameters:
        - $ref: "#/components/parameters/auditReason"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                delete_message_seconds: { type: integer, minimum: 0, maximum: 604800, default: 0 }
      responses:
        "204": { description: Пользователь забанен }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Guilds]
      summary: Разбанить
      operationId: deleteGuildBan
      parameters:
        - $ref: "#/components/parameters/auditReason"
      responses:
        "204": { description: Бан снят }
        "404": { $ref: "#/components/responses/NotFound" }

  /guilds/{guildId}/audit-logs:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Guilds]
      summary: Журнал аудита (90 дней)
      description: "Требует VIEW_AUDIT_LOG. Причина действия берётся из заголовка X-Audit-Log-Reason (до 512 символов) любого изменяющего запроса."
      operationId: getGuildAuditLog
      parameters:
        - name: user_id
          in: query
          schema: { $ref: "#/components/schemas/Snowflake" }
        - name: action_type
          in: query
          schema: { type: integer }
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/before"
      responses:
        "200":
          description: Записи аудита
          content:
            application/json:
              schema:
                type: object
                required: [audit_log_entries, users]
                properties:
                  audit_log_entries:
                    type: array
                    items: { $ref: "#/components/schemas/AuditLogEntry" }
                  users:
                    type: array
                    items: { $ref: "#/components/schemas/User" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /guilds/{guildId}/emojis:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Guilds]
      summary: Эмодзи сервера
      operationId: listGuildEmojis
      responses:
        "200":
          description: Эмодзи
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Emoji" }
    post:
      tags: [Guilds]
      summary: Добавить эмодзи
      description: Требует MANAGE_GUILD_EXPRESSIONS. Лимит слотов зависит от уровня сервера (30008).
      operationId: createGuildEmoji
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, image]
              properties:
                name: { type: string, minLength: 2, maxLength: 32, pattern: "^[a-zA-Z0-9_]+$" }
                image: { type: string, description: "data URI, до 256 КБ" }
      responses:
        "201":
          description: Созданный эмодзи
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Emoji" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /guilds/{guildId}/emojis/{emojiId}:
    parameters:
      - $ref: "#/components/parameters/guildId"
      - name: emojiId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Snowflake" }
    get:
      tags: [Guilds]
      operationId: getGuildEmoji
      summary: Эмодзи сервера
      responses:
        "200":
          description: Эмодзи
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Emoji" }
        "404": { description: Нет такого эмодзи (10014) }
    patch:
      tags: [Guilds]
      operationId: updateGuildEmoji
      summary: Переименовать эмодзи
      description: Требует MANAGE_GUILD_EXPRESSIONS; аудит 61.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 2, maxLength: 32, pattern: "^[a-zA-Z0-9_]+$" }
      responses:
        "200":
          description: Эмодзи; участникам уйдёт GUILD_EMOJIS_UPDATE
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Emoji" }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Guilds]
      operationId: deleteGuildEmoji
      summary: Удалить эмодзи
      description: Требует MANAGE_GUILD_EXPRESSIONS; файл удаляется, реакции с этим эмодзи остаются по id; аудит 62.
      responses:
        "204": { description: Удалено; GUILD_EMOJIS_UPDATE }
        "403": { $ref: "#/components/responses/Forbidden" }

  /emojis/{emojiId}:
    get:
      tags: [Guilds]
      operationId: emojiImage
      summary: Картинка эмодзи (публично)
      description: "`{id}.png`, `{id}.gif` — расширение только для браузера; ответ с Cache-Control на год."
      security: []
      parameters:
        - name: emojiId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: Картинка }
        "404": { description: Нет такого эмодзи }

  /guilds/{guildId}/invites:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Invites]
      summary: Приглашения сервера
      description: Требует MANAGE_GUILD.
      operationId: listGuildInvites
      responses:
        "200":
          description: Приглашения
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Invite" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /guilds/{guildId}/messages/search:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Messages]
      summary: Поиск сообщений по серверу
      description: "Meilisearch по каналам, где у пользователя есть VIEW_CHANNEL и READ_MESSAGE_HISTORY; до 25 результатов за запрос, `offset` до 5000. Без `content` — новые сверху. 503 (40006), если индекс недоступен."
      operationId: searchGuildMessages
      parameters:
        - name: content
          in: query
          schema: { type: string }
        - name: author_id
          in: query
          schema: { $ref: "#/components/schemas/Snowflake" }
        - name: channel_id
          in: query
          schema: { $ref: "#/components/schemas/Snowflake" }
        - name: mentions
          in: query
          schema: { $ref: "#/components/schemas/Snowflake" }
        - name: has
          in: query
          description: "Пока поддерживается только link; остальные значения появятся с вложениями"
          schema:
            type: string
            enum: [link, embed, file, video, image, sound]
        - name: min_id
          in: query
          schema: { $ref: "#/components/schemas/Snowflake" }
        - name: max_id
          in: query
          schema: { $ref: "#/components/schemas/Snowflake" }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, maximum: 5000 }
      responses:
        "200":
          description: Результаты поиска
          content:
            application/json:
              schema:
                type: object
                required: [total_results, messages]
                properties:
                  total_results: { type: integer }
                  messages:
                    type: array
                    items:
                      type: array
                      items: { $ref: "#/components/schemas/Message" }
                      description: Найденное сообщение с контекстом до и после
        "403": { $ref: "#/components/responses/Forbidden" }

  # ---------------------------------------------------------------- Channels
  /channels/{channelId}:
    parameters:
      - $ref: "#/components/parameters/channelId"
    get:
      tags: [Channels]
      summary: Канал
      operationId: getChannel
      responses:
        "200":
          description: Канал
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Channel" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Channels]
      summary: Изменить канал
      description: "Требует MANAGE_CHANNELS. Для групп ЛС любой участник меняет `name` (до 100 символов, пустая строка убирает название) и `icon`; остальные поля игнорируются."
      operationId: updateChannel
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChannelUpdate" }
      responses:
        "200":
          description: Канал после изменения
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Channel" }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Channels]
      summary: Удалить канал или покинуть группу
      description: "ЛС — скрыть из списка; группа — выйти (владение переходит следующему участнику, пустая группа удаляется); канал сервера — удалить."
      operationId: deleteChannel
      parameters:
        - $ref: "#/components/parameters/auditReason"
      responses:
        "200":
          description: Удалённый канал
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Channel" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /channels/{channelId}/recipients/{userId}:
    parameters:
      - $ref: "#/components/parameters/channelId"
      - $ref: "#/components/parameters/userId"
    put:
      tags: [Channels]
      summary: Добавить друга в группу
      description: "Только для групп ЛС (иначе 50003). Добавлять можно только своих друзей (50033), не больше 10 участников (50035). Всем участникам приходит CHANNEL_RECIPIENT_ADD и системное сообщение типа 1."
      operationId: addGroupRecipient
      responses:
        "204": { description: Добавлен }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Channels]
      summary: Исключить из группы
      description: "Владелец исключает участника (иначе 50013); свой id — то же, что выход. Остальным приходит CHANNEL_RECIPIENT_REMOVE и системное сообщение типа 2, исключённому — CHANNEL_DELETE."
      operationId: removeGroupRecipient
      responses:
        "204": { description: Исключён }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /channels/{channelId}/permissions/{overwriteId}:
    parameters:
      - $ref: "#/components/parameters/channelId"
      - name: overwriteId
        in: path
        required: true
        description: ID роли или пользователя
        schema: { $ref: "#/components/schemas/Snowflake" }
    put:
      tags: [Channels]
      summary: Установить перекрытие прав
      description: Требует MANAGE_ROLES; менять можно только права, которыми обладаешь.
      operationId: putChannelPermission
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type: { type: integer, enum: [0, 1], description: "0 — роль, 1 — участник" }
                allow: { $ref: "#/components/schemas/Permissions" }
                deny: { $ref: "#/components/schemas/Permissions" }
      responses:
        "204": { description: Перекрытие сохранено }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Channels]
      summary: Удалить перекрытие
      operationId: deleteChannelPermission
      responses:
        "204": { description: Перекрытие удалено }
        "403": { $ref: "#/components/responses/Forbidden" }

  /channels/{channelId}/messages:
    parameters:
      - $ref: "#/components/parameters/channelId"
    get:
      tags: [Messages]
      summary: История сообщений
      description: Требует VIEW_CHANNEL и READ_MESSAGE_HISTORY. Ровно один из before/after/around.
      operationId: listMessages
      parameters:
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/before"
        - $ref: "#/components/parameters/after"
        - name: around
          in: query
          schema: { $ref: "#/components/schemas/Snowflake" }
      responses:
        "200":
          description: Сообщения от новых к старым
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Message" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Messages]
      summary: Отправить сообщение
      description: |
        Требует SEND_MESSAGES (ATTACH_FILES для вложений, MENTION_EVERYONE для @everyone).
        Лимиты: 2 000 символов (4 000 с «Кличат Плюс»), 10 вложений, 5 сообщений за 5 с на канал.
        `nonce` возвращается в событии MESSAGE_CREATE, чтобы клиент сопоставил оптимистичное сообщение.
      operationId: createMessage
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MessageCreate" }
      responses:
        "200":
          description: Созданное сообщение (200, как у Discord)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /channels/{channelId}/messages/{messageId}:
    parameters:
      - $ref: "#/components/parameters/channelId"
      - $ref: "#/components/parameters/messageId"
    get:
      tags: [Messages]
      summary: Сообщение
      operationId: getMessage
      responses:
        "200":
          description: Сообщение
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Messages]
      summary: Редактировать своё сообщение или скрыть превью
      description: |
        `content` меняет только автор. `flags` (бит 4, SUPPRESS_EMBEDS) — автор или участник с MANAGE_MESSAGES
        на сервере: скрытие очищает `embeds`, снятие флага превью не восстанавливает. Нужно хотя бы одно из полей.
      operationId: updateMessage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                content: { type: string, maxLength: 4000 }
                flags: { type: integer, description: "4 — скрыть превью ссылок (SUPPRESS_EMBEDS); остальные биты игнорируются" }
                embeds:
                  type: array
                  items: { $ref: "#/components/schemas/Embed" }
                attachments:
                  type: array
                  description: Оставшиеся вложения; отсутствующие удаляются
                  items:
                    type: object
                    required: [id]
                    properties:
                      id: { $ref: "#/components/schemas/Snowflake" }
      responses:
        "200":
          description: Сообщение после правки
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "403":
          description: Чужое сообщение (50005)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [Messages]
      summary: Удалить сообщение
      description: Своё — всегда; чужое — с MANAGE_MESSAGES.
      operationId: deleteMessage
      parameters:
        - $ref: "#/components/parameters/auditReason"
      responses:
        "204": { description: Сообщение удалено }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /channels/{channelId}/messages/bulk-delete:
    parameters:
      - $ref: "#/components/parameters/channelId"
    post:
      tags: [Messages]
      summary: Массовое удаление (2–100 сообщений не старше 14 дней)
      description: Требует MANAGE_MESSAGES.
      operationId: bulkDeleteMessages
      parameters:
        - $ref: "#/components/parameters/auditReason"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [messages]
              properties:
                messages:
                  type: array
                  minItems: 2
                  maxItems: 100
                  items: { $ref: "#/components/schemas/Snowflake" }
      responses:
        "204": { description: Сообщения удалены }
        "403": { $ref: "#/components/responses/Forbidden" }

  /channels/{channelId}/messages/{messageId}/reactions/{emoji}/@me:
    parameters:
      - $ref: "#/components/parameters/channelId"
      - $ref: "#/components/parameters/messageId"
      - $ref: "#/components/parameters/emoji"
    put:
      tags: [Messages]
      summary: Поставить реакцию
      description: Требует ADD_REACTIONS для первой реакции с данным эмодзи. Лимит — 20 разных эмодзи (30010).
      operationId: addReaction
      responses:
        "204": { description: Реакция добавлена }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Messages]
      summary: Снять свою реакцию
      operationId: removeOwnReaction
      responses:
        "204": { description: Реакция снята }

  /channels/{channelId}/messages/{messageId}/reactions/{emoji}:
    parameters:
      - $ref: "#/components/parameters/channelId"
      - $ref: "#/components/parameters/messageId"
      - $ref: "#/components/parameters/emoji"
    get:
      tags: [Messages]
      summary: Кто поставил реакцию
      operationId: listReactionUsers
      parameters:
        - $ref: "#/components/parameters/limit"
        - $ref: "#/components/parameters/after"
      responses:
        "200":
          description: Пользователи
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/User" }
    delete:
      tags: [Messages]
      summary: Снять все реакции с этим эмодзи
      description: Требует MANAGE_MESSAGES.
      operationId: removeReactionsForEmoji
      responses:
        "204": { description: Реакции сняты }
        "403": { $ref: "#/components/responses/Forbidden" }

  /channels/{channelId}/messages/{messageId}/reactions:
    parameters:
      - $ref: "#/components/parameters/channelId"
      - $ref: "#/components/parameters/messageId"
    delete:
      tags: [Messages]
      summary: Снять все реакции
      description: Требует MANAGE_MESSAGES.
      operationId: removeAllReactions
      responses:
        "204": { description: Реакции сняты }
        "403": { $ref: "#/components/responses/Forbidden" }

  /channels/{channelId}/messages/{messageId}/ack:
    parameters:
      - $ref: "#/components/parameters/channelId"
      - $ref: "#/components/parameters/messageId"
    post:
      tags: [Messages]
      summary: Отметить прочитанным до этого сообщения
      operationId: ackMessage
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                mention_count: { type: integer, minimum: 0 }
      responses:
        "204": { description: "read_state обновлён, остальным сессиям уйдёт MESSAGE_ACK" }

  /channels/{channelId}/messages/{messageId}/threads:
    parameters:
      - $ref: "#/components/parameters/channelId"
      - $ref: "#/components/parameters/messageId"
    post:
      tags: [Channels]
      summary: Создать ветку от сообщения
      description: |
        Нужны VIEW_CHANNEL и CREATE_PUBLIC_THREADS в канале. Id ветки равен id сообщения; у сообщения появляются
        флаг 32 (HAS_THREAD) и поле `thread` (MESSAGE_UPDATE). У одного сообщения только одна ветка (160004).
        Автор становится участником; писать в ветке разрешает SEND_MESSAGES_IN_THREADS.
      operationId: createThreadFromMessage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 100 }
                auto_archive_duration: { type: integer, enum: [60, 1440, 4320, 10080], default: 1440 }
      responses:
        "201":
          description: Канал-тред
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Channel" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /channels/{channelId}/threads:
    parameters:
      - $ref: "#/components/parameters/channelId"
    post:
      tags: [Channels]
      summary: Создать ветку без сообщения
      description: В канал уходит системное сообщение типа 18 с названием ветки; id сообщения равен id ветки.
      operationId: createThread
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 100 }
                auto_archive_duration: { type: integer, enum: [60, 1440, 4320, 10080], default: 1440 }
      responses:
        "201":
          description: Канал-тред
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Channel" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /channels/{channelId}/threads/archived/public:
    parameters:
      - $ref: "#/components/parameters/channelId"
    get:
      tags: [Channels]
      summary: Архивные ветки канала
      description: От новых к старым по времени архивации; нужны VIEW_CHANNEL и READ_MESSAGE_HISTORY.
      operationId: listArchivedThreads
      parameters:
        - name: before
          in: query
          schema: { type: string, format: date-time }
          description: archive_timestamp последней полученной ветки
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        "200":
          description: Ветки и признак продолжения
          content:
            application/json:
              schema:
                type: object
                required: [threads, members, has_more]
                properties:
                  threads:
                    type: array
                    items: { $ref: "#/components/schemas/Channel" }
                  members:
                    type: array
                    items: { $ref: "#/components/schemas/ThreadMember" }
                  has_more: { type: boolean }
        "403": { $ref: "#/components/responses/Forbidden" }

  /channels/{channelId}/thread-members:
    parameters:
      - $ref: "#/components/parameters/channelId"
    get:
      tags: [Channels]
      summary: Участники ветки
      operationId: listThreadMembers
      responses:
        "200":
          description: Участники с профилями
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ThreadMember" }

  /channels/{channelId}/thread-members/@me:
    parameters:
      - $ref: "#/components/parameters/channelId"
    put:
      tags: [Channels]
      summary: Присоединиться к ветке
      description: Автор ветки и все, кто в ней пишет, становятся участниками сами; в архивную ветку войти нельзя (50083).
      operationId: joinThread
      responses:
        "204": { description: Участник ветки }
        "400": { $ref: "#/components/responses/ValidationError" }
    delete:
      tags: [Channels]
      summary: Покинуть ветку
      operationId: leaveThread
      responses:
        "204": { description: Больше не участник }

  /guilds/{guildId}/threads/active:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Guilds]
      summary: Активные ветки сервера
      description: Незаархивированные ветки в каналах, где у участника есть VIEW_CHANNEL, и его участие в них.
      operationId: listActiveThreads
      responses:
        "200":
          description: Ветки и участие запрашивающего
          content:
            application/json:
              schema:
                type: object
                required: [threads, members, has_more]
                properties:
                  threads:
                    type: array
                    items: { $ref: "#/components/schemas/Channel" }
                  members:
                    type: array
                    items: { $ref: "#/components/schemas/ThreadMember" }
                  has_more: { type: boolean }

  /channels/{channelId}/pins:
    parameters:
      - $ref: "#/components/parameters/channelId"
    get:
      tags: [Channels]
      summary: Закреплённые сообщения
      operationId: listPins
      responses:
        "200":
          description: До 50 сообщений
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Message" }

  /channels/{channelId}/pins/{messageId}:
    parameters:
      - $ref: "#/components/parameters/channelId"
      - $ref: "#/components/parameters/messageId"
    put:
      tags: [Channels]
      summary: Закрепить сообщение
      description: Требует MANAGE_MESSAGES. Лимит — 50 закрепов (30003).
      operationId: pinMessage
      parameters:
        - $ref: "#/components/parameters/auditReason"
      responses:
        "204": { description: Закреплено }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Channels]
      summary: Открепить сообщение
      operationId: unpinMessage
      responses:
        "204": { description: Откреплено }
        "403": { $ref: "#/components/responses/Forbidden" }

  /channels/{channelId}/typing:
    parameters:
      - $ref: "#/components/parameters/channelId"
    post:
      tags: [Messages]
      summary: Индикатор «печатает» на 10 секунд
      operationId: triggerTyping
      responses:
        "204": { description: Участникам канала уйдёт TYPING_START }

  /channels/{channelId}/invites:
    parameters:
      - $ref: "#/components/parameters/channelId"
    post:
      tags: [Invites]
      summary: Создать приглашение
      description: Требует CREATE_INSTANT_INVITE. Лимит — 5 в минуту, 1 000 активных на сервер (30016).
      operationId: createChannelInvite
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                max_age: { type: integer, minimum: 0, maximum: 604800, default: 86400, description: "0 — бессрочно" }
                max_uses: { type: integer, minimum: 0, maximum: 100, default: 0 }
                temporary: { type: boolean, default: false }
                unique: { type: boolean, default: false }
      responses:
        "201":
          description: Приглашение
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invite" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /channels/{channelId}/attachments:
    parameters:
      - $ref: "#/components/parameters/channelId"
    post:
      tags: [Messages]
      summary: Получить presigned URL для загрузки вложений
      description: |
        Нужны SEND_MESSAGES и ATTACH_FILES. Клиент загружает файл напрямую в S3 по `upload_url` (PUT с тем же
        Content-Type), затем ссылается на `upload_filename` в `attachments[].uploaded_filename` при отправке сообщения
        (content может быть пустым). Лимиты бесплатного тарифа: 25 МБ, 10 файлов, исполняемые файлы не принимаются;
        незавершённые загрузки удаляются через час. Размер и тип проверяются по объекту в S3 при отправке.
      operationId: createAttachmentUploads
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [files]
              properties:
                files:
                  type: array
                  minItems: 1
                  maxItems: 10
                  items:
                    type: object
                    required: [filename, file_size]
                    properties:
                      id: { type: string, description: Локальный id для сопоставления }
                      filename: { type: string, maxLength: 255 }
                      file_size: { type: integer, minimum: 1 }
                      content_type: { type: string }
      responses:
        "200":
          description: Ссылки для загрузки, действуют 10 минут
          content:
            application/json:
              schema:
                type: object
                required: [attachments]
                properties:
                  attachments:
                    type: array
                    items:
                      type: object
                      required: [id, upload_url, upload_filename]
                      properties:
                        id: { type: string }
                        upload_url: { type: string, format: uri }
                        upload_filename: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "413":
          description: Файл слишком большой (40005)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /channels/{channelId}/voice/join:
    parameters:
      - $ref: "#/components/parameters/channelId"
    post:
      tags: [Voice]
      summary: Войти в голосовой канал
      description: |
        Требует CONNECT (и SPEAK, чтобы говорить). Возвращает адрес узла LiveKit и JWT комнаты
        (комната = channel_id). Участникам сервера уйдёт VOICE_STATE_UPDATE (спецификация 5.4).
        В ЛС и группах тот же вызов начинает звонок или присоединяет к идущему: первый вошедший создаёт
        системное сообщение типа 3 и `CALL_CREATE` с `ringing` (все остальные получатели), следующие — `CALL_UPDATE`;
        выход последнего — `CALL_DELETE` и `MESSAGE_UPDATE` с `call.ended_timestamp`. `guild_id` в ответе — null.
      operationId: joinVoiceChannel
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                self_mute: { type: boolean, default: false }
                self_deaf: { type: boolean, default: false }
                session_id: { type: string, description: "Сессия шлюза (session_id из READY): её отключение снимает голосовое состояние" }
                region: { type: string, description: "Предпочитаемый регион SFU, по умолчанию ближайший" }
      responses:
        "200":
          description: Параметры подключения к SFU; повторный вход в тот же канал обновляет токен, вход в другой — переводит
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VoiceJoin" }
        "400":
          description: Не голосовой канал (50024)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Нет права CONNECT (50013) или канал полон (50013, «Voice channel is full»; MOVE_MEMBERS обходит лимит)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /channels/{channelId}/call:
    parameters:
      - $ref: "#/components/parameters/channelId"
    get:
      tags: [Voice]
      operationId: getCall
      summary: Идущий звонок в ЛС или группе
      responses:
        "200":
          description: Звонок с состояниями участников
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Call" }
        "400": { description: Не личные сообщения (50024) }
        "403": { description: Не получатель канала (50013) }
        "404": { description: Звонка нет }

  /channels/{channelId}/call/ring:
    parameters:
      - $ref: "#/components/parameters/channelId"
    post:
      tags: [Voice]
      operationId: ringCall
      summary: Позвонить получателям ещё раз
      description: Нужно самому быть в звонке. `recipients` null — всем, кто не в звонке. Звонок длится 60 секунд, потом `CALL_UPDATE` с пустым `ringing`.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                recipients:
                  type: [array, "null"]
                  items: { $ref: "#/components/schemas/Snowflake" }
      responses:
        "204": { description: Уйдёт CALL_UPDATE }
        "400": { description: Вы не в звонке (40032) }
        "404": { description: Звонка нет }

  /channels/{channelId}/call/stop-ringing:
    parameters:
      - $ref: "#/components/parameters/channelId"
    post:
      tags: [Voice]
      operationId: stopRinging
      summary: Перестать звонить
      description: "`recipients` null — отклонить входящий звонок самому; иначе перестать звонить перечисленным."
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                recipients:
                  type: [array, "null"]
                  items: { $ref: "#/components/schemas/Snowflake" }
      responses:
        "204": { description: Уйдёт CALL_UPDATE }
        "404": { description: Звонка нет }

  /users/@me/voice:
    get:
      tags: [Voice]
      summary: Своё голосовое состояние
      operationId: getMyVoiceState
      responses:
        "200":
          description: Состояние
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VoiceState" }
        "404":
          description: Не в голосовом канале (40032)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    patch:
      tags: [Voice]
      summary: Свои микрофон, звук, экран, камера
      operationId: updateMyVoiceState
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                self_mute: { type: boolean }
                self_deaf: { type: boolean }
                self_stream: { type: boolean }
                self_video: { type: boolean }
      responses:
        "200":
          description: Состояние; участникам уходит VOICE_STATE_UPDATE
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VoiceState" }
        "400":
          description: Не в голосовом канале (40032)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [Voice]
      summary: Выйти из голосового канала
      description: "То же, что op 4 с channel_id null. Без состояния — тоже 204."
      operationId: leaveVoice
      responses:
        "204": { description: Вышли }

  /voice/webhook:
    post:
      tags: [Voice]
      summary: Вебхук LiveKit
      description: |
        Только для сервера LiveKit: подпись проверяется ключом API (заголовок Authorization с JWT).
        participant_joined подтверждает вход, participant_left и room_finished снимают состояния.
      operationId: voiceWebhook
      security: []
      responses:
        "204": { description: Принято }
        "401": { description: Подпись неверна }

  /voice/regions:
    get:
      tags: [Voice]
      summary: Регионы SFU
      operationId: listVoiceRegions
      responses:
        "200":
          description: Регионы
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [id, name, optimal]
                  properties:
                    id: { type: string, examples: [msk, nsk] }
                    name: { type: string, examples: [Москва, Новосибирск] }
                    optimal: { type: boolean, description: Ближайший к клиенту по измеренному RTT }
                    deprecated: { type: boolean }

  # ---------------------------------------------------------------- Invites
  /invites/{code}:
    parameters:
      - $ref: "#/components/parameters/inviteCode"
    get:
      tags: [Invites]
      summary: Превью приглашения
      operationId: getInvite
      security: []
      parameters:
        - name: with_counts
          in: query
          schema: { type: boolean, default: true }
      responses:
        "200":
          description: Приглашение с кратким объектом сервера
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invite" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Invites]
      summary: Принять приглашение
      description: Ботам недоступно (20001). Забаненным — 40007. Проверка уровня верификации сервера — 40002.
      operationId: acceptInvite
      responses:
        "200":
          description: Приглашение и сервер, куда вступил пользователь
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invite" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Invites]
      summary: Отозвать приглашение
      description: Создатель приглашения или MANAGE_CHANNELS / MANAGE_GUILD.
      operationId: deleteInvite
      parameters:
        - $ref: "#/components/parameters/auditReason"
      responses:
        "200":
          description: Отозванное приглашение
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invite" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ---------------------------------------------------------------- Auto Moderation
  /guilds/{guildId}/auto-moderation/rules:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Guilds]
      operationId: listAutoModerationRules
      summary: Правила автомода сервера
      description: Требует MANAGE_GUILD. Проверка выполняется при отправке и правке сообщений; администраторы сервера не проверяются.
      responses:
        "200":
          description: Список правил
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AutoModerationRule" }
        "403": { description: Нет права MANAGE_GUILD (50013) }
    post:
      tags: [Guilds]
      operationId: createAutoModerationRule
      summary: Создать правило автомода
      description: |
        Лимиты как у Discord: 6 правил KEYWORD, по одному SPAM, KEYWORD_PRESET и MENTION_SPAM; до 1000 ключевых слов по 60 символов,
        10 регулярных выражений RE2 по 260 символов, до 100 исключений (1000 для списков), лимит упоминаний 1–50, таймаут до 28 дней
        (только для KEYWORD, KEYWORD_PRESET и MENTION_SPAM), канал оповещений — текстовый канал сервера.
        Ключевые слова: `слово` — целиком, `слово*` — начало, `*слово` — конец, `*слово*` — где угодно.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AutoModerationRuleInput" }
      responses:
        "200":
          description: Правило создано; участникам уйдёт AUTO_MODERATION_RULE_CREATE
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutoModerationRule" }
        "400": { description: Ошибка формы (50035) или лимит правил }
  /guilds/{guildId}/auto-moderation/rules/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/guildId"
      - name: ruleId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Snowflake" }
    get:
      tags: [Guilds]
      operationId: getAutoModerationRule
      summary: Правило автомода
      responses:
        "200":
          description: Правило
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutoModerationRule" }
        "404": { description: Правила нет }
    patch:
      tags: [Guilds]
      operationId: updateAutoModerationRule
      summary: Изменить правило автомода
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AutoModerationRuleInput" }
      responses:
        "200":
          description: Правило изменено; AUTO_MODERATION_RULE_UPDATE
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutoModerationRule" }
        "404": { description: Правила нет }
    delete:
      tags: [Guilds]
      operationId: deleteAutoModerationRule
      summary: Удалить правило автомода
      responses:
        "204": { description: Удалено; AUTO_MODERATION_RULE_DELETE }
        "404": { description: Правила нет }

  # ---------------------------------------------------------------- Moderation (staff)
  /moderation/reports:
    get:
      tags: [Reports]
      operationId: moderationReports
      summary: Очередь жалоб (сотрудники)
      description: Только для аккаунтов с битом STAFF в `flags` (`klichat staff <username>`); иначе 403/50001. Новые первыми, `before` — id для следующей страницы.
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [open, resolved, rejected], default: open } }
        - { name: before, in: query, schema: { $ref: "#/components/schemas/Snowflake" } }
        - { name: limit, in: query, schema: { type: integer, maximum: 100, default: 50 } }
      responses:
        "200":
          description: Жалобы со снимками объектов
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ModerationReport" }
        "403": { description: Не сотрудник (50001) }
  /moderation/reports/{reportId}:
    parameters:
      - name: reportId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Snowflake" }
    patch:
      tags: [Reports]
      operationId: moderationResolveReport
      summary: Закрыть жалобу
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [resolved, rejected] }
                resolution: { type: string, maxLength: 500 }
      responses:
        "200":
          description: Закрыта; запись в журнале
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModerationReport" }
        "400": { description: Уже закрыта или неверный статус }
        "404": { description: Жалобы нет }
  /moderation/users/search:
    get:
      tags: [Users]
      operationId: moderationFindUser
      summary: Карточка аккаунта по имени (сотрудники)
      parameters:
        - { name: username, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: Карточка
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModerationUser" }
        "404": { description: Нет такого аккаунта }
  /moderation/users/{userId}:
    parameters:
      - $ref: "#/components/parameters/userId"
    get:
      tags: [Users]
      operationId: moderationUser
      summary: Карточка аккаунта (сотрудники)
      responses:
        "200":
          description: Карточка
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModerationUser" }
        "404": { description: Нет такого аккаунта }
  /moderation/users/{userId}/ban:
    parameters:
      - $ref: "#/components/parameters/userId"
    put:
      tags: [Users]
      operationId: moderationBan
      summary: Заблокировать аккаунт
      description: Все сессии отзываются; вход и запросы с токеном отвечают 403/40007 с причиной и сроком. `until` null — бессрочно.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 500 }
                until: { type: [string, "null"], format: date-time }
      responses:
        "200":
          description: Карточка после блокировки
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModerationUser" }
        "400": { description: Нельзя применить к себе (50035) или срок вне допустимого }
    delete:
      tags: [Users]
      operationId: moderationUnban
      summary: Снять блокировку
      responses:
        "200":
          description: Карточка
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModerationUser" }
  /moderation/users/{userId}/restriction:
    parameters:
      - $ref: "#/components/parameters/userId"
    put:
      tags: [Users]
      operationId: moderationRestrict
      summary: Теневое ограничение
      description: До срока (не больше 90 дней) аккаунт не может отправлять заявки в друзья и создавать приглашения (403/20025); остальное работает.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [until]
              properties:
                reason: { type: string, maxLength: 500 }
                until: { type: string, format: date-time }
      responses:
        "200":
          description: Карточка
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModerationUser" }
    delete:
      tags: [Users]
      operationId: moderationUnrestrict
      summary: Снять ограничение
      responses:
        "200":
          description: Карточка
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModerationUser" }
  /moderation/messages/{channelId}/{messageId}:
    parameters:
      - $ref: "#/components/parameters/channelId"
      - $ref: "#/components/parameters/messageId"
    delete:
      tags: [Messages]
      operationId: moderationDeleteMessage
      summary: Удалить любое сообщение (сотрудники)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 500 }
      responses:
        "204": { description: "Удалено; MESSAGE_DELETE участникам, запись в журнале" }
        "404": { description: Сообщения нет }
  /moderation/actions:
    get:
      tags: [Reports]
      operationId: moderationActions
      summary: Журнал действий сотрудников
      parameters:
        - { name: before, in: query, schema: { $ref: "#/components/schemas/Snowflake" } }
        - { name: limit, in: query, schema: { type: integer, maximum: 100, default: 50 } }
      responses:
        "200":
          description: Действия, новые первыми
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ModerationAction" }

  # ---------------------------------------------------------------- Push
  /users/@me/push:
    get:
      tags: [Users]
      operationId: getPushSubscriptions
      summary: VAPID-ключ и push-подписки пользователя
      description: Подписки привязаны к сессии — выход из неё или отзыв убирают их.
      responses:
        "200":
          description: Ключ и подписки
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PushInfo" }
        "401": { description: Нет токена }
    put:
      tags: [Users]
      operationId: putPushSubscription
      summary: Сохранить push-подписку текущего браузера
      description: Повтор с тем же `endpoint` обновляет ключи и сессию. До 20 подписок на пользователя.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PushSubscriptionInput" }
      responses:
        "200":
          description: Подписка сохранена
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PushSubscriptionInfo" }
        "400":
          description: "Push выключен на сервере (код 40006), некорректная подписка (50035) или слишком много подписок"
    delete:
      tags: [Users]
      operationId: deletePushSubscription
      summary: Удалить push-подписку
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [endpoint]
              properties:
                endpoint: { type: string, format: uri }
      responses:
        "204": { description: Удалено }
        "404": { description: Такой подписки нет }

  # ---------------------------------------------------------------- Reports
  /reports:
    post:
      tags: [Reports]
      summary: Пожаловаться на сообщение, пользователя или сервер
      operationId: createReport
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_type, target_id, category]
              properties:
                target_type: { type: string, enum: [message, user, guild] }
                target_id: { $ref: "#/components/schemas/Snowflake" }
                category:
                  type: string
                  enum: [spam, fraud, harassment, nsfw, violence, csam, extremism, copyright, other]
                text: { type: string, maxLength: 1000 }
      description: |
        Объект должен быть виден жалующемуся: сообщение — в доступном канале, сервер — где он участник.
        На себя, свои сообщения и свой сервер пожаловаться нельзя (50035). Со снимком объекта жалоба попадает
        в очередь платформенной модерации (`klichat reports` до появления панели). Лимит — 10 жалоб в час.
      responses:
        "201":
          description: Жалоба принята в очередь модерации
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportResult" }
        "200":
          description: "Открытая жалоба от этого аккаунта на объект уже есть (existing: true), новая не создаётся"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportResult" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404":
          description: Объект не найден или не виден
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /attachments/{sig}/{id}/{filename}:
    get:
      tags: [Messages]
      summary: Скачать вложение
      description: |
        Подписанная ссылка из `attachments[].url` объекта сообщения: без авторизации, но только с верной подписью.
        Картинки, видео, аудио, PDF и текст отдаются inline, остальное — как скачивание. Файл читается из S3 через API.
      operationId: getAttachment
      security: []
      parameters:
        - name: sig
          in: path
          required: true
          schema: { type: string }
        - name: id
          in: path
          required: true
          schema: { $ref: "#/components/schemas/Snowflake" }
        - name: filename
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Файл
          content:
            "*/*":
              schema: { type: string, format: binary }
        "404":
          description: Подпись неверна или вложения нет
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /external/{sig}/{url}:
    get:
      tags: [Messages]
      summary: Картинка превью через прокси
      description: |
        Отдаёт внешнюю картинку из `embed.*.proxy_url`: без авторизации (её нет у `<img>`), но только по HMAC-подписи
        сервера, поэтому открытым прокси эндпоинт не является. Только `image/*` (не SVG), до 8 МиБ, кэш сутки.
        Внешний сайт видит адрес сервера, а не пользователя (спецификация 7.2).
      operationId: externalImage
      security: []
      parameters:
        - name: sig
          in: path
          required: true
          schema: { type: string, description: "base64url(HMAC-SHA256 от второго сегмента)" }
        - name: url
          in: path
          required: true
          schema: { type: string, description: "base64url исходного адреса" }
      responses:
        "200":
          description: Картинка
          content:
            image/*:
              schema: { type: string, format: binary }
        "404":
          description: Подпись неверна или картинка недоступна
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "415":
          description: По адресу не картинка
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ---------------------------------------------------------------- Import (Discord)
  /guilds/{guildId}/imports:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Guilds]
      summary: Задания импорта сервера
      operationId: listGuildImports
      responses:
        "200":
          description: Последние 20 заданий
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ImportJob" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /guilds/{guildId}/imports/discord:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Guilds]
      summary: "Перенос из Discord: состояние входа и серверы владельца"
      operationId: getDiscordImport
      responses:
        "200":
          description: Настроен ли перенос, ссылка входа и серверы, где владелец может переносить
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
                  authorize_url: { type: string, format: uri }
                  linked: { type: boolean }
                  guilds:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        icon: { type: [string, "null"] }
                        owner: { type: boolean }
                        can_import: { type: boolean }
                        bot_present: { type: boolean, description: Бот Кличата уже на сервере }
    post:
      tags: [Guilds]
      summary: Снять структуру сервера Discord и завести задание
      description: Бот Кличата должен быть на исходном сервере. Коды переноса отдаются один раз.
      operationId: createDiscordImport
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [source_guild_id]
              properties:
                source_guild_id: { type: string }
                claims: { type: boolean, description: Сохранить роли участников под кодами переноса }
      responses:
        "201":
          description: Задание и коды переноса
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: "#/components/schemas/ImportJob" }
                  codes:
                    type: array
                    items:
                      type: object
                      properties:
                        username: { type: string }
                        display_name: { type: string }
                        code: { type: string }
        "400": { description: Нет прав на сервере Discord или бот ещё не добавлен }
        "501": { description: Перенос из Discord не настроен }

  /discord/link:
    post:
      tags: [Guilds]
      summary: Возврат из Discord после входа
      operationId: discordLink
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
                state: { type: string }
      responses:
        "200":
          description: Сервер Кличата из подписанного state и серверы владельца в Discord
          content:
            application/json:
              schema:
                type: object
                properties:
                  guild_id: { $ref: "#/components/schemas/Snowflake" }
  /discord/invite:
    get:
      tags: [Guilds]
      summary: Ссылка добавления бота Кличата на сервер Discord
      operationId: discordInvite
      parameters:
        - { name: guild_id, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: Ссылка
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }

  /guilds/{guildId}/imports/{jobId}:
    parameters:
      - $ref: "#/components/parameters/guildId"
      - name: jobId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Snowflake" }
    get:
      tags: [Guilds]
      summary: Задание с предпросмотром или отчётом
      operationId: getGuildImport
      responses:
        "200":
          description: Задание
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportJob" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Guilds]
      summary: Отменить задание
      description: Незапущенное или завершённое; архив удаляется из хранилища.
      operationId: cancelGuildImport
      responses:
        "204": { description: Отменено }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404": { $ref: "#/components/responses/NotFound" }

  /guilds/{guildId}/imports/{jobId}/start:
    post:
      tags: [Guilds]
      summary: Запустить импорт с выбором
      description: |
        Статус `ready` или `failed`. Пустой список означает «всё»; переносятся роли → категории → каналы с ролевыми перекрытиями → ветки →
        эмодзи → стикеры → настройки → коды переноса. Прогресс — GUILD_IMPORT_UPDATE `{import: {status, progress}}`, результат — `report`.
      operationId: startGuildImport
      parameters:
        - $ref: "#/components/parameters/guildId"
        - name: jobId
          in: path
          required: true
          schema: { $ref: "#/components/schemas/Snowflake" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ImportOptions" }
      responses:
        "200":
          description: Задание в статусе running
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportJob" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404": { $ref: "#/components/responses/NotFound" }

  /guilds/{guildId}/import-claims:
    post:
      tags: [Guilds]
      summary: Код переноса из Discord
      description: Участник вводит код из claims-codes.csv экспортёра и получает роли и личные перекрытия прав; код одноразовый, 90 дней, 5 попыток в час.
      operationId: claimGuildImport
      parameters:
        - $ref: "#/components/parameters/guildId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, examples: [ABCD-EFGH-JKLM] }
      responses:
        "200":
          description: Выданные роли
          content:
            application/json:
              schema:
                type: object
                required: [roles]
                properties:
                  roles:
                    type: array
                    items: { $ref: "#/components/schemas/Role" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------- Stickers
  /guilds/{guildId}/stickers:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Guilds]
      summary: Стикеры сервера
      operationId: listGuildStickers
      responses:
        "200":
          description: Список стикеров
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Sticker" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Guilds]
      summary: Добавить стикер
      description: MANAGE_GUILD_EXPRESSIONS; PNG, APNG или GIF до 512 КБ в data URI, имя 2–30 символов; 5 слотов базово (400/30039). Аудит 90, событие GUILD_STICKERS_UPDATE.
      operationId: createGuildSticker
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, file]
              properties:
                name: { type: string, minLength: 2, maxLength: 30 }
                description: { type: string, maxLength: 100 }
                tags: { type: string, maxLength: 200, description: "Ключевые слова через запятую" }
                file: { type: string, description: "data:image/png;base64,… (PNG, APNG, GIF)" }
      responses:
        "201":
          description: Созданный стикер
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Sticker" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /guilds/{guildId}/stickers/{stickerId}:
    parameters:
      - $ref: "#/components/parameters/guildId"
      - name: stickerId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Snowflake" }
    get:
      tags: [Guilds]
      summary: Стикер
      operationId: getGuildSticker
      responses:
        "200":
          description: Стикер
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Sticker" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Guilds]
      summary: Изменить стикер
      description: Имя, описание, теги (MANAGE_GUILD_EXPRESSIONS). Аудит 91.
      operationId: updateGuildSticker
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 2, maxLength: 30 }
                description: { type: string, maxLength: 100 }
                tags: { type: string, maxLength: 200 }
      responses:
        "200":
          description: Обновлённый стикер
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Sticker" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Guilds]
      summary: Удалить стикер
      description: Файл удаляется из S3, в старых сообщениях стикер пропадает. Аудит 92.
      operationId: deleteGuildSticker
      responses:
        "204": { description: Удалён }
        "404": { $ref: "#/components/responses/NotFound" }

  /stickers/{sticker}:
    get:
      tags: [Guilds]
      summary: Картинка стикера
      description: "`/stickers/{id}.png` или `.gif` — публично, с долгим кэшем."
      operationId: getStickerImage
      security: []
      parameters:
        - name: sticker
          in: path
          required: true
          schema: { type: string, examples: ["1547686336335450112.png"] }
      responses:
        "200":
          description: Картинка
          content:
            image/png: { schema: { type: string, format: binary } }
            image/gif: { schema: { type: string, format: binary } }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---------------------------------------------------------------- Webhooks
  # ---------------------------------------------------------------- Webhooks
  /channels/{channelId}/webhooks:
    parameters:
      - $ref: "#/components/parameters/channelId"
    get:
      tags: [Webhooks]
      summary: Вебхуки канала
      description: MANAGE_WEBHOOKS в канале; в объектах есть `token` и `url`.
      operationId: listChannelWebhooks
      responses:
        "200":
          description: Список вебхуков
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Webhook" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Webhooks]
      summary: Создать вебхук
      description: MANAGE_WEBHOOKS в текстовом канале сервера; до 15 вебхуков на канал (400/30007). Аудит 50, событие WEBHOOKS_UPDATE.
      operationId: createWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
                avatar: { type: string, description: "data URI; пока не сохраняется" }
      responses:
        "200":
          description: Вебхук с токеном
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /guilds/{guildId}/webhooks:
    parameters:
      - $ref: "#/components/parameters/guildId"
    get:
      tags: [Webhooks]
      summary: Вебхуки сервера
      operationId: listGuildWebhooks
      responses:
        "200":
          description: Список вебхуков всех каналов
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Webhook" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /webhooks/{webhookId}:
    parameters:
      - name: webhookId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Snowflake" }
    get:
      tags: [Webhooks]
      summary: Вебхук
      operationId: getWebhook
      responses:
        "200":
          description: Вебхук
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Webhooks]
      summary: Изменить вебхук
      description: Имя и перенос в другой текстовый канал того же сервера (MANAGE_WEBHOOKS в обоих). Аудит 51.
      operationId: updateWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
                channel_id: { $ref: "#/components/schemas/Snowflake" }
      responses:
        "200":
          description: Обновлённый вебхук
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Webhooks]
      summary: Удалить вебхук
      operationId: deleteWebhook
      responses:
        "204": { description: Удалён }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhooks/{webhookId}/{webhookToken}:
    parameters:
      - name: webhookId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Snowflake" }
      - name: webhookToken
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Webhooks]
      summary: Вебхук по токену
      description: Без входа; в ответе нет создателя.
      operationId: getWebhookByToken
      security: []
      responses:
        "200":
          description: Вебхук
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Webhooks]
      summary: Переименовать вебхук по токену
      operationId: updateWebhookByToken
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
      responses:
        "200":
          description: Обновлённый вебхук
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    delete:
      tags: [Webhooks]
      summary: Удалить вебхук по токену
      operationId: deleteWebhookByToken
      security: []
      responses:
        "204": { description: Удалён }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Webhooks]
      summary: Исполнить вебхук
      description: |
        Без входа — токен в пути (неверный → 401/50027). Формат тела как у Discord: `content` до 2000 символов или до 10 `embeds`
        (rich-карточки с `fields`), `username` переопределяет имя автора. Автор сообщения — вебхук (`bot: true`, `webhook_id`).
        Упоминания и @everyone разбираются как у участника с MENTION_EVERYONE. 30 сообщений в минуту на вебхук (429).
        `?wait=true` возвращает сообщение, иначе 204.
      operationId: executeWebhook
      security: []
      parameters:
        - name: wait
          in: query
          schema: { type: boolean, default: false }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookExecute" }
      responses:
        "204": { description: Принято (wait=false) }
        "200":
          description: Созданное сообщение (wait=true)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhooks/{webhookId}/{webhookToken}/github:
    post:
      tags: [Webhooks]
      summary: Вебхук GitHub
      description: |
        Принимает стандартный вебхук GitHub (заголовок `X-GitHub-Event`) и превращает его в карточку от имени «GitHub»:
        push (коммиты, force push, новые и удалённые ветки), create/delete, pull_request (открыт, черновик, готов к ревью,
        влит, закрыт), pull_request_review, issues, issue_comment, release, workflow_run, fork, star, discussion.
        ping и незнакомые события — 204 без сообщения.
      operationId: executeGitHubWebhook
      security: []
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: { $ref: "#/components/schemas/Snowflake" }
        - name: webhookToken
          in: path
          required: true
          schema: { type: string }
        - name: X-GitHub-Event
          in: header
          required: true
          schema: { type: string }
        - name: wait
          in: query
          schema: { type: boolean, default: false }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "204": { description: Принято или событие пропущено }
        "200":
          description: Созданное сообщение (wait=true)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhooks/{webhookId}/{webhookToken}/slack:
    post:
      tags: [Webhooks]
      summary: Вебхук Slack
      description: |
        Принимает тело входящего вебхука Slack (`text`, `username`, `icon_url`, `attachments` с `color`, `pretext`, `title`,
        `text`, `fields`, `image_url`, `footer`, `ts`) как JSON или `application/x-www-form-urlencoded` с полем `payload`.
        mrkdwn переводится в markdown (`<url|текст>`, `*жирный*`, `~зачёркнутый~`, `<!channel>` → @everyone). Ответ — текст `ok`.
      operationId: executeSlackWebhook
      security: []
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: { $ref: "#/components/schemas/Snowflake" }
        - name: webhookToken
          in: path
          required: true
          schema: { type: string }
        - name: wait
          in: query
          schema: { type: boolean, default: false }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                payload: { type: string, description: JSON Slack }
      responses:
        "200":
          description: "«ok» текстом; при wait=true — сообщение"
          content:
            text/plain:
              schema: { type: string }
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /webhooks/{webhookId}/{webhookToken}/messages/{messageId}:
    parameters:
      - name: webhookId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Snowflake" }
      - name: webhookToken
        in: path
        required: true
        schema: { type: string }
      - $ref: "#/components/parameters/messageId"
    patch:
      tags: [Webhooks]
      summary: Изменить сообщение вебхука
      operationId: editWebhookMessage
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                content: { type: string, maxLength: 2000 }
                embeds:
                  type: array
                  maxItems: 10
                  items: { $ref: "#/components/schemas/Embed" }
      responses:
        "200":
          description: Обновлённое сообщение
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Webhooks]
      summary: Удалить сообщение вебхука
      operationId: deleteWebhookMessage
      security: []
      responses:
        "204": { description: Удалено }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---------------------------------------------------------------- Applications (боты)
  /applications:
    get:
      tags: [Applications]
      summary: Мои приложения
      description: Приложения текущего пользователя с ботами и числом серверов. Ботам недоступно (403/20001).
      operationId: listApplications
      responses:
        "200":
          description: Список приложений
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Application" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Applications]
      summary: Создать приложение с ботом
      description: |
        Создаёт приложение и его бот-аккаунт (username выводится из названия). Требует принятия соглашения разработчика;
        при `KLICHAT_APPLICATIONS_REQUIRE_MFA` — включённой 2FA (403/60003). Не больше 25 приложений у владельца (400/30032).
        Токен бота возвращается один раз.
      operationId: createApplication
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, developer_agreement]
              properties:
                name: { type: string, minLength: 2, maxLength: 32 }
                description: { type: string, maxLength: 400 }
                developer_agreement: { type: boolean, description: "Принято соглашение разработчика (docs/legal/developer-agreement-outline.md)" }
      responses:
        "201":
          description: Приложение и токен бота
          content:
            application/json:
              schema:
                type: object
                required: [application, token]
                properties:
                  application: { $ref: "#/components/schemas/Application" }
                  token: { type: string, description: "klb_<application_id>.<43 символа base64url>; показывается один раз" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /applications/{appId}:
    parameters:
      - name: appId
        in: path
        required: true
        schema: { $ref: "#/components/schemas/Snowflake" }
    get:
      tags: [Applications]
      summary: Приложение владельца
      operationId: getApplication
      responses:
        "200":
          description: Приложение
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Application" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Applications]
      summary: Изменить приложение
      description: Имя (меняет и отображаемое имя бота), описание, публичность, redirect URI (только https или http://localhost), привилегированные intents.
      operationId: updateApplication
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 2, maxLength: 32 }
                description: { type: string, maxLength: 400 }
                bot_public: { type: boolean }
                bot_require_code_grant: { type: boolean }
                redirect_uris:
                  type: array
                  maxItems: 10
                  items: { type: string, format: uri }
                privileged_intents: { type: integer, description: "Биты 1<<1 GUILD_MEMBERS, 1<<8 GUILD_PRESENCES, 1<<15 MESSAGE_CONTENT" }
      responses:
        "200":
          description: Обновлённое приложение
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Application" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Applications]
      summary: Удалить приложение
      description: Бот покидает все серверы (управляемые роли удаляются), токены отзываются, сессии шлюза закрываются кодом 4004, бот-аккаунт гасится.
      operationId: deleteApplication
      responses:
        "204": { description: Удалено }
        "404": { $ref: "#/components/responses/NotFound" }

  /applications/{appId}/bot/reset:
    post:
      tags: [Applications]
      summary: Перевыпустить токен бота
      description: Старые токены отзываются, сессии шлюза бота закрываются кодом 4004. Новый токен показывается один раз.
      operationId: resetBotToken
      parameters:
        - name: appId
          in: path
          required: true
          schema: { $ref: "#/components/schemas/Snowflake" }
      responses:
        "200":
          description: Новый токен
          content:
            application/json:
              schema:
                type: object
                required: [token]
                properties:
                  token: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }

  /applications/{appId}/guilds:
    get:
      tags: [Applications]
      summary: Серверы, где стоит бот приложения
      operationId: listApplicationGuilds
      parameters:
        - name: appId
          in: path
          required: true
          schema: { $ref: "#/components/schemas/Snowflake" }
      responses:
        "200":
          description: Частичные объекты серверов
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/GuildPartial" }
        "404": { $ref: "#/components/responses/NotFound" }

  /applications/@me:
    get:
      tags: [Applications]
      summary: Приложение текущего бота
      description: Только по токену бота (пользователю — 403/20002). Тот же ответ у `GET /oauth2/applications/@me`, который discord.py читает при `login()`.
      operationId: getOwnApplication
      security:
        - botAuth: []
      responses:
        "200":
          description: Приложение в форме Discord
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Application" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /oauth2/applications/@me:
    get:
      tags: [Applications]
      summary: Приложение текущего бота (псевдоним для библиотек)
      operationId: getOwnApplicationOAuth
      security:
        - botAuth: []
      responses:
        "200":
          description: Приложение в форме Discord
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Application" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /oauth2/@me:
    get:
      tags: [Applications]
      summary: Сведения о текущем токене бота
      operationId: getOAuthMe
      security:
        - botAuth: []
      responses:
        "200":
          description: Приложение, scope и пользователь токена
          content:
            application/json:
              schema:
                type: object
                required: [application, scopes, expires, user]
                properties:
                  application: { $ref: "#/components/schemas/Application" }
                  scopes:
                    type: array
                    items: { type: string }
                  expires: { type: "null" }
                  user: { $ref: "#/components/schemas/User" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /oauth2/authorize:
    parameters:
      - name: client_id
        in: query
        required: true
        schema: { $ref: "#/components/schemas/Snowflake" }
      - name: scope
        in: query
        schema: { type: string, default: bot, description: "Через пробел: bot, applications.commands, identify" }
      - name: permissions
        in: query
        schema: { $ref: "#/components/schemas/Permissions" }
      - name: guild_id
        in: query
        schema: { $ref: "#/components/schemas/Snowflake" }
    get:
      tags: [Applications]
      summary: Данные страницы добавления бота
      description: |
        Приложение, бот и серверы пользователя с MANAGE_GUILD. Приватного бота (`bot_public: false`) видит только владелец приложения.
        Ссылка авторизации в формате Discord ведёт на страницу веб-клиента `/oauth2/authorize?client_id=…&scope=bot&permissions=…&guild_id=…`;
        `GET /api/oauth2/authorize` без версии перенаправляет туда же (такой адрес собирает `generateInvite` в discord.js).
      operationId: getOAuthAuthorize
      responses:
        "200":
          description: Данные для подтверждения
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthorizeInfo" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Applications]
      summary: Добавить бота на сервер
      description: |
        Нужен scope `bot` и право MANAGE_GUILD на сервере. Бот получает управляемую роль с запрошенными правами, урезанными до прав
        приглашающего (ADMINISTRATOR — только от администратора или владельца). Повторная авторизация обновляет права роли.
        События: GUILD_ROLE_CREATE, GUILD_MEMBER_ADD, системное сообщение типа 7, боту — GUILD_CREATE; аудит 28 (BOT_ADD).
      operationId: postOAuthAuthorize
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [guild_id]
              properties:
                guild_id: { $ref: "#/components/schemas/Snowflake" }
                permissions: { $ref: "#/components/schemas/Permissions" }
                authorize: { type: boolean, default: true }
      responses:
        "200":
          description: Бот добавлен
          content:
            application/json:
              schema:
                type: object
                required: [guild_id, permissions, member, location]
                properties:
                  guild_id: { $ref: "#/components/schemas/Snowflake" }
                  permissions: { $ref: "#/components/schemas/Permissions" }
                  member: { $ref: "#/components/schemas/Member" }
                  location: { type: string, format: uri, description: "Адрес сервера в веб-клиенте" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Access-токен пользователя: `Authorization: Bearer <jwt>` (живёт 15 минут)"
    botAuth:
      type: apiKey
      in: header
      name: Authorization
      description: "Токен бота: `Authorization: Bot klb_<application_id>.<секрет>`"

  parameters:
    guildId:
      name: guildId
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Snowflake" }
    channelId:
      name: channelId
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Snowflake" }
    messageId:
      name: messageId
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Snowflake" }
    userId:
      name: userId
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Snowflake" }
    roleId:
      name: roleId
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Snowflake" }
    inviteCode:
      name: code
      in: path
      required: true
      schema: { type: string, pattern: "^[A-Za-z0-9]{8}$" }
    emoji:
      name: emoji
      in: path
      required: true
      description: Unicode-эмодзи (URL-encoded) или `name:id` для кастомного
      schema: { type: string }
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    before:
      name: before
      in: query
      schema: { $ref: "#/components/schemas/Snowflake" }
    after:
      name: after
      in: query
      schema: { $ref: "#/components/schemas/Snowflake" }
    auditReason:
      name: X-Audit-Log-Reason
      in: header
      description: Причина действия, попадает в журнал аудита (до 512 символов)
      schema: { type: string, maxLength: 512 }

  responses:
    Unauthorized:
      description: Нет или просрочен токен (40001)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            unauthorized:
              value: { code: 40001, message: "Unauthorized" }
    Forbidden:
      description: Нет доступа (50001) или прав (50013)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            missingPermissions:
              value: { code: 50013, message: "Missing Permissions" }
    NotFound:
      description: Объект не найден (100xx)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            unknownChannel:
              value: { code: 10003, message: "Unknown Channel" }
    ValidationError:
      description: Ошибка валидации тела (50035)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            invalidFormBody:
              value:
                code: 50035
                message: "Invalid Form Body"
                errors:
                  username:
                    _errors:
                      - code: BASE_TYPE_BAD_LENGTH
                        message: "Must be between 2 and 32 in length."
    RateLimited:
      description: Превышен лимит запросов
      headers:
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
        X-RateLimit-Reset:
          schema: { type: number, description: "Unix-время сброса, секунды" }
        X-RateLimit-Reset-After:
          schema: { type: number, description: Секунды до сброса }
        X-RateLimit-Bucket:
          schema: { type: string }
        X-RateLimit-Global:
          schema: { type: boolean }
        X-RateLimit-Scope:
          schema: { type: string, enum: [user, global, shared] }
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/RateLimited" }

  schemas:
    GifPage:
      type: object
      properties:
        items:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              title: { type: string }
              preview: { $ref: "#/components/schemas/GifImage" }
              full: { $ref: "#/components/schemas/GifImage" }
        has_next: { type: boolean }
    GifImage:
      type: object
      properties:
        url: { type: string, format: uri }
        width: { type: integer }
        height: { type: integer }
    DiscoveryCard:
      type: object
      description: Карточка сервера в каталоге.
      properties:
        guild_id: { $ref: "#/components/schemas/Snowflake" }
        slug: { type: string }
        name: { type: string }
        icon: { type: [string, "null"] }
        banner: { type: [string, "null"] }
        description: { type: string }
        category: { type: string }
        tags: { type: array, items: { type: string } }
        approximate_member_count: { type: integer }
        approximate_presence_count: { type: integer }
        state: { type: string, enum: [listed, auto_hidden, removed] }
        reports_open: { type: integer }
    DiscoverySettings:
      type: object
      properties:
        listed: { type: boolean }
        state: { type: string, enum: [none, listed, auto_hidden, removed] }
        slug: { type: string }
        category: { type: string }
        tags: { type: array, items: { type: string } }
        description: { type: string }
        removed_reason: { type: [string, "null"] }
        url: { type: string }
        problems:
          type: array
          description: Невыполненные условия публикации.
          items:
            type: object
            properties:
              key: { type: string }
              text: { type: string }
    GuildPreview:
      type: object
      description: Сервер для тех, кто ещё не вступил.
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        name: { type: string }
        icon: { type: [string, "null"] }
        banner: { type: [string, "null"] }
        description: { type: [string, "null"] }
        features: { type: array, items: { type: string } }
        approximate_member_count: { type: integer }
        approximate_presence_count: { type: integer }
        emojis: { type: array, items: { $ref: "#/components/schemas/Emoji" } }
        stickers: { type: array, items: { $ref: "#/components/schemas/Sticker" } }
    GuildLimits:
      type: object
      description: Лимиты сервера по уровню от бустов.
      properties:
        tier: { type: integer, enum: [0, 1, 2, 3] }
        emojis: { type: integer }
        stickers: { type: integer }
        bitrate_kbps: { type: integer }
        upload_mb: { type: integer }
    UserLimits:
      type: object
      description: Что даёт подписка «Кличат Плюс».
      properties:
        plus: { type: boolean }
        upload_mb: { type: integer }
        message_len: { type: integer }
        stream_height: { type: integer }
        stream_fps: { type: integer }
        any_emoji: { type: boolean }
        animated_avatar: { type: boolean }
    Boost:
      type: object
      description: Слот буста. guild_id = null — слот свободен.
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        guild_id: { oneOf: [{ $ref: "#/components/schemas/Snowflake" }, { type: "null" }] }
        ends_at: { type: string, format: date-time }
    BillingStatus:
      type: object
      properties:
        plus: { type: boolean }
        plus_until: { type: [string, "null"], format: date-time }
        auto_renew: { type: boolean }
        boosts: { type: array, items: { $ref: "#/components/schemas/Boost" } }
        boosts_free: { type: integer }
        limits: { $ref: "#/components/schemas/UserLimits" }
        provider: { type: string, enum: [none, yookassa] }
        payments:
          type: array
          items:
            type: object
            properties:
              id: { $ref: "#/components/schemas/Snowflake" }
              plan: { type: string }
              amount_rub: { type: integer }
              status: { type: string, enum: [pending, succeeded, canceled] }
              created_at: { type: string, format: date-time }
    GuildPremium:
      type: object
      properties:
        tier: { type: integer, enum: [0, 1, 2, 3] }
        boosts: { type: integer }
        next_tier: { type: [integer, "null"] }
        next_need: { type: [integer, "null"] }
        limits: { $ref: "#/components/schemas/GuildLimits" }
        boosters:
          type: array
          items:
            type: object
            properties:
              user_id: { $ref: "#/components/schemas/Snowflake" }
              username: { type: string }
              since: { type: [string, "null"], format: date-time }
    Snowflake:
      type: string
      pattern: "^\\d{1,20}$"
      description: 64-битный идентификатор строкой
      examples: ["1173629456812345678"]
    SnowflakeNullable:
      type: [string, "null"]
      pattern: "^\\d{1,20}$"
    Permissions:
      type: string
      pattern: "^\\d{1,20}$"
      description: 64-битная маска прав строкой (спецификация 6.3)
      examples: ["3072"]
    Error:
      type: object
      required: [code, message]
      properties:
        code: { type: integer, description: "Код ошибки, как у Discord" }
        message: { type: string }
        errors: { type: object, additionalProperties: true }
    RateLimited:
      type: object
      required: [message, retry_after, global]
      properties:
        message: { type: string }
        retry_after: { type: number, description: Секунды }
        global: { type: boolean }
        code: { type: integer }
    TokenPair:
      type: object
      required: [access_token, refresh_token, expires_in, token_type]
      properties:
        access_token: { type: string }
        refresh_token: { type: string }
        expires_in: { type: integer, description: Секунды жизни access-токена, examples: [900] }
        token_type: { type: string, const: Bearer }
        user: { $ref: "#/components/schemas/CurrentUser" }
    User:
      type: object
      required: [id, username, global_name, avatar]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        username: { type: string }
        global_name: { type: [string, "null"], description: Отображаемое имя }
        discriminator: { type: string, const: "0", description: "Всегда «0», для совместимости с библиотеками" }
        avatar: { type: [string, "null"], description: Хэш аватара }
        banner: { type: [string, "null"] }
        accent_color: { type: [integer, "null"] }
        bot: { type: boolean }
        public_flags: { type: integer }
        premium_type: { type: integer, enum: [0, 2], description: "0 — нет, 2 — «Кличат Плюс»" }
    Profile:
      allOf:
        - $ref: "#/components/schemas/User"
        - type: object
          required: [bio, created_at, premium]
          properties:
            bio: { type: string, description: Строчка о себе }
            created_at: { type: string, format: date-time, description: Когда зарегистрировался }
            premium: { type: boolean, description: Действует ли «Кличат Плюс» }
    DeletionStatus:
      type: object
      required: [deletion_requested_at, delete_at]
      properties:
        deletion_requested_at: { type: [string, "null"], format: date-time }
        delete_at: { type: [string, "null"], format: date-time, description: Когда учётная запись будет удалена }
    DataExport:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [none, pending, ready, failed] }
        requested_at: { type: string, format: date-time }
        finished_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, description: Когда архив удаляется }
        size_bytes: { type: integer }
        url: { type: string, description: Временная ссылка на скачивание (час) }
    CurrentUser:
      allOf:
        - $ref: "#/components/schemas/User"
        - type: object
          required: [phone, verified, mfa_enabled, locale]
          properties:
            phone: { type: string, description: Замаскированный номер, examples: ["+7916***4567"] }
            email: { type: [string, "null"], format: email, description: Подтверждённая почта; null — не привязана }
            verified: { type: boolean, description: Почта подтверждена (имя поля как у Discord) }
            email_pending: { type: [string, "null"], format: email, description: "Адрес, на который ушло письмо со ссылкой, но по ней ещё не перешли" }
            deletion_requested_at: { type: [string, "null"], format: date-time, description: Заявка на удаление учётной записи }
            mfa_enabled: { type: boolean }
            locale: { type: string, examples: [ru] }
            bio: { type: string }
            flags: { type: integer }
            premium_until: { type: [string, "null"], format: date-time }
            nsfw_allowed: { type: boolean, description: "Есть 18 лет по дате рождения: доступ к каналам nsfw" }
            guild_order:
              type: array
              description: Порядок серверов в колонке слева, как его расставил человек
              items: { $ref: "#/components/schemas/Snowflake" }
    UserSettings:
      type: object
      properties:
        theme: { type: string, enum: [dark, light, system] }
        locale: { type: string, enum: [ru, en] }
        status: { type: string, enum: [online, idle, dnd, invisible] }
        custom_status:
          type: [object, "null"]
          properties:
            text: { type: string, maxLength: 128 }
            emoji_id: { $ref: "#/components/schemas/SnowflakeNullable" }
            emoji_name: { type: [string, "null"] }
            expires_at: { type: [string, "null"], format: date-time }
        message_display_compact: { type: boolean }
        font_scale: { type: integer, enum: [12, 14, 15, 16, 18, 20, 24] }
        dm_privacy: { type: string, enum: [everyone, friends, mutual_guilds] }
        friend_requests: { type: string, enum: [everyone, mutual_friends, mutual_guilds, nobody] }
        voice:
          type: object
          properties:
            mode: { type: string, enum: [voice_activity, push_to_talk] }
            input_device_id: { type: string }
            output_device_id: { type: string }
            sensitivity: { type: integer, minimum: -100, maximum: 0 }
            noise_suppression: { type: boolean }
    Session:
      type: object
      required: [id, device, created_at, last_seen_at, current]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        device: { type: string, examples: ["Chrome на macOS"] }
        ip: { type: string, description: Замаскированный IP }
        created_at: { type: string, format: date-time }
        last_seen_at: { type: string, format: date-time }
        current: { type: boolean }
    Relationship:
      type: object
      required: [id, type, user]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        type:
          type: integer
          enum: [1, 2, 3, 4]
          description: "1 — друг, 2 — заблокирован, 3 — входящая заявка, 4 — исходящая заявка"
        user: { $ref: "#/components/schemas/User" }
        since: { type: string, format: date-time }
    GuildPartial:
      type: object
      required: [id, name, icon, owner, permissions]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        name: { type: string }
        icon: { type: [string, "null"] }
        owner: { type: boolean }
        permissions: { $ref: "#/components/schemas/Permissions" }
        features:
          type: array
          items: { type: string }
    Guild:
      type: object
      required: [id, name, icon, owner_id, roles, verification_level]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        name: { type: string }
        icon: { type: [string, "null"] }
        banner: { type: [string, "null"] }
        description: { type: [string, "null"] }
        owner_id: { $ref: "#/components/schemas/Snowflake" }
        verification_level: { type: integer, enum: [0, 1, 2, 3], description: "0 нет; 1 аккаунт старше 24 часов; 2 плюс 5 минут на сервере; 3 плюс 10 минут; владелец, администраторы и участники с ролями не проверяются; отказ — 403/40002" }
        default_message_notifications: { type: integer, enum: [0, 1] }
        system_channel_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        rules_channel_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        afk_channel_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        afk_timeout: { type: integer }
        premium_tier: { type: integer, enum: [0, 1, 2, 3] }
        premium_subscription_count: { type: integer }
        features:
          type: array
          items: { type: string, examples: [DISCOVERABLE, COMMUNITY, VANITY_URL] }
        roles:
          type: array
          items: { $ref: "#/components/schemas/Role" }
        emojis:
          type: array
          items: { $ref: "#/components/schemas/Emoji" }
        approximate_member_count: { type: integer }
        approximate_presence_count: { type: integer }
    Role:
      type: object
      required: [id, name, color, hoist, position, permissions, managed, mentionable]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        name: { type: string }
        color: { type: integer, description: "RGB числом, 0 — без цвета" }
        hoist: { type: boolean, description: Показывать группой в списке участников }
        position: { type: integer }
        permissions: { $ref: "#/components/schemas/Permissions" }
        managed: { type: boolean, description: "Роль бота, управляется системой" }
        mentionable: { type: boolean }
        icon: { type: [string, "null"] }
    RoleCreate:
      type: object
      properties:
        name: { type: string, minLength: 1, maxLength: 100 }
        color: { type: integer, minimum: 0, maximum: 16777215 }
        hoist: { type: boolean }
        permissions: { $ref: "#/components/schemas/Permissions" }
        mentionable: { type: boolean }
    Member:
      type: object
      required: [user, nick, roles, joined_at]
      properties:
        user: { $ref: "#/components/schemas/User" }
        nick: { type: [string, "null"] }
        avatar: { type: [string, "null"], description: Аватар для этого сервера («Кличат Плюс») }
        roles:
          type: array
          items: { $ref: "#/components/schemas/Snowflake" }
        joined_at: { type: string, format: date-time }
        premium_since: { type: [string, "null"], format: date-time, description: С какого момента бустит сервер }
        deaf: { type: boolean }
        mute: { type: boolean }
        pending: { type: boolean, description: Не прошёл экран приветствия }
        communication_disabled_until: { type: [string, "null"], format: date-time }
        permissions: { $ref: "#/components/schemas/Permissions" }
    Channel:
      type: object
      required: [id, type]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        type:
          type: integer
          enum: [0, 1, 2, 3, 4, 5, 11, 13, 15]
          description: "0 текст, 1 ЛС, 2 голос, 3 группа, 4 категория, 5 анонсы, 11 публичный тред, 13 стейдж, 15 форум"
        guild_id: { $ref: "#/components/schemas/Snowflake" }
        name: { type: string }
        topic: { type: [string, "null"] }
        position: { type: integer }
        parent_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        nsfw: { type: boolean }
        rate_limit_per_user: { type: integer, description: Секунды между сообщениями (slowmode) }
        bitrate: { type: integer }
        user_limit: { type: integer }
        rtc_region: { type: [string, "null"] }
        last_message_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        last_pin_timestamp: { type: [string, "null"], format: date-time }
        permission_overwrites:
          type: array
          items: { $ref: "#/components/schemas/Overwrite" }
        recipients:
          type: array
          description: Для ЛС и групп
          items: { $ref: "#/components/schemas/User" }
        owner_id: { $ref: "#/components/schemas/Snowflake" }
        icon: { type: [string, "null"] }
        thread_metadata:
          type: object
          properties:
            archived: { type: boolean }
            auto_archive_duration: { type: integer }
            archive_timestamp: { type: string, format: date-time }
            locked: { type: boolean, description: Разархивировать может только MANAGE_THREADS }
            create_timestamp: { type: string, format: date-time }
        message_count: { type: integer }
        member_count: { type: integer }
        member:
          description: Участие запрашивающего в ветке (READY, активные ветки, ответ на создание)
          allOf: [{ $ref: "#/components/schemas/ThreadMember" }]
    ChannelCreate:
      type: object
      required: [name, type]
      properties:
        name: { type: string, minLength: 1, maxLength: 100 }
        type: { type: integer, enum: [0, 2, 4, 5, 13, 15] }
        topic: { type: string, maxLength: 1024 }
        position: { type: integer, minimum: 0 }
        parent_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        nsfw: { type: boolean }
        rate_limit_per_user: { type: integer, minimum: 0, maximum: 21600 }
        bitrate: { type: integer, minimum: 8000, maximum: 384000 }
        user_limit: { type: integer, minimum: 0, maximum: 99 }
        permission_overwrites:
          type: array
          items: { $ref: "#/components/schemas/Overwrite" }
    ChannelUpdate:
      type: object
      properties:
        name: { type: string, minLength: 1, maxLength: 100 }
        topic: { type: [string, "null"], maxLength: 1024 }
        position: { type: integer, minimum: 0 }
        parent_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        nsfw: { type: boolean }
        rate_limit_per_user: { type: integer, minimum: 0, maximum: 21600 }
        bitrate: { type: integer, minimum: 8000, maximum: 384000 }
        user_limit: { type: integer, minimum: 0, maximum: 99 }
        rtc_region: { type: [string, "null"] }
        archived: { type: boolean, description: "Только для веток: автор ветки или MANAGE_THREADS; в архивной ветке другие поля не меняются (50083)" }
        locked: { type: boolean, description: "Только для веток: MANAGE_THREADS; закрытую ветку из архива возвращает только MANAGE_THREADS" }
        auto_archive_duration: { type: integer, enum: [60, 1440, 4320, 10080], description: Только для веток }
        icon: { type: [string, "null"], description: Только для групп ЛС }
        permission_overwrites:
          type: array
          items: { $ref: "#/components/schemas/Overwrite" }
    Overwrite:
      type: object
      required: [id, type, allow, deny]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        type: { type: integer, enum: [0, 1], description: "0 — роль, 1 — участник" }
        allow: { $ref: "#/components/schemas/Permissions" }
        deny: { $ref: "#/components/schemas/Permissions" }
    Message:
      type: object
      required: [id, channel_id, author, content, timestamp, edited_timestamp, tts, mention_everyone, mentions, mention_roles, attachments, embeds, pinned, type]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        channel_id: { $ref: "#/components/schemas/Snowflake" }
        guild_id: { $ref: "#/components/schemas/Snowflake" }
        author: { $ref: "#/components/schemas/User" }
        member:
          type: object
          description: Данные участника, если сообщение в сервере
          properties:
            nick: { type: [string, "null"] }
            roles:
              type: array
              items: { $ref: "#/components/schemas/Snowflake" }
            joined_at: { type: string, format: date-time }
        content: { type: string }
        timestamp: { type: string, format: date-time }
        edited_timestamp: { type: [string, "null"], format: date-time }
        tts: { type: boolean, const: false, description: Всегда false; поле нужно библиотекам ботов }
        mention_everyone: { type: boolean }
        mentions:
          type: array
          items: { $ref: "#/components/schemas/User" }
        mention_roles:
          type: array
          items: { $ref: "#/components/schemas/Snowflake" }
        attachments:
          type: array
          items: { $ref: "#/components/schemas/Attachment" }
        embeds:
          type: array
          description: |
            Превью ссылок (до 5) строит worker после отправки и присылает MESSAGE_UPDATE; в ответе на POST список пуст.
            Не строятся без EMBED_LINKS в канале, для ссылок в `<угловых скобках>` и в коде, для доменов из стоп-списка.
          items: { $ref: "#/components/schemas/Embed" }
        call:
          type: [object, "null"]
          description: Звонок в ЛС (тип 3)
          properties:
            participants:
              type: array
              items: { $ref: "#/components/schemas/Snowflake" }
            ended_timestamp: { type: [string, "null"], format: date-time, description: null — звонок идёт }
        thread:
          description: Ветка, начатая от этого сообщения (flags & 32)
          allOf: [{ $ref: "#/components/schemas/Channel" }]
        reactions:
          type: array
          items: { $ref: "#/components/schemas/Reaction" }
        pinned: { type: boolean }
        type:
          type: integer
          description: "0 — обычное, 6 — закреп, 7 — вступление, 19 — ответ, 21 — стартовое сообщение треда"
        flags: { type: integer }
        message_reference:
          type: [object, "null"]
          properties:
            message_id: { $ref: "#/components/schemas/Snowflake" }
            channel_id: { $ref: "#/components/schemas/Snowflake" }
            guild_id: { $ref: "#/components/schemas/Snowflake" }
        referenced_message:
          type: [object, "null"]
          description: Сообщение, на которое отвечают (без вложенности)
          additionalProperties: true
        webhook_id: { $ref: "#/components/schemas/Snowflake", description: "Сообщение вебхука: author — вебхук с bot: true и именем из сообщения" }
        sticker_items:
          type: array
          items:
            type: object
            required: [id, name, format_type]
            properties:
              id: { $ref: "#/components/schemas/Snowflake" }
              name: { type: string }
              format_type: { type: integer, enum: [1, 2, 4], description: "1 PNG, 2 APNG, 4 GIF" }
        nonce: { type: [string, integer, "null"] }
    MessageCreate:
      type: object
      properties:
        content: { type: string, maxLength: 4000 }
        nonce: { type: [string, integer], description: "До 25 символов, возвращается в MESSAGE_CREATE" }
        tts: { type: boolean, const: false }
        embeds:
          type: array
          maxItems: 10
          description: "Rich-карточки (с fields): только для ботов, у пользователей игнорируются; лимиты как у Discord (50035 при нарушении)"
          items: { $ref: "#/components/schemas/Embed" }
        sticker_ids:
          type: array
          maxItems: 3
          description: Стикеры серверов, где состоит отправитель (в канале сервера — только его); чужой → 400/10060
          items: { $ref: "#/components/schemas/Snowflake" }
        message_reference:
          type: object
          required: [message_id]
          properties:
            message_id: { $ref: "#/components/schemas/Snowflake" }
            fail_if_not_exists: { type: boolean, default: true, description: "false — исходное удалено: сообщение уходит обычным, без ответа" }
        attachments:
          type: array
          maxItems: 10
          items:
            type: object
            required: [id, uploaded_filename]
            properties:
              id: { type: string }
              filename: { type: string }
              uploaded_filename: { type: string, description: "Из ответа POST /channels/{id}/attachments" }
              description: { type: string, maxLength: 1024 }
              spoiler: { type: boolean }
        allowed_mentions:
          type: object
          properties:
            parse:
              type: array
              items: { type: string, enum: [roles, users, everyone] }
            roles:
              type: array
              items: { $ref: "#/components/schemas/Snowflake" }
            users:
              type: array
              items: { $ref: "#/components/schemas/Snowflake" }
            replied_user:
              type: boolean
              description: >-
                Упомянуть автора сообщения из message_reference (он попадает в mentions: счётчик и push
                при «только @упоминания»). Без allowed_mentions — да; с allowed_mentions без этого поля — нет, как у Discord.
        flags: { type: integer, description: "4 — без превью ссылок (SUPPRESS_EMBEDS)" }
      anyOf:
        - required: [content]
        - required: [embeds]
        - required: [attachments]
    Attachment:
      type: object
      required: [id, filename, size, url, proxy_url]
      description: "`url` и `proxy_url` — подписанные ссылки `GET /attachments/{sig}/{id}/{filename}`; размеры есть у картинок"
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        filename: { type: string }
        description: { type: string }
        content_type: { type: string }
        size: { type: integer }
        url: { type: string, format: uri }
        proxy_url: { type: string, format: uri, description: Ссылка через прокси/CDN с ресайзом }
        width: { type: [integer, "null"] }
        height: { type: [integer, "null"] }
        duration_secs: { type: number }
        spoiler: { type: boolean }
        blocked: { type: boolean, description: "Антивирус нашёл угрозу: файл удалён, `url` и `proxy_url` пустые" }
    AutoModerationRuleInput:
      type: object
      properties:
        name: { type: string, maxLength: 100 }
        event_type: { type: integer, enum: [1], description: 1 — MESSAGE_SEND }
        trigger_type: { type: integer, enum: [1, 3, 4, 5], description: "1 KEYWORD, 3 SPAM, 4 KEYWORD_PRESET, 5 MENTION_SPAM" }
        trigger_metadata: { $ref: "#/components/schemas/AutoModerationTriggerMetadata" }
        actions:
          type: array
          maxItems: 3
          items: { $ref: "#/components/schemas/AutoModerationAction" }
        enabled: { type: boolean }
        exempt_roles:
          type: array
          maxItems: 20
          items: { $ref: "#/components/schemas/Snowflake" }
        exempt_channels:
          type: array
          maxItems: 50
          items: { $ref: "#/components/schemas/Snowflake" }
    AutoModerationRule:
      allOf:
        - $ref: "#/components/schemas/AutoModerationRuleInput"
        - type: object
          required: [id, guild_id, name, creator_id, event_type, trigger_type, trigger_metadata, actions, enabled, exempt_roles, exempt_channels]
          properties:
            id: { $ref: "#/components/schemas/Snowflake" }
            guild_id: { $ref: "#/components/schemas/Snowflake" }
            creator_id: { $ref: "#/components/schemas/Snowflake" }
    AutoModerationTriggerMetadata:
      type: object
      properties:
        keyword_filter: { type: array, items: { type: string, maxLength: 60 }, maxItems: 1000 }
        regex_patterns: { type: array, items: { type: string, maxLength: 260 }, maxItems: 10, description: "RE2 без учёта регистра; \\b — только ASCII" }
        presets: { type: array, items: { type: integer, enum: [1, 2, 3] }, description: "1 PROFANITY, 2 SEXUAL_CONTENT, 3 SLURS — встроенные списки" }
        allow_list: { type: array, items: { type: string, maxLength: 60 } }
        mention_total_limit: { type: integer, minimum: 1, maximum: 50 }
        mention_raid_protection_enabled: { type: boolean }
    AutoModerationAction:
      type: object
      required: [type]
      properties:
        type: { type: integer, enum: [1, 2, 3], description: "1 BLOCK_MESSAGE (custom_message), 2 SEND_ALERT_MESSAGE (channel_id), 3 TIMEOUT (duration_seconds)" }
        metadata:
          type: object
          properties:
            channel_id: { $ref: "#/components/schemas/Snowflake" }
            duration_seconds: { type: integer, minimum: 1, maximum: 2419200 }
            custom_message: { type: string, maxLength: 150 }
    ModerationReport:
      type: object
      required: [id, reporter_id, target_type, target_id, category, text, snapshot, status, created_at]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        reporter_id: { $ref: "#/components/schemas/Snowflake" }
        target_type: { type: string, enum: [message, user, guild] }
        target_id: { $ref: "#/components/schemas/Snowflake" }
        guild_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        channel_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        category: { type: string }
        text: { type: string }
        snapshot: { type: object, description: Копия объекта на момент жалобы }
        status: { type: string, enum: [open, resolved, rejected] }
        resolver_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        resolution: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        resolved_at: { type: [string, "null"], format: date-time }
    ModerationUser:
      type: object
      required: [user, created_at, flags, banned, restricted, guilds, reports_against]
      properties:
        user: { $ref: "#/components/schemas/User" }
        created_at: { type: string, format: date-time }
        flags: { type: integer, description: Бит 1 — сотрудник }
        banned:
          type: [object, "null"]
          properties:
            at: { type: string, format: date-time }
            until: { type: [string, "null"], format: date-time }
            reason: { type: string }
        restricted:
          type: [object, "null"]
          properties:
            until: { type: string, format: date-time }
            reason: { type: string }
        guilds: { type: integer }
        reports_against: { type: integer }
    ModerationAction:
      type: object
      required: [id, staff_id, action, reason, meta, created_at]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        staff_id: { $ref: "#/components/schemas/Snowflake" }
        action: { type: string, enum: [ban, unban, restrict, unrestrict, report_resolved, report_rejected, message_delete] }
        target_user_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        target_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        reason: { type: string }
        meta: { type: object }
        created_at: { type: string, format: date-time }
    PushInfo:
      type: object
      required: [enabled, public_key, subscriptions]
      properties:
        enabled: { type: boolean, description: На сервере заданы VAPID-ключи }
        public_key: { type: string, description: "Публичный VAPID-ключ (base64url) для `pushManager.subscribe`" }
        subscriptions:
          type: array
          items: { $ref: "#/components/schemas/PushSubscriptionInfo" }
    PushSubscriptionInfo:
      type: object
      required: [endpoint, user_agent, created_at, current]
      properties:
        endpoint: { type: string, format: uri }
        user_agent: { type: string }
        created_at: { type: string, format: date-time }
        current: { type: boolean, description: Создана в текущей сессии }
    PushSubscriptionInput:
      type: object
      required: [endpoint, keys]
      description: "`PushSubscription.toJSON()` браузера"
      properties:
        endpoint: { type: string, format: uri, description: https-адрес push-службы браузера }
        keys:
          type: object
          required: [p256dh, auth]
          properties:
            p256dh: { type: string, description: "Публичный ключ P-256 (base64url, 65 байт)" }
            auth: { type: string, description: "Секрет аутентификации (base64url, 16 байт)" }
    Call:
      type: object
      required: [channel_id, message_id, region, ringing]
      description: Звонок в ЛС или группе (CALL_CREATE, CALL_UPDATE, READY.calls, GET /channels/{id}/call)
      properties:
        channel_id: { $ref: "#/components/schemas/Snowflake" }
        message_id: { $ref: "#/components/schemas/Snowflake", description: Системное сообщение типа 3 }
        region: { type: string }
        ringing:
          type: array
          description: Кому сейчас звонит
          items: { $ref: "#/components/schemas/Snowflake" }
        voice_states:
          type: array
          description: Кто в звонке (в CALL_CREATE, READY и GET; в CALL_UPDATE нет)
          items: { $ref: "#/components/schemas/VoiceState" }
    VoiceJoin:
      type: object
      required: [endpoint, token, region, expires_at, guild_id, channel_id, voice_state]
      properties:
        endpoint: { type: string, format: uri, examples: ["wss://livekit-msk-1.klichat.ru"] }
        token: { type: string, description: "JWT LiveKit на комнату channel_id: publish при SPEAK и без серверного mute, экран при STREAM; живёт 10 минут до подключения" }
        region: { type: string, examples: [msk] }
        expires_at: { type: string, format: date-time }
        guild_id: { $ref: "#/components/schemas/SnowflakeNullable", description: null в звонке ЛС }
        channel_id: { $ref: "#/components/schemas/Snowflake" }
        voice_state: { $ref: "#/components/schemas/VoiceState" }
    VoiceState:
      type: object
      required: [channel_id, user_id, session_id, deaf, mute, self_deaf, self_mute, self_stream, self_video, suppress]
      description: Кто в каком голосовом канале; `channel_id` null в VOICE_STATE_UPDATE — вышел
      properties:
        guild_id: { $ref: "#/components/schemas/Snowflake" }
        channel_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        user_id: { $ref: "#/components/schemas/Snowflake" }
        member: { $ref: "#/components/schemas/Member" }
        session_id: { type: string }
        deaf: { type: boolean, description: Серверный (DEAFEN_MEMBERS) }
        mute: { type: boolean, description: Серверный (MUTE_MEMBERS) }
        self_deaf: { type: boolean }
        self_mute: { type: boolean }
        self_stream: { type: boolean, description: Демонстрирует экран }
        self_video: { type: boolean }
        suppress: { type: boolean }
        request_to_speak_timestamp: { type: [string, "null"], format: date-time }
    ThreadMember:
      type: object
      required: [id, user_id, join_timestamp, flags]
      properties:
        id: { $ref: "#/components/schemas/Snowflake", description: id ветки }
        user_id: { $ref: "#/components/schemas/Snowflake" }
        join_timestamp: { type: string, format: date-time }
        flags: { type: integer }
        user: { $ref: "#/components/schemas/User" }
    ReportResult:
      type: object
      required: [id, status]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        status: { type: string, enum: [open, resolved, rejected] }
        existing: { type: boolean, description: Жалоба уже была открыта раньше }
    Embed:
      type: object
      description: |
        Превью ссылки или карточка бота. `video.url` заполняется только для YouTube, RuTube и VK Видео (клиент открывает плеер в iframe).
        Картинки клиент грузит по `proxy_url` — подписанной ссылке на `GET /external/{sig}/{url}`, чтобы IP пользователя не утекал.
      properties:
        type: { type: string, enum: [rich, image, video, link, article] }
        title: { type: string, maxLength: 256 }
        description: { type: string, maxLength: 4096 }
        url: { type: string, format: uri }
        timestamp: { type: string, format: date-time }
        color: { type: integer }
        footer:
          type: object
          required: [text]
          properties:
            text: { type: string, maxLength: 2048 }
            icon_url: { type: string, format: uri }
        image:
          type: object
          required: [url]
          properties:
            url: { type: string, format: uri }
            proxy_url: { type: string, format: uri }
            width: { type: integer }
            height: { type: integer }
        thumbnail:
          type: object
          required: [url]
          properties:
            url: { type: string, format: uri }
            proxy_url: { type: string, format: uri }
            width: { type: integer }
            height: { type: integer }
        video:
          type: object
          properties:
            url: { type: string, format: uri }
            width: { type: integer }
            height: { type: integer }
        provider:
          type: object
          properties:
            name: { type: string }
            url: { type: string, format: uri }
        author:
          type: object
          required: [name]
          properties:
            name: { type: string, maxLength: 256 }
            url: { type: string, format: uri }
            icon_url: { type: string, format: uri }
        fields:
          type: array
          maxItems: 25
          items:
            type: object
            required: [name, value]
            properties:
              name: { type: string, maxLength: 256 }
              value: { type: string, maxLength: 1024 }
              inline: { type: boolean }
    Reaction:
      type: object
      required: [count, me, emoji]
      properties:
        count: { type: integer }
        me: { type: boolean }
        emoji:
          type: object
          required: [id, name]
          properties:
            id: { $ref: "#/components/schemas/SnowflakeNullable" }
            name: { type: [string, "null"] }
            animated: { type: boolean }
    Emoji:
      type: object
      required: [id, name]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        name: { type: string }
        animated: { type: boolean }
        managed: { type: boolean }
        available: { type: boolean }
        require_colons: { type: boolean }
        user: { $ref: "#/components/schemas/User" }
        roles:
          type: array
          items: { $ref: "#/components/schemas/Snowflake" }
        url: { type: string, format: uri, description: "Адрес картинки (расширение формы Discord: клиент не собирает CDN-ссылку сам)" }
    Invite:
      type: object
      required: [code, guild, channel]
      properties:
        code: { type: string }
        guild: { $ref: "#/components/schemas/GuildPartial" }
        channel:
          type: object
          required: [id, name, type]
          properties:
            id: { $ref: "#/components/schemas/Snowflake" }
            name: { type: string }
            type: { type: integer }
        inviter: { $ref: "#/components/schemas/User" }
        approximate_member_count: { type: integer }
        approximate_presence_count: { type: integer }
        expires_at: { type: [string, "null"], format: date-time }
        uses: { type: integer }
        max_uses: { type: integer }
        max_age: { type: integer }
        temporary: { type: boolean }
        created_at: { type: string, format: date-time }
    Ban:
      type: object
      required: [user, reason]
      properties:
        user: { $ref: "#/components/schemas/User" }
        reason: { type: [string, "null"] }
    UserGuildSettings:
      type: object
      required: [guild_id, message_notifications, muted, suppress_everyone, suppress_roles, channel_overrides]
      properties:
        guild_id: { type: [string, "null"], description: "Id сервера; null — запись личных бесед" }
        message_notifications: { type: integer, enum: [0, 1, 2, 3] }
        muted: { type: boolean }
        mute_config: { type: [object, "null"], properties: { end_time: { type: [string, "null"], format: date-time } } }
        suppress_everyone: { type: boolean }
        suppress_roles: { type: boolean }
        channel_overrides:
          type: array
          items:
            type: object
            required: [channel_id, message_notifications, muted, collapsed]
            properties:
              channel_id: { $ref: "#/components/schemas/Snowflake" }
              message_notifications: { type: integer, enum: [0, 1, 2, 3] }
              muted: { type: boolean }
              mute_config: { type: [object, "null"], properties: { end_time: { type: [string, "null"], format: date-time } } }
              collapsed: { type: boolean }
    AuditLogEntry:
      type: object
      required: [id, action_type, user_id, target_id]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        action_type: { type: integer, description: "Коды действий как у Discord (1 GUILD_UPDATE, 10 CHANNEL_CREATE, 20 MEMBER_KICK, 22 MEMBER_BAN_ADD, 24 MEMBER_UPDATE, 30 ROLE_CREATE, 72 MESSAGE_DELETE…)" }
        user_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        target_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        reason: { type: string }
        changes:
          type: array
          items:
            type: object
            required: [key]
            properties:
              key: { type: string }
              old_value: {}
              new_value: {}
        options: { type: object, additionalProperties: true }

    Application:
      type: object
      description: Приложение в форме Discord; `privileged_intents` и `redirect_uris` — расширения раздела «Приложения», `team` всегда null.
      required: [id, name, icon, description, bot_public, bot_require_code_grant, verify_key, team, flags, redirect_uris, privileged_intents]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        name: { type: string }
        icon: { type: [string, "null"] }
        description: { type: string }
        bot_public: { type: boolean }
        bot_require_code_grant: { type: boolean }
        bot: { $ref: "#/components/schemas/User" }
        owner: { $ref: "#/components/schemas/User" }
        verify_key: { type: string }
        team: { type: "null" }
        flags: { type: integer, description: "GATEWAY_PRESENCE 1<<12, GATEWAY_GUILD_MEMBERS 1<<14, GATEWAY_MESSAGE_CONTENT 1<<18 по включённым intents" }
        redirect_uris:
          type: array
          items: { type: string }
        privileged_intents: { type: integer, description: "Разрешённые привилегированные intents: 1<<1, 1<<8, 1<<15" }
        approximate_guild_count: { type: integer }
        created_at: { type: string, format: date-time }
    AuthorizeInfo:
      type: object
      required: [application, bot, user, guilds, scopes, permissions, guild_id, authorized]
      properties:
        application: { $ref: "#/components/schemas/Application" }
        bot: { $ref: "#/components/schemas/User" }
        user: { $ref: "#/components/schemas/User" }
        guilds:
          type: array
          description: Серверы пользователя с правом MANAGE_GUILD
          items: { $ref: "#/components/schemas/GuildPartial" }
        scopes:
          type: array
          items: { type: string }
        permissions: { $ref: "#/components/schemas/Permissions" }
        guild_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        authorized: { type: boolean }
    Webhook:
      type: object
      description: Входящий вебхук в форме Discord; `token` и `url` отдаются тем, у кого MANAGE_WEBHOOKS, `user` — создатель (нет в ответах по токену).
      required: [id, type, guild_id, channel_id, name, avatar, application_id]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        type: { type: integer, enum: [1], description: "1 — incoming" }
        guild_id: { $ref: "#/components/schemas/Snowflake" }
        channel_id: { $ref: "#/components/schemas/Snowflake" }
        user: { $ref: "#/components/schemas/User" }
        name: { type: string, maxLength: 80 }
        avatar: { type: [string, "null"] }
        token: { type: string }
        application_id: { $ref: "#/components/schemas/SnowflakeNullable" }
        url: { type: string, format: uri, description: "Адрес исполнения: {KLICHAT_API_PUBLIC_URL}/webhooks/{id}/{token}" }
    WebhookExecute:
      type: object
      description: Тело исполнения вебхука; нужен content или embeds.
      properties:
        content: { type: string, maxLength: 2000 }
        username: { type: string, maxLength: 80, description: Имя автора для этого сообщения }
        avatar_url: { type: string, format: uri, description: "Принимается, пока не показывается" }
        tts: { type: boolean, description: Игнорируется }
        embeds:
          type: array
          maxItems: 10
          items: { $ref: "#/components/schemas/Embed" }
    Sticker:
      type: object
      description: Стикер сервера в форме Discord (type 2); `url` — расширение для клиента.
      required: [id, name, description, tags, type, format_type, guild_id]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        name: { type: string, maxLength: 30 }
        description: { type: string }
        tags: { type: string }
        type: { type: integer, enum: [2] }
        format_type: { type: integer, enum: [1, 2, 4], description: "1 PNG, 2 APNG, 4 GIF" }
        available: { type: boolean }
        guild_id: { $ref: "#/components/schemas/Snowflake" }
        user: { $ref: "#/components/schemas/User" }
        sort_value: { type: integer }
        url: { type: string, format: uri }
    ImportOptions:
      type: object
      description: Выбор владельца в мастере импорта (исходные Discord-ID из предпросмотра; пустой список — ничего из группы, отсутствие поля — всё).
      properties:
        channels:
          type: array
          items: { type: string }
        roles:
          type: array
          items: { type: string }
        emojis:
          type: array
          items: { type: string }
        stickers:
          type: array
          items: { type: string }
        threads:
          type: array
          items: { type: string }
        settings: { type: boolean }
        claims: { type: boolean }
        renames:
          type: object
          additionalProperties: { type: string }
    ImportJob:
      type: object
      required: [id, guild_id, owner_id, source, status, options, error, created_at, started_at, finished_at]
      properties:
        id: { $ref: "#/components/schemas/Snowflake" }
        guild_id: { $ref: "#/components/schemas/Snowflake" }
        owner_id: { $ref: "#/components/schemas/Snowflake" }
        source: { type: string, enum: [exporter] }
        status: { type: string, enum: [uploaded, ready, running, done, failed, cancelled] }
        options: { $ref: "#/components/schemas/ImportOptions" }
        preview: { type: object, description: "Сводка и дерево архива: guild, summary, categories, uncategorized, roles, emojis, stickers, threads, claims, slots, warnings", additionalProperties: true }
        report: { type: object, description: "created, existing, skipped[{type, id, name, reason}], warnings, duration_ms", additionalProperties: true }
        error: { type: [string, "null"] }
        progress:
          type: object
          properties:
            step: { type: string }
            done: { type: integer }
            total: { type: integer }
        created_at: { type: string, format: date-time }
        started_at: { type: [string, "null"], format: date-time }
        finished_at: { type: [string, "null"], format: date-time }
