openapi: 3.0.3
info:
  title: tgbeholder REST API
  description: >-
    Query API over the Telegram message archive collected by tgbeholder.
    All endpoints (except the admin UI at `/` and this spec) require a full
    admin bearer token from `HTTP_API_TOKENS`. The passive pull endpoint also
    accepts a distinct pull-only bearer from
    `HTTP_EXTERNAL_INGEST_PULL_TOKENS`.
  version: "1.0"
servers:
  - url: /
    description: Current host
security:
  - bearerAuth: []
paths:
  /admin/health:
    get:
      summary: Proxy actuator health for the admin UI
      description: >-
        Returns the aggregated actuator health JSON through the main REST
        server, protected by the same bearer token as the data and control API.
      operationId: getAdminHealth
      responses:
        "200":
          description: Aggregated health.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthStatus" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "502": { $ref: "#/components/responses/ActuatorUnavailable" }
  /admin/health/plain:
    get:
      summary: Proxy actuator plain health for the admin UI
      description: Returns the plain aggregate health status through the main REST server.
      operationId: getAdminHealthPlain
      responses:
        "200":
          description: Plain health status.
          content:
            text/plain:
              schema:
                type: string
                example: UP
        "403": { $ref: "#/components/responses/Forbidden" }
        "502": { $ref: "#/components/responses/ActuatorUnavailable" }
  /admin/metrics:
    get:
      summary: Proxy Prometheus metrics for the admin UI
      description: Returns actuator Prometheus metrics through the main REST server.
      operationId: getAdminMetrics
      responses:
        "200":
          description: Prometheus text exposition format.
          content:
            text/plain:
              schema:
                type: string
        "403": { $ref: "#/components/responses/Forbidden" }
        "502": { $ref: "#/components/responses/ActuatorUnavailable" }
  /admin/chats:
    get:
      summary: List monitored chats for the admin UI
      description: >-
        Returns current monitored peers with best-effort titles and archive
        counters. When no monitored peer set has been resolved yet, falls back
        to chats present in the archive.
      operationId: getAdminChats
      responses:
        "200":
          description: Admin chat summaries.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AdminChat" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /admin/authors:
    get:
      summary: List message authors for the admin UI
      description: Returns author summaries across all archived chats, grouped by stable `sender_id`.
      operationId: getAdminAuthors
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 200, maximum: 500 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
        - name: includeUnknown
          in: query
          schema: { type: boolean, default: true }
      responses:
        "200":
          description: Admin author summaries.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AdminAuthor" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /admin/messages:
    get:
      summary: List admin timeline messages
      description: >-
        Returns display-oriented message DTOs with deletion marker, sorted edit
        history, attachment metadata, raw payload, and external ingest state.
      operationId: getAdminMessages
      parameters:
        - name: peerId
          in: query
          schema: { type: integer, format: int64 }
        - name: telegramChatId
          in: query
          schema: { type: integer, format: int64 }
        - name: senderId
          in: query
          description: Matches messages sent by this author and messages forwarded from this author. The author list itself remains grouped by the actual sender.
          schema: { type: integer, format: int64 }
        - name: since
          in: query
          schema: { type: string, format: date-time }
        - name: till
          in: query
          schema: { type: string, format: date-time }
        - name: includeDeleted
          in: query
          schema: { type: boolean, default: true }
        - name: includeHistory
          in: query
          schema: { type: boolean, default: true }
        - name: includeRaw
          in: query
          schema: { type: boolean, default: true }
        - name: editedOnly
          in: query
          schema: { type: boolean, default: false }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 500 }
      responses:
        "200":
          description: Admin message timeline.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AdminMessage" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /admin/messages/{id}/attachment:
    get:
      summary: Download an archived Telegram attachment
      description: >-
        Returns the blob saved for a message attachment. Only attachments whose
        size was strictly below BEHOLDER_ATTACHMENT_MAX_SIZE are available.
      operationId: downloadAdminMessageAttachment
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer, format: int64 }
      responses:
        "200":
          description: Attachment bytes.
          headers:
            Content-Disposition:
              schema: { type: string }
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: Message has no stored attachment. }
  /admin/externalIngest/queue:
    get:
      summary: Preview the next external ingest queue
      description: Uses the same eligibility scope and ordering as the dispatcher.
      operationId: getAdminExternalIngestQueue
      parameters:
        - name: ignoreDelay
          in: query
          schema: { type: boolean, default: false }
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 500 }
      responses:
        "200":
          description: Next eligible messages.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AdminMessage" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /admin/externalIngest/pull:
    get:
      summary: Read messages for passive external ingest
      description: >-
        Returns minimized normalized envelopes selected from the same Message
        table and eligibility scope as outbound external ingest. Full raw
        Telegram and attachment objects plus Telegram file access IDs are
        omitted. Raw-only events retain only a `telegram_raw` marker; Telegram
        fields without a normalized representation are not returned. This
        endpoint is read-only: it does not send messages, mark them as sent, or
        change any ExternalIngest state. An initial request fixes the drain's
        selection clock, upper Updated watermark, UTC offset, and `ignoreDelay`
        choice. Follow the opaque cursor while
        `X-Tgbeholder-Has-More` is `true`; continuation requests may change
        `limit` but must omit `since` and `ignoreDelay`. Each response scans at
        most 5000 stored rows. Malformed rows are logged, omitted, and included
        in cursor progress, so a bare empty array can still have more data. A
        completed cursor drain is neither an MVCC snapshot nor a durable
        event-stream checkpoint: concurrent edits and operator eligibility
        changes can alter its membership. Start a fresh initial poll with an
        overlapping inclusive `since` window and deduplicate by
        `source.event_id` to observe later eligibility changes.
      operationId: pullExternalIngestMessages
      security:
        - bearerAuth: []
        - externalIngestPullBearer: []
      parameters:
        - name: ignoreDelay
          in: query
          description: >-
            Initial request only. When true, ignore the Created plus configured
            ingest-delay cutoff. Frozen into the cursor for continuations.
          schema: { type: boolean, default: false }
        - name: since
          in: query
          description: >-
            Initial request only. Inclusively select rows whose Message.Updated
            is at or after this RFC3339 timestamp.
          schema: { type: string, format: date-time }
        - name: cursor
          in: query
          description: >-
            Opaque value returned in `X-Tgbeholder-Next-Cursor`. Supply it only
            when `X-Tgbeholder-Has-More` was `true`; do not decode or construct
            it. Cannot be combined with `since` or `ignoreDelay`.
          schema:
            type: string
            maxLength: 512
            pattern: "^[A-Za-z0-9_-]+$"
        - name: limit
          in: query
          description: Maximum number of envelopes to return.
          schema: { type: integer, default: 100, maximum: 500 }
      responses:
        "200":
          description: >-
            Bare JSON array of normalized passive-ingest envelopes. It may be
            empty while `X-Tgbeholder-Has-More` is `true`.
          headers:
            X-Tgbeholder-Next-Cursor:
              description: >-
                Opaque position of the last row scanned. Use it for the next
                request only when `X-Tgbeholder-Has-More` is `true`. It is
                present only while `X-Tgbeholder-Has-More` is `true`.
              schema: { type: string }
            X-Tgbeholder-Has-More:
              description: >-
                Always present. `true` means the bounded drain has more rows and
                `X-Tgbeholder-Next-Cursor` must be supplied to continue.
              schema:
                type: string
                enum: ["true", "false"]
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ExternalMessageEnvelope" }
        "400":
          description: >-
            Invalid `since`, `ignoreDelay`, or cursor; cursor combined with
            `since` or `ignoreDelay`; or the removed `afterId` parameter.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /admin/externalIngest/exclusions:
    get:
      summary: List messages excluded from external ingest
      operationId: getAdminExternalIngestExclusions
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 500 }
      responses:
        "200":
          description: Excluded messages.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AdminMessage" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /admin/externalIngest/exclude:
    post:
      summary: Exclude messages from external ingest
      description: Exclusion is persistent until the message is explicitly included again.
      operationId: excludeAdminExternalIngest
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExternalIngestExclusionRequest" }
      responses:
        "200":
          description: Rows updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExternalIngestExclusionResult" }
        "400": { description: Empty or invalid ids. }
        "403": { $ref: "#/components/responses/Forbidden" }
  /admin/externalIngest/include:
    post:
      summary: Restore messages to external ingest eligibility
      operationId: includeAdminExternalIngest
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExternalIngestExclusionRequest" }
      responses:
        "200":
          description: Rows updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExternalIngestExclusionResult" }
        "400": { description: Empty or invalid ids. }
        "403": { $ref: "#/components/responses/Forbidden" }
  /messages:
    get:
      summary: List archived messages
      description: Returns raw stored messages, newest first.
      operationId: getMessages
      parameters:
        - name: offset
          in: query
          description: Number of rows to skip.
          schema: { type: integer, default: 0 }
        - name: limit
          in: query
          description: Maximum number of rows to return.
          schema: { type: integer, default: 100 }
        - name: since
          in: query
          description: Only messages whose `updated` is at or after this time (RFC3339).
          schema: { type: string, format: date-time }
        - name: chatId
          in: query
          description: Filter by peer id (matches `peer_id`).
          schema: { type: integer, format: int64 }
      responses:
        "200":
          description: Matching messages.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Message" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /chats:
    get:
      summary: List monitored chats
      description: >-
        Returns the distinct chats present in the archive, resolved to their
        live Telegram peer objects. Peers that cannot be resolved are skipped.
      operationId: getChats
      responses:
        "200":
          description: Resolved Telegram peer objects (shape depends on peer type).
          content:
            application/json:
              schema:
                type: array
                items: { type: object, additionalProperties: true }
        "403": { $ref: "#/components/responses/Forbidden" }
  /chatHistory:
    get:
      summary: Get grouped chat history
      description: >-
        Returns non-deleted messages within a time window, grouped per chat and
        reduced to a brief form suitable for display.
      operationId: getChatHistory
      parameters:
        - name: since
          in: query
          description: Window start (RFC3339). Defaults to 24h ago.
          schema: { type: string, format: date-time }
        - name: till
          in: query
          description: Window end (RFC3339). Defaults to 24h from now.
          schema: { type: string, format: date-time }
        - name: chatId
          in: query
          description: Filter by peer id (matches `peer_id`).
          schema: { type: integer, format: int64 }
        - name: generalOnly
          in: query
          description: When true, include only the general (non-forum-topic) messages.
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: Per-chat brief history.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ChatHistory" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /control/resolvePeers:
    post:
      summary: Re-resolve monitored peers
      description: >-
        Forces an immediate re-read of the monitored Telegram folder and
        purges archived rows for chats no longer present in that folder.
      operationId: resolvePeers
      responses:
        "200": { description: Done. }
        "403": { $ref: "#/components/responses/Forbidden" }
  /control/retrieveHistoryMessages:
    post:
      summary: Trigger a history backfill
      description: >-
        Re-resolves peers and runs a bounded history backfill immediately.
      operationId: retrieveHistoryMessages
      parameters:
        - name: timeLimit
          in: query
          description: >-
            How far back to fetch, as a Go duration (e.g. `24h`, `30m`).
            Defaults to BEHOLDER_HISTORY_LIMIT_TIME.
          schema: { type: string, example: "24h" }
        - name: countLimit
          in: query
          description: >-
            Strict max messages per peer. Defaults to
            BEHOLDER_HISTORY_LIMIT_MSG_COUNT.
          schema: { type: integer }
      responses:
        "200": { description: Done. }
        "403": { $ref: "#/components/responses/Forbidden" }
  /control/dispatchExternalIngest:
    post:
      summary: Trigger external ingest dispatch
      description: >-
        Runs the external ingest dispatcher immediately. By default it ignores
        the computed due time (`Created + BEHOLDER_EXTERNAL_INGEST_DELAY`) and
        sends all eligible rows up to the configured batch size.
      operationId: dispatchExternalIngest
      parameters:
        - name: ignoreDelay
          in: query
          description: When true, sends eligible messages even if their computed due time has not arrived.
          schema: { type: boolean, default: true }
      responses:
        "200":
          description: Dispatch summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExternalIngestDispatchResult" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /control/cleanupExternalIngest:
    post:
      summary: Cleanup sent external ingest rows
      description: >-
        Deletes archived rows whose latest version was successfully delivered to
        external ingest, whose `ExternalIngestSentAt` is older than the
        configured retention, and whose message date is outside the history
        backfill window. The default retention comes from
        `BEHOLDER_EXTERNAL_INGEST_CLEANUP_AFTER`; when that duration is zero or
        unset, cleanup is disabled unless `olderThan` is provided.
      operationId: cleanupExternalIngest
      parameters:
        - name: olderThan
          in: query
          description: Optional retention override as a Go duration (e.g. `168h`).
          schema: { type: string, example: "168h" }
      responses:
        "200":
          description: Cleanup summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExternalIngestCleanupResult" }
        "403": { $ref: "#/components/responses/Forbidden" }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        Full administrative token from `HTTP_API_TOKENS`. When pull-only tokens
        are configured, every admin token must also contain at least 32 unpadded
        base64url characters (`[A-Za-z0-9_-]`) because it remains accepted on
        pull.
    externalIngestPullBearer:
      type: http
      scheme: bearer
      description: >-
        Pull-only token from `HTTP_EXTERNAL_INGEST_PULL_TOKENS`; at least 32
        unpadded base64url characters (`[A-Za-z0-9_-]`).
  responses:
    Unauthorized:
      description: Missing or malformed bearer credentials.
    Forbidden:
      description: Unknown bearer token or a valid scoped token used outside its route.
    ActuatorUnavailable:
      description: The local actuator server could not be reached from the main REST server.
  schemas:
    HealthStatus:
      type: object
      properties:
        status:
          type: string
          example: UP
        details:
          type: object
          additionalProperties: true
        components:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/HealthStatus"
    Message:
      type: object
      properties:
        Id: { type: integer, format: int64 }
        Created: { type: string, format: date-time }
        Updated: { type: string, format: date-time }
        Deleted: { type: string, format: date-time, nullable: true, description: Soft-delete timestamp; null if not deleted. }
        ChatId: { type: integer, format: int64 }
        TelegramChatId: { type: integer, format: int64, description: Telegram Bot API-style chat id, e.g. -100... for channels and supergroups. }
        PeerId: { type: integer, format: int64 }
        PeerType: { type: string }
        MessageId: { type: integer, format: int64 }
        SenderId: { type: integer, format: int64, nullable: true }
        ForwardedFromSenderId: { type: integer, format: int64, nullable: true, description: Stable original sender id for a forwarded message; 0 means no stable id was available. }
        ForwardedFromSenderName: { type: string, description: Original sender name supplied by Telegram when the stable id is hidden. }
        Data:
          type: object
          additionalProperties: true
          description: Full Telegram message as stored JSON.
        History:
          type: object
          additionalProperties: true
          description: Prior versions of the message keyed by edit timestamp.
        Date: { type: string, format: date-time }
        EditDate: { type: string, format: date-time }
        ExternalIngestStatus: { type: string, nullable: true }
        ExternalIngestSentAt: { type: string, format: date-time, nullable: true }
        ExternalIngestAttempts: { type: integer }
        ExternalIngestLastAttempt: { type: string, format: date-time, nullable: true }
        ExternalIngestLastError: { type: string }
        ExternalIngestExcludedAt: { type: string, format: date-time, nullable: true }
        ExternalIngestExcludedReason: { type: string }
        AttachmentName: { type: string, description: Original or generated attachment filename. }
        AttachmentMimeType: { type: string }
        AttachmentSize: { type: integer, format: int64 }
        AttachmentStatus: { type: string, description: stored, skipped_disabled, skipped_unknown_size, skipped_too_large, or failed. }
    AdminChat:
      type: object
      properties:
        peerId: { type: integer, format: int64 }
        chatId: { type: integer, format: int64 }
        telegramChatId: { type: integer, format: int64 }
        peerType: { type: string }
        title: { type: string }
        messageCount: { type: integer, format: int64 }
        deletedCount: { type: integer, format: int64 }
        editedCount: { type: integer, format: int64 }
        pendingIngestCount: { type: integer, format: int64 }
        latestMessageAt: { type: string, format: date-time, nullable: true }
        unresolved: { type: boolean }
    AdminAuthor:
      type: object
      properties:
        senderId: { type: integer, format: int64 }
        displayName: { type: string }
        username: { type: string }
        messageCount: { type: integer, format: int64 }
        chatCount: { type: integer, format: int64 }
        deletedCount: { type: integer, format: int64 }
        editedCount: { type: integer, format: int64 }
        latestMessageAt: { type: string, format: date-time, nullable: true }
    AdminMessage:
      type: object
      properties:
        id: { type: integer, format: int64 }
        chatId: { type: integer, format: int64 }
        telegramChatId: { type: integer, format: int64 }
        peerId: { type: integer, format: int64 }
        peerType: { type: string }
        chatTitle: { type: string }
        messageId: { type: integer, format: int64 }
        date: { type: string, format: date-time }
        updated: { type: string, format: date-time }
        created: { type: string, format: date-time }
        deletedAt: { type: string, format: date-time, nullable: true }
        text: { type: string }
        sender: { type: string }
        senderId: { type: integer, format: int64 }
        forwardedFromSender: { type: string, description: Original sender name supplied by Telegram when available. }
        forwardedFromSenderId: { type: integer, format: int64, description: Stable original sender id for a forwarded message. }
        topic: { type: string }
        topicTitle: { type: string, description: Forum topic title captured when the message was archived. }
        history:
          type: array
          items: { $ref: "#/components/schemas/AdminMessageVersion" }
        externalIngestStatus: { type: string }
        externalIngestSentAt: { type: string, format: date-time, nullable: true }
        externalIngestAttempts: { type: integer }
        externalIngestLastAttempt: { type: string, format: date-time, nullable: true }
        externalIngestLastError: { type: string }
        externalIngestExcludedAt: { type: string, format: date-time, nullable: true }
        externalIngestExcludedReason: { type: string }
        dueAt: { type: string, format: date-time, nullable: true }
        retryAfter: { type: string, format: date-time, nullable: true }
        attachment: { $ref: "#/components/schemas/AdminMessageAttachment" }
        raw:
          type: object
          additionalProperties: true
    AdminMessageAttachment:
      type: object
      properties:
        name: { type: string }
        mimeType: { type: string }
        size: { type: integer, format: int64 }
        status: { type: string, description: stored, skipped_disabled, skipped_unknown_size, skipped_too_large, or failed. }
        downloadPath: { type: string, description: Present only when status is stored. }
    AdminMessageVersion:
      type: object
      properties:
        editedAt: { type: string, format: date-time }
        date: { type: string, format: date-time }
        text: { type: string }
        sender: { type: string }
        senderId: { type: integer, format: int64 }
        forwardedFromSender: { type: string, description: Original sender name supplied by Telegram when available. }
        forwardedFromSenderId: { type: integer, format: int64, description: Stable original sender id for a forwarded message. }
        topic: { type: string }
        topicTitle: { type: string, description: Forum topic title captured when the message was archived. }
        raw:
          type: object
          additionalProperties: true
    ExternalIngestExclusionRequest:
      type: object
      required: [ids]
      properties:
        ids:
          type: array
          items: { type: integer, format: int64 }
        reason: { type: string }
    ExternalIngestExclusionResult:
      type: object
      properties:
        updated: { type: integer, format: int64 }
    ExternalIngestDispatchResult:
      type: object
      properties:
        requestedAt: { type: string, format: date-time }
        disabled: { type: boolean }
        busy: { type: boolean }
        loaded: { type: integer }
        sent: { type: integer }
        failed: { type: integer }
        error: { type: string }
    ExternalIngestCleanupResult:
      type: object
      properties:
        requestedAt: { type: string, format: date-time }
        disabled: { type: boolean }
        busy: { type: boolean }
        olderThan: { type: string }
        cutoff: { type: string, format: date-time, nullable: true }
        deleted: { type: integer, format: int64 }
        error: { type: string }
    ExternalMessageEnvelope:
      type: object
      required: [schema_version, source, conversation, actor, message]
      properties:
        schema_version: { type: string, example: "1" }
        source: { $ref: "#/components/schemas/ExternalMessageSource" }
        conversation: { $ref: "#/components/schemas/ExternalMessageConversation" }
        actor: { $ref: "#/components/schemas/ExternalMessageActor" }
        message: { $ref: "#/components/schemas/ExternalMessagePayload" }
    ExternalMessageSource:
      type: object
      required: [service, event_id]
      properties:
        service: { type: string, example: tgbeholder }
        event_id: { type: string }
        received_at: { type: string, format: date-time }
        metadata: { type: object, additionalProperties: true }
    ExternalMessageConversation:
      type: object
      required: [id, kind]
      properties:
        id: { type: string }
        kind: { type: string, enum: [dm, group, channel] }
        title: { type: string }
        platform: { type: string, example: telegram }
        telegram_chat_id: { type: integer, format: int64 }
        thread_id: { type: string }
        thread_title: { type: string }
    ExternalMessageActor:
      type: object
      required: [id, display_name]
      properties:
        id: { type: string }
        display_name: { type: string }
        username: { type: string }
        full_name: { type: string }
        telegram_user_id: { type: integer, format: int64, nullable: true }
        is_bot: { type: boolean, nullable: true }
    ExternalMessagePayload:
      type: object
      required: [id, occurred_at]
      properties:
        id: { type: string }
        occurred_at: { type: string, format: date-time }
        text: { type: string }
        reply_to_message_id: { type: string }
        attachments:
          type: array
          items: { $ref: "#/components/schemas/ExternalMessageAttachment" }
        raw:
          type: object
          additionalProperties: true
          description: Present on configured outbound POSTs; omitted from passive pull responses.
    ExternalMessageAttachment:
      type: object
      required: [type]
      properties:
        type: { type: string }
        mime_type: { type: string }
        file_id:
          type: string
          description: Present on configured outbound POSTs; omitted from passive pull responses.
        caption: { type: string }
        raw:
          type: object
          additionalProperties: true
          description: Present on configured outbound POSTs; omitted from passive pull responses.
    ChatHistory:
      type: object
      properties:
        Title: { type: string }
        Messages:
          type: array
          items: { $ref: "#/components/schemas/BriefMessage" }
    BriefMessage:
      type: object
      properties:
        Date: { type: string, format: date-time }
        Sender: { type: string, nullable: true }
        Text: { type: string }
        Topic: { type: string, nullable: true }
        TopicTitle: { type: string, nullable: true, description: Forum topic title captured when the message was archived. }
