# ⚠️★ 这是**生成物**，不要手改。
#
#   改法：改 `zh.yaml`（译文表）或 `clip9.openapi.yaml`（英文原文），
#   然后跑 `python3 docs/openapi/build-zh.py`。
#
# 结构（路径 / 字段 / 必填 / 示例）**全部来自英文那份**，所以两份不会漂。
# 已译 450 条，未译（保留英文）0 条。
openapi: 3.1.0
info:
  title: clip9 API
  version: 0.1.0
  summary: clip9 Rust 服务端（crates/server）的 REST + WebSocket 契约
  description: '# clip9 API


    `clip9-server`（`rust/crates/server`）对外提供的 HTTP + WebSocket 接口。

    它是 Cloud Clipboard 项目的服务端一半：一个**按房间隔离**的剪贴板 ——

    客户端把文本或文件投进某个房间，房间里其他成员**实时**收到。


    这份文档是照着 Rust 的路由表（`crates/server/src/lib.rs::router`）和各处理器读出来的。

    ⚠️ 与代码不一致时**以代码为准**；与 Go 版 / Worker 版不一致的地方在正文里用 `NOTE (code)` 标出来。


    ## 路径索引


    | 方法 | 路径 | 干什么 |

    |---|---|---|

    | GET | `/server` | 服务能力与当前鉴权状态 |

    | GET | `/healthz` | 存活探针（**实现附赠**） |

    | POST | `/auth/token` | 用密码换一个 1 小时的会话令牌 |

    | POST | `/auth/token/refresh` | 续期会话令牌（不用再给密码） |

    | POST | `/text` | 发文本（带 `id` 是**原地改**） |

    | GET | `/content` | 历史分页（游标是 `id`） |

    | GET | `/content/latest` | 最新一条 |

    | GET | `/content/{id}` | 按 id 取一条 |

    | POST | `/content/{id}/column` | 改看板那一列 |

    | GET | `/rooms` | 房间列表 |

    | GET | `/stats/daily` | 每天活跃度（热力图数据源） |

    | POST | `/upload` | 小文件整份上传（multipart） |

    | POST | `/upload/chunk` · `/upload/chunk/{uuid}` · `/upload/finish/{uuid}` | 分片上传 |

    | GET/DELETE | `/file/{uuid}` · `/file/{uuid}/{name}` | 下载 / 删 |

    | POST | `/revoke/{id}` · `/revoke/all` | 撤回一条 / 清空 |

    | POST/GET | `/share` · `/share/list` · `/share/visit` · `/s/{token}` | 分享链接与落地页 |

    | GET | `/push` | **WebSocket** |

    | GET/POST | `/tasks*` | 定时自动化 |

    '
  license:
    name: MIT
    identifier: MIT
  contact:
    name: clip9
    url: https://github.com/Jonnyan404/clip9
externalDocs:
  description: 可切中英的规格页面（中文那份由 `zh.yaml` 生成）
  url: https://jonnyan404.github.io/clip9/spec.html
servers:
- url: http://localhost:9501
  description: 默认的本地服务端（不带前缀）
- url: https://clip.example.com
  description: 走 TLS 的部署示例（域名与协议由你自己定）
- url: https://clip.example.com/clip9
  description: 配了 server.prefix 的部署示例
tags:
- name: Service
  description: 服务能力、健康检查，以及这个部署**当前**的鉴权状态。
- name: Auth
  description: 会话令牌 —— 用密码换一次，之后拿着一枚短期令牌。
- name: Content
  description: 发文本、读历史、看板列、撤回。
- name: Files
  description: 上传、下载（支持 Range）与删除文件。
- name: Rooms
  description: 房间发现。
- name: Sharing
  description: 为单条内容签分享令牌，并跟踪链接被打开了几次。
- name: Automation
  description: 定时自动化（cron）、试跑，以及管理页。
- name: Realtime
  description: WebSocket 推送通道。
paths:
  /server:
    get:
      tags:
      - Service
      summary: 服务能力与当前鉴权状态
      description: "网页界面调它来决定：要不要弹密码框、WebSocket 在哪、要不要给出定时任务的入口。**不需要鉴权。**\n\n有三处语义很容易理解错，而它们**都不会报错**：\n\
        \n1. `auth`（「这个房间要不要密码」）与 `roomProtected` **不是一回事**：\n   `roomProtected` 只在给了 `?room=` 时才算，否则是\
        \ `false`。\n2. `authorized` 初值是 `true` —— 不给 `?room=`、也没有全局密码时，它**连凭据都没有也是 `true`**。\n   别默认把它读成\
        \ `false`。\n3. 不给 `?room=` 时，`auth` 反映的**只是全局密码**。\n\nNOTE (code)：这个接口**不返回** `version`，也**不返回**那几个限额\
        \ —— 它们在 WebSocket 握手的\n`config` 帧里。（`/server` 还带定时任务的能力位，而 `config` 帧里那个只是个布尔。）\n\n上面那个 `automation`\
        \ 对象是节选，完整形状见 `AutomationCapability`。\n"
      operationId: getServerInfo
      parameters:
      - $ref: '#/components/parameters/RoomQueryOptional'
      security: []
      responses:
        '200':
          description: 按调用方当前上下文算出的服务端信息。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServerInfo'
              examples:
                openServer:
                  summary: 开放部署，没给房间
                  value:
                    server: ws://127.0.0.1:9501/push
                    auth: false
                    authorized: true
                    roomProtected: false
                    config:
                      server:
                        history: 50
                        roomList: false
                    automation:
                      enabled: false
                protectedRoom:
                  summary: 房间 `home` 受保护，没带凭据
                  value:
                    server: wss://clip.example.com/push
                    auth: true
                    authorized: false
                    roomProtected: true
                    config:
                      server:
                        history: 50
                        roomList: true
                    automation:
                      enabled: true
                      room: home
                      tier: none
                      allowed: false
                      admin: false
                      max: 0
                      defaultTZ: Asia/Shanghai
                      vars:
                      - date
                      - weekday
                      - time
                      - datetime
                      - timestamp
                      - uuid
                      - task
                      - room
                      actions:
                      - id: text.trimLines
                        group: text
                        groupKey: actionGroupText
                        key: actionTrimLines
  /healthz:
    get:
      tags:
      - Service
      summary: 存活探针
      description: '返回字面量 `ok`。这是**实现附赠**（Go 版从来没有过），存在的理由是让验收脚本有一个便宜的

        存活探针 —— 它不像 `/server` 那样要算鉴权状态。


        ⚠️ Cloudflare Worker 也答这个路径（它自己那个 `/health` 也还在）—— 一个探针两个名字，

        会让每个健康检查都得按后端分叉。Go 版两个都没有。

        '
      operationId: healthz
      security: []
      responses:
        '200':
          description: 服务端活着。
          content:
            text/plain:
              schema:
                type: string
                const: ok
              examples:
                ok:
                  value: ok
  /auth/token:
    post:
      tags:
      - Auth
      summary: 用密码换会话令牌
      description: '这个密码会**同时**跟房间密码和全局密码比。匹配上**全局**密码的会拿到

        `scope: "global"` 的令牌（对所有房间有效，且在 `/tasks` 上是管理员）；房间密码则拿到 `scope: ""`。


        请求体必须是合法 JSON —— **空体会是 400，不是取默认值**（空密码没有意义）。


        NOTE (code)：这个接口**刻意不用**那个「房间访问」的公共助手 —— 它对本就开放的房间一律返回

        `true`，那等于**任何**密码都能给一个没设防的房间签出令牌。这里直接比明文。

        '
      operationId: issueSessionToken
      parameters:
      - $ref: '#/components/parameters/RoomQueryOptional'
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TokenRequest'
            examples:
              roomPassword:
                summary: 房间密码
                value:
                  password: correct-horse-battery-staple
      responses:
        '200':
          description: 一枚会话令牌。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionToken'
              examples:
                global:
                  summary: 全局密码 —— 到处都有效
                  value:
                    token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6Imdsb2JhbCJ9.7mQ2
                    expiresAt: 1758003600
                    scope: global
                room:
                  summary: 房间密码 —— 只对该房间有效
                  value:
                    token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6IiJ9.8xR4
                    expiresAt: 1758003600
                    scope: ''
        '400':
          $ref: '#/components/responses/InvalidRequestBody'
        '401':
          description: 密码为空或不对。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                empty:
                  value:
                    code: password_required
                    error: Password required
                    message: 密码不能为空
                wrong:
                  value:
                    code: wrong_password
                    error: Wrong password
                    message: 密码不正确
        '405':
          $ref: '#/components/responses/OnlyPost'
  /auth/token/refresh:
    post:
      tags:
      - Auth
      summary: 不用密码续期会话令牌
      description: '拿旧令牌换一枚新的，**房间绑定与 `scope` 都不变** —— 全局会话**绝不会**被悄悄降级成只对单个房间

        （定时任务管理页不存明文密码，一降级它就再也进不去了）。

        '
      operationId: refreshSessionToken
      parameters:
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 一枚新令牌。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionToken'
              examples:
                renewed:
                  value:
                    token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6IiJ9.9pL1
                    expiresAt: 1758007200
                    scope: ''
        '401':
          description: 出示的令牌无效、已过期，或者绑定的是别的房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid:
                  value:
                    code: session_token_invalid
                    error: Session token invalid or expired
                    message: 会话令牌无效或已过期
        '405':
          $ref: '#/components/responses/OnlyPost'
  /text:
    post:
      tags:
      - Content
      summary: 发文本，或原地改一条已存在的
      description: '存一条文本，并向房间里广播 `receive`。


        **请求体有三种被接受的形状**，由 `Content-Type` 决定：


        | `Content-Type` | 请求体 |

        |---|---|

        | `text/plain`、缺失、或不认识 | 整个请求体就是正文 |

        | `application/json` | `{"content": "..."}` |

        | `multipart/form-data` | 表单字段 `content` |


        后两种是给 Apple 快捷指令的：它把字符串变量当原始正文发时用的是 UTF-16，而发结构化正文时用 UTF-8。

        纯文本那条路也会嗅探并解码 UTF-16（看 BOM 或特征字节）；其余一律**逐字节原样存**，不转义、不改写。


        ⚠️ `application/x-www-form-urlencoded` 是**刻意不认**的 —— 它保留「整个正文就是文本」那条路，

        因为 `curl --data-binary` 默认发的就是它，当成表单会**悄悄存一条空的**。


        带 `?id=` 时，那个 id 的条目会被**原地改写**（id 不变），广播的是 `update`。改写会刷新

        `timestamp`（它是新内容了），并且要求该 id 是同房间的一条文本条目。


        长度按**字节**算，不按字符：4096 字节大约是 1365 个汉字。

        '
      operationId: sendText
      parameters:
      - $ref: '#/components/parameters/RoomQuery'
      - $ref: '#/components/parameters/NameQuery'
      - $ref: '#/components/parameters/ClientQuery'
      - name: id
        in: query
        required: false
        description: 给了且非空时，**改写**这个 id 的条目，而不是新建。该条目必须是同房间的文本条目，否则服务端回 `404 message_not_updatable`。
        schema:
          type: integer
          format: int32
        example: 7
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      requestBody:
        required: true
        description: 正文本身，按 `Content-Type` 包装（见上）。
        content:
          text/plain:
            schema:
              type: string
            examples:
              plain:
                summary: 整个请求体就是正文
                value: 'hello from curl

                  '
          application/json:
            schema:
              type: object
              required:
              - content
              properties:
                content:
                  type: string
            examples:
              json:
                summary: 结构化请求体（快捷指令）
                value:
                  content: hello from Shortcuts
          multipart/form-data:
            schema:
              type: object
              required:
              - content
              properties:
                content:
                  type: string
            examples:
              form:
                value:
                  content: hello from a form
      responses:
        '200':
          description: 条目已存下（或被改写）。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TextSendResult'
              examples:
                created:
                  summary: 新建的条目
                  value:
                    url: http://localhost:9501/content/7
                    id: '7'
                    type: text
                overwritten:
                  summary: 改写已有条目 7
                  value:
                    url: http://localhost:9501/content/7
                    id: '7'
                    type: text
        '400':
          description: 请求体解析不了，或 `?id=` 不是数字。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badBody:
                  value:
                    code: invalid_body
                    error: Cannot parse request body
                    message: 请求体无法解析
                badId:
                  value:
                    code: invalid_id
                    error: Invalid id parameter
                    message: 无效的 ID 参数
        '401':
          description: 这个房间需要凭据，而没给（或给错了）。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing:
                  value:
                    code: unauthorized
                    error: Authentication required
                    message: 需要认证令牌
                wrong:
                  value:
                    code: unauthorized_invalid_token
                    error: Invalid auth token
                    message: 无效的认证令牌
        '404':
          description: 带 `?id=` 时，目标条目不存在，或者它不是本房间里可改写的文本条目。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notUpdatable:
                  value:
                    code: message_not_updatable
                    error: Message not found or not updatable
                    message: 消息未找到或无法更新
        '413':
          description: 文本超过了 `text.limit` 字节。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                tooLong:
                  value:
                    code: text_too_long
                    error: Text too long
                    message: 文本内容超出限制 (最大 4096 字符)
        '405':
          $ref: '#/components/responses/OnlyPost'
  /content:
    get:
      tags:
      - Content
      summary: 房间历史（游标分页）
      description: "按**升序**（旧的在前）返回房间历史的一页，客户端直接 append 即可，下一页游标取 `messages[0].id`。\n\n- 游标是 **`id`**，不是时间戳：时间戳是秒级的、同一秒里会重复，拿它当游标会**漏条或重复**。\n\
        - `limit` 会被夹到 `min(server.history, 100)` —— 这个夹取正是为了不让 `?limit=999999`\n  把「一次推 2MB」从 WebSocket\
        \ 挪到 HTTP。\n- `before` 指向一条已被删掉的条目**不算错**：那一页退化成「最近的 `limit` 条」，\n  而这正是「从最新往回读」的意思。这里回 5xx 只会把客户端卡死。\n\
        - 一页如果超过 1 MiB 的字节预算，会丢**最旧**的几条（绝不整页丢），所以 `messages[0]` 永远是有效游标。\n- **空数组表示「没有更多历史了」。**\n"
      operationId: listContent
      parameters:
      - $ref: '#/components/parameters/RoomQuery'
      - name: before
        in: query
        required: false
        description: 只返回**严格早于**这个 id 的条目。不给或解析不了 = 「从最新开始」。
        schema:
          type: integer
          format: int32
        example: 120
      - name: limit
        in: query
        required: false
        description: 每页条数。夹到 `min(server.history, 100)`；非数字、0 或负数都**静默**回落到那个有效上限。
        schema:
          type: integer
          minimum: 1
        example: 50
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 一页历史，旧的在前。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContentList'
              examples:
                mixedPage:
                  summary: 一条文本 + 一条文件
                  value:
                    messages:
                    - type: text
                      content: hello world
                      id: '7'
                      timestamp: 1758000000
                      room: default
                      senderIP: 203.0.113.7
                      senderClientID: web-1a2b
                      senderDevice:
                        name: iPhone
                        type: smartphone
                        os: iOS 18
                        browser: Safari 18
                      column: todo
                    - type: image
                      name: photo.png
                      size: 20480
                      uuid: 11111111-2222-3333-4444-555555555555
                      cache: 11111111-2222-3333-4444-555555555555
                      thumbnail: data:image/jpeg;base64,/9j/4AAQSkZJRg==
                      url: http://localhost:9501/file/11111111-2222-3333-4444-555555555555/photo.png
                      id: '8'
                      timestamp: 1758000060
                      expire: 1758003600
                      room: default
                      senderIP: 203.0.113.7
                      senderDevice:
                        name: iPhone
                        type: smartphone
                        os: iOS 18
                        browser: Safari 18
                      column: todo
        '401':
          description: 无权访问该房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: room_forbidden
                    error: No access to this room
                    message: 无权访问该房间
        '405':
          $ref: '#/components/responses/OnlyGet'
  /content/latest:
    get:
      tags:
      - Content
      summary: 房间里最新的一条
      description: '默认把最新一条按**原始内容**返回；带 `?format=json`（文本条目也可以靠 `Accept: application/json`）

        则返回一个紧凑的 JSON 对象。


        **搜哪个房间：**


        | 请求 | 搜哪里 |

        |---|---|

        | `?room=R` | 房间 `R` |

        | `?all=1` | 凭据能读的**每个**房间，最新的赢 |

        | 都不给 | `default` 房间 |


        NOTE (code)：`?all=1` 是这个服务端表达「哪都行、取最新」的写法。旧文档写的是

        「不给 `room` → 全房间最新」；这里**把隐式变显式**了 —— 不给 `room` 就是 `default`，

        与其他所有接口一致，跨房间要显式 `?all=1`。`?all=1` 下**读不了的房间会被跳过**，而不是致命：

        一个受保护房间有新消息，不该把每次「全局最新」的请求都变成 401。


        NOTE (code)：这里的 JSON 形状**不是** `GET /content` 那个丰富形状，只返回下面列出的字段。

        文件条目的 `type` 是探测出的媒体种类（`image` / `video` / `audio` / `document` / `archive` / `file`）。


        ⚠️ 这里**不接受分享令牌**（分享绑定的是一个稳定的条目 id，而 `latest` 没有）；只有房间凭据管用。

        '
      operationId: getLatestContent
      parameters:
      - $ref: '#/components/parameters/RoomQueryOptional'
      - name: all
        in: query
        required: false
        description: 搜**每个**可读房间，而不是只搜 `default`。
        schema:
          $ref: '#/components/schemas/Truthy'
        example: 1
      - $ref: '#/components/parameters/FormatQuery'
      - $ref: '#/components/parameters/JsonQuery'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 最新一条，原始内容或紧凑 JSON。
          content:
            text/plain:
              schema:
                type: string
              examples:
                rawText:
                  summary: 文本条目，原始内容
                  value: 'hello world

                    '
            application/json:
              schema:
                oneOf:
                - $ref: '#/components/schemas/LatestTextEntry'
                - $ref: '#/components/schemas/LatestFileEntry'
              examples:
                text:
                  summary: 文本条目（JSON）
                  value:
                    type: text
                    content: hello world
                    id: '7'
                    timestamp: 1758000000
                    column: todo
                file:
                  summary: 文件条目（JSON）
                  value:
                    type: image
                    name: photo.png
                    size: 20480
                    uuid: 11111111-2222-3333-4444-555555555555
                    url: http://localhost:9501/file/11111111-2222-3333-4444-555555555555/photo.png
                    id: '8'
                    timestamp: 1758000060
                    expire: 1758003600
                    column: todo
        '400':
          $ref: '#/components/responses/UnsupportedFormat'
        '401':
          description: 无权访问解析出来的那个房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: room_forbidden
                    error: No access to this room
                    message: 无权访问该房间
        '404':
          description: 没东西可返回：房间是空的（`no_content`）、解析出的房间与请求的不一致（`content_not_found`），或者最新的那条文件已过期（`file_expired`，只在原始分支上）。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                empty:
                  value:
                    code: no_content
                    error: No content available
                    message: 没有可用的内容
                mismatch:
                  value:
                    code: content_not_found
                    error: Content not found
                    message: 内容未找到
                expired:
                  value:
                    code: file_expired
                    error: File expired
                    message: 文件已过期
  /content/{id}:
    get:
      tags:
      - Content
      summary: 按 id 取一条
      description: '取单独一条。你给的 `?room=` **必须**和该条目记录的房间一致，否则服务端回 `404 content_not_found`

        （它不会确认「你叫错的那个房间里」有没有这条）。


        鉴权可以是房间凭据，**或者**（仅限读）`?t=` 里的分享令牌。分享令牌**永远不能**授权写操作。


        文本条目默认返回原始文本（**总带一个换行**，免得 `curl` 的输出和提示符粘在一起），

        带 `?format=json` 则返回**丰富**的 JSON 投影。文件条目没有正文可返回：`?format=json` 给投影，

        而原始分支回 `404 file_expired`（字节在 `/file/{uuid}`）。

        '
      operationId: getContentById
      parameters:
      - name: id
        in: path
        required: true
        description: 条目 id。必须是整数 —— 非数字的 id 是 **400，不是 404**。
        schema:
          type: integer
          format: int32
        example: 7
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/FormatQuery'
      - $ref: '#/components/parameters/JsonQuery'
      - $ref: '#/components/parameters/ShareTokenQuery'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      - shareTokenQuery: []
      responses:
        '200':
          description: 该条目，原始内容或 JSON 投影。
          content:
            text/plain:
              schema:
                type: string
              examples:
                rawText:
                  value: 'hello world

                    '
            application/json:
              schema:
                $ref: '#/components/schemas/ContentEntry'
              examples:
                text:
                  summary: 文本条目的投影
                  value:
                    type: text
                    content: hello world
                    id: '7'
                    timestamp: 1758000000
                    room: default
                    senderIP: 203.0.113.7
                    senderClientID: web-1a2b
                    senderDevice:
                      name: iPhone
                      type: smartphone
                      os: iOS 18
                      browser: Safari 18
                    column: todo
                file:
                  summary: 文件条目的投影
                  value:
                    type: image
                    name: photo.png
                    size: 20480
                    uuid: 11111111-2222-3333-4444-555555555555
                    cache: 11111111-2222-3333-4444-555555555555
                    thumbnail: data:image/jpeg;base64,/9j/4AAQSkZJRg==
                    url: http://localhost:9501/file/11111111-2222-3333-4444-555555555555/photo.png
                    id: '8'
                    timestamp: 1758000060
                    expire: 1758003600
                    room: default
                    senderIP: 203.0.113.7
                    senderDevice:
                      name: iPhone
                      type: smartphone
                      os: iOS 18
                      browser: Safari 18
                    column: todo
        '400':
          description: id 不是数字，或 `?format=` 不是 raw / json 之一。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badId:
                  value:
                    code: invalid_content_id
                    error: Invalid content id
                    message: 无效的内容 ID
                badFormat:
                  value:
                    code: unsupported_format
                    error: Unsupported format
                    message: 不支持的格式（只支持 raw / json）
        '401':
          description: 无权访问该条目所在的房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: room_forbidden
                    error: No access to this room
                    message: 无权访问该房间
        '404':
          description: 不存在、房间对不上，或（原始分支上）文件已过期。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing:
                  value:
                    code: content_not_found
                    error: Content not found
                    message: 内容未找到
                expired:
                  value:
                    code: file_expired
                    error: File expired
                    message: 文件已过期
        '405':
          $ref: '#/components/responses/OnlyGet'
  /content/{id}/column:
    post:
      tags:
      - Content
      summary: 把一条挪到看板的某一列
      description: '看板是**同一批条目的一个视图**，不是第二份存储：这个接口只在条目上设**一个**字段，别的什么都不动，

        然后广播 `update`。


        - 三列是固定的：`todo`（默认；空或缺失的列都归一到它）、`doing`、`done`。列名**忽略大小写并去首尾空白**。

        - 文本与文件条目都适用。

        - **`timestamp` 刻意不动。** `POST /text?id=` 改写正文时会刷新它，但拖一张卡片**不该**把它顶到时间线最上面。

        - 鉴权要房间凭据。分享令牌**不接受** —— 它是只读的。

        - NOTE (code)：这里的失败码是 `room_auth_required`，与读接口用的 `room_forbidden` **不同**。客户端可以据此分支。

        '
      operationId: setContentColumn
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          format: int32
        example: 7
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                column:
                  $ref: '#/components/schemas/BoardColumn'
            examples:
              move:
                value:
                  column: doing
      responses:
        '200':
          description: 条目已挪动。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ColumnResult'
              examples:
                moved:
                  value:
                    id: '7'
                    type: text
                    column: doing
        '400':
          description: id 不对、JSON 体不对，或列名不认识。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badId:
                  value:
                    code: invalid_content_id
                    error: Invalid content id
                    message: 无效的内容 ID
                badBody:
                  value:
                    code: invalid_body
                    error: Invalid JSON body
                    message: 请求体不是合法的 JSON
                badColumn:
                  value:
                    code: invalid_column
                    error: Unknown board column
                    message: 未知的看板列（只支持 todo / doing / done）
        '401':
          description: 无权访问该条目所在的房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                required:
                  value:
                    code: room_auth_required
                    error: Room authentication required
                    message: 无权访问该房间
        '404':
          description: 没这条，或者它不在请求的那个房间里。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing:
                  value:
                    code: content_not_found
                    error: Content not found
                    message: 内容未找到
        '405':
          $ref: '#/components/responses/OnlyPost'
  /rooms:
    get:
      tags:
      - Rooms
      summary: 房间列表
      description: "列出调用方看得见的房间。需要打开 `server.roomList`，否则回 `403 room_list_disabled`。\n\n- 凭据打不开的房间**整个不出现**，而不是列出来挂把锁。\n\
        - `default` 房间的 `name` 报成**空串**（前端把空名字当作默认房间）。\n- 数组顺序**不属于契约**（Go 版遍历的是 map）。这个服务端给的是确定顺序，但客户端不该依赖它。\n\
        - `isProtected` 的意思是「**确实**需要密码」，所以显式配了 `{\"open\": true}` 的房间即使存在全局密码也报 `false`。\n- 凭据可以给一个主的\
        \ + 若干个附加的，附加的走 `X-Room-Auth-Tokens`（JSON 数组或逗号分隔）——\n  手里有好几个房间密码的客户端靠它一次看全自己所有的房间。\n"
      operationId: listRooms
      parameters:
      - $ref: '#/components/parameters/AuthQuery'
      - name: X-Room-Auth-Tokens
        in: header
        required: false
        description: 附加凭据，JSON 数组（`["a","b"]`）或逗号分隔（`a, b`）。首尾空白会去掉、重复的丢掉、顺序保留，主凭据排在最前。
        schema:
          type: string
        example: '["home-pass","work-pass"]'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 看得见的那些房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoomListResponse'
              examples:
                list:
                  value:
                    rooms:
                    - name: ''
                      messageCount: 12
                      deviceCount: 2
                      lastActive: 1758000000
                      isActive: true
                      isProtected: false
                    - name: home
                      messageCount: 3
                      deviceCount: 0
                      lastActive: 1757990000
                      isActive: false
                      isProtected: true
        '403':
          description: 这个服务端关掉了房间列表功能。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                disabled:
                  value:
                    code: room_list_disabled
                    error: Room list disabled
                    message: 房间列表功能未启用
        '405':
          $ref: '#/components/responses/OnlyGet'
  /stats/daily:
    get:
      tags:
      - Stats
      summary: 房间每天的活跃度
      description: "活跃度热力图背后的数据：这个房间在最近 `days` 个**本地日**里各收到多少条。\n\n**时区是调用方的，不是服务端的。** `tz` 收 IANA 名字；服务端不知道看图的人在哪，\n\
        而「今天」必须是**他的**今天。按 UTC 分桶的话，东八区晚上 8 点之后的活跃会被算进第二天。\n认不出来的名字回落 `UTC`，响应里的 `tz` 报**实际用的**那个\n（`requestedTz`\
        \ 把请求的原样回显，调用方据此分辨有没有回落）。\n\n**`levels` 是这个房间自己那些非零天的分位数**，不是固定阈值。一个每天只收三条的房间，\n在固定阈值下会整张图都是最浅色\
        \ —— 而「哪几天更多」正是来看这张图的唯一理由。\n代价是同一个颜色在不同房间里代表不同的量，所以响应里带着那几道阈值，界面上印的是实际区间。\n\n- `days` 夹到 `371`（53\
        \ 周）。`0`、负数和非数字都表示「没给」而回落到默认值 ——\n  **不是**一张空图，那会被读成「这个房间从来没有过东西」。\n- **已过期的文件不算数。** 服务端在文件过期时会把时间线那条删掉，这里再算一遍会让房间\n\
        \  显得比时间线上看到的更活跃。\n- 窗口为空时 `busiest` 是 `null`；并列时取**最近**那天。\n"
      operationId: getRoomDailyActivity
      parameters:
      - name: room
        in: query
        required: false
        description: 要统计的房间。不传即 `default` 房间。
        schema:
          type: string
        example: work
      - name: days
        in: query
        required: false
        description: 窗口天数，截止到今天。夹到 `371`。`0`、负数和非数字都当作「没给」。
        schema:
          type: integer
          minimum: 0
          maximum: 371
        example: 371
      - name: tz
        in: query
        required: false
        description: 用来判断某个时间戳属于哪个本地日的 IANA 时区名。认不出来就回落 `UTC`。
        schema:
          type: string
        example: Asia/Shanghai
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 所请求窗口内每天的条数。
          content:
            application/json:
              schema:
                type: object
                required:
                - room
                - tz
                - requestedTz
                - truncated
                - activity
                properties:
                  room:
                    type: string
                  tz:
                    type: string
                    description: '''''''''''''''''''''''''''''''**实际用的**时区（回落之后）。'''''''''''''''''''''''''''''''
                  requestedTz:
                    type: string
                    description: 调用方请求的那个，原样回显，这样回落就看得见。
                  truncated:
                    type: boolean
                    description: 为真表示扫描撞到了行数上限，窗口里最旧的那几天可能少算。
                  activity:
                    type: object
                    required:
                    - days
                    - total
                    - busiest
                    - streak
                    - levels
                    properties:
                      days:
                        type: array
                        description: 旧的在前；长度恰好等于生效的 `days`。
                        items:
                          type: object
                          required:
                          - date
                          - count
                          properties:
                            date:
                              type: string
                              description: 本地日期，`YYYY-MM-DD`。
                            count:
                              type: integer
                      total:
                        type: integer
                      busiest:
                        type: object
                        nullable: true
                        properties:
                          date:
                            type: string
                          count:
                            type: integer
                      streak:
                        type: object
                        required:
                        - current
                        - longest
                        properties:
                          current:
                            type: integer
                            description: 一直到今天的连续天数。今天还没内容时**跳过它**、从昨天往回数 —— 这样这个数不会每天早上都掉回 0。
                          longest:
                            type: integer
                      levels:
                        type: array
                        description: 配色用的四道递增阈值。窗口内活跃天不足四天时，这四个值相等。
                        items:
                          type: integer
              examples:
                week:
                  value:
                    room: work
                    tz: Asia/Shanghai
                    requestedTz: Asia/Shanghai
                    truncated: false
                    activity:
                      days:
                      - date: '2026-10-01'
                        count: 0
                      - date: '2026-10-02'
                        count: 3
                      - date: '2026-10-03'
                        count: 0
                      - date: '2026-10-04'
                        count: 12
                      - date: '2026-10-05'
                        count: 5
                      - date: '2026-10-06'
                        count: 0
                      - date: '2026-10-07'
                        count: 8
                      total: 28
                      busiest:
                        date: '2026-10-04'
                        count: 12
                      streak:
                        current: 2
                        longest: 3
                      levels:
                      - 3
                      - 5
                      - 8
                      - 12
                unknownTimezone:
                  summary: '''''''''''''''''''''''''''''''`tz` 认不出来时回落 UTC'''''''''''''''''''''''''''''''
                  value:
                    room: ''
                    tz: UTC
                    requestedTz: Not/AZone
                    truncated: false
                    activity:
                      days:
                      - date: '2026-10-06'
                        count: 0
                      - date: '2026-10-07'
                        count: 1
                      total: 1
                      busiest:
                        date: '2026-10-07'
                        count: 1
                      streak:
                        current: 1
                        longest: 1
                      levels:
                      - 1
                      - 1
                      - 1
                      - 1
        '403':
          $ref: '#/components/responses/RoomForbidden'
  /upload:
    post:
      tags:
      - Files
      summary: 整份上传（multipart）
      description: '一次请求传完一个小文件：写字节、登记文件、在房间里存一条文件条目、广播 `receive`，

        然后返回条目 id 与 URL。


        表单字段名**永远是 `file`**。


        特殊情况：当 `Content-Type` **逐字符**等于 `text/plain` 时（`text/plain; charset=utf-8` **不算**），

        请求体被当成一个**文件名**，这个接口就变成了分片上传的初始化 —— 与 `POST /upload/chunk` 完全一样。

        这是与 Go 实现共有的、刻意保留并且写进文档的怪癖。


        有 `Content-Length` 时，会先拿它跟 `file.limit` 比，**在把文件读进内存之前**就拒掉超大的。


        NOTE (code)：成功响应体是 `{url, id, type}` —— 与 `/text` 同样那三个字段。旧文档在这里还写了

        `name` / `size` / `uuid` / `expire`，这个服务端不返回它们。`type` 是按文件名探测出的媒体种类，

        **不是**字面量 `file`。

        '
      operationId: uploadFile
      parameters:
      - $ref: '#/components/parameters/RoomQuery'
      - $ref: '#/components/parameters/NameQuery'
      - $ref: '#/components/parameters/ClientQuery'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              properties:
                file:
                  type: string
                  format: binary
                  description: 文件字节。part 头里的文件名会成为条目的 `name`。
          text/plain:
            schema:
              type: string
            examples:
              initAsFilename:
                summary: 请求体是文件名 —— 这时它充当分片上传的初始化
                value: 'big-video.mp4

                  '
      responses:
        '200':
          description: 文件已存下并广播（`text/plain` 那种形式则是已初始化）。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadResult'
              examples:
                uploaded:
                  value:
                    url: http://localhost:9501/content/8
                    id: '8'
                    type: image
                initialised:
                  summary: 分片上传初始化的响应
                  value:
                    result:
                      uuid: 11111111-2222-3333-4444-555555555555
        '400':
          description: 表单解析不了，或者没有 `file` 字段。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badForm:
                  value:
                    code: form_parse_failed
                    error: Cannot parse form data
                    message: 无法解析表单数据
                noField:
                  value:
                    code: file_field_missing
                    error: Cannot read uploaded file
                    message: 无法获取文件
        '401':
          description: 无权访问目标房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing:
                  value:
                    code: unauthorized
                    error: Authentication required
                    message: 需要认证令牌
        '413':
          description: 文件超过 `file.limit`。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                tooLarge:
                  value:
                    code: file_too_large
                    error: File too large
                    message: 文件大小超出限制 (最大 268435456 字节)
        '500':
          description: 写文件或登记失败。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                saveFailed:
                  value:
                    code: file_save_failed
                    error: Cannot save file
                    message: 无法保存文件
        '405':
          $ref: '#/components/responses/OnlyPost'
  /upload/chunk:
    post:
      tags:
      - Files
      summary: 分片上传 —— 初始化
      description: '分片上传的第一步。请求体是**文件名**（纯文本），这时还不写任何字节。

        响应里带着 `uuid`，后面追加分片与收尾都用它。


        ```text

        POST /upload/chunk          body = 文件名            → {"result":{"uuid":"..."}}

        POST /upload/chunk/{uuid}   body = 一片的字节        → {}

        POST /upload/finish/{uuid}  （无 body）              → {"url","id","type"}

        ```


        这条路由与 `POST /upload` **共用同一个处理器**，区别只在路径和那道 `Content-Type: text/plain` 的关卡。

        '
      operationId: initChunkedUpload
      parameters:
      - $ref: '#/components/parameters/RoomQuery'
      - $ref: '#/components/parameters/NameQuery'
      - $ref: '#/components/parameters/ClientQuery'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      requestBody:
        required: true
        description: 文件名，纯文本。
        content:
          text/plain:
            schema:
              type: string
            examples:
              init:
                value: 'big-video.mp4

                  '
      responses:
        '200':
          description: 一个新的上传 uuid。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChunkInitResult'
              examples:
                init:
                  value:
                    result:
                      uuid: 11111111-2222-3333-4444-555555555555
        '401':
          description: 无权访问目标房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing:
                  value:
                    code: unauthorized
                    error: Authentication required
                    message: 需要认证令牌
        '500':
          description: 登记这次上传失败。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                saveFailed:
                  value:
                    code: file_save_failed
                    error: Cannot save file
                    message: 无法保存文件
        '405':
          $ref: '#/components/responses/OnlyPost'
  /upload/chunk/{uuid}:
    post:
      tags:
      - Files
      summary: 分片上传 —— 追加一片
      description: '往一个先前用 `POST /upload/chunk` 初始化过的上传里追加原始字节。每次调用都会更新已记录的体积，

        累计值会拿去跟 `file.limit` 比。


        分片大小按 `file.chunk`（默认 1 MiB）左右发。**单个请求体上限 16 MiB**，由 HTTP 层管，

        与 `file.limit` 无关。


        NOTE (code)：鉴权用的是**初始化时记录在上传上的那个房间**，不是这次请求里带的 `?room=`。

        '
      operationId: appendChunk
      parameters:
      - name: uuid
        in: path
        required: true
        schema:
          type: string
          format: uuid
        example: 11111111-2222-3333-4444-555555555555
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        '200':
          description: 分片已追加。
          content:
            application/json:
              schema:
                type: object
                description: 一个空对象（这个服务端在这里**不回 204**）。
                additionalProperties: false
              examples:
                ok:
                  value: {}
        '400':
          description: 那个 uuid 下没有登记过上传。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badUuid:
                  value:
                    code: invalid_uuid
                    error: Invalid UUID
                    message: 无效的 UUID
        '401':
          description: 无权访问这次上传所属的房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing:
                  value:
                    code: unauthorized
                    error: Authentication required
                    message: 需要认证令牌
        '413':
          description: 累计体积会超过 `file.limit`。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                tooLarge:
                  value:
                    code: file_too_large
                    error: File too large
                    message: 文件大小已超过限制 (最大 268435456 字节)
        '500':
          description: 分片写不进去。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                writeFailed:
                  value:
                    code: file_write_failed
                    error: Cannot write file
                    message: 无法写入文件
        '405':
          $ref: '#/components/responses/OnlyPost'
  /upload/finish/{uuid}:
    post:
      tags:
      - Files
      summary: 分片上传 —— 封口并广播
      description: '收尾一次分片上传：生成文件条目（字节能解码成图片、且不超过 32 MiB 时**顺带生成缩略图**）、

        存下来、广播 `receive`。


        房间取自**这次上传自己的登记**；在这里带 `?room=default` **不能**把它挪出受保护的房间。


        NOTE (code)：成功响应体是 `{url, id, type}`，而 `type` 是按文件名探测出的媒体种类，

        不是字面量 `file`。

        '
      operationId: finishChunkedUpload
      parameters:
      - name: uuid
        in: path
        required: true
        schema:
          type: string
          format: uuid
        example: 11111111-2222-3333-4444-555555555555
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/NameQuery'
      - $ref: '#/components/parameters/ClientQuery'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 文件条目已存下并广播。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadResult'
              examples:
                finished:
                  value:
                    url: http://localhost:9501/content/9
                    id: '9'
                    type: video
        '400':
          description: 那个 uuid 下没有登记过上传。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badUuid:
                  value:
                    code: invalid_uuid
                    error: Invalid UUID
                    message: 无效的 UUID
        '401':
          description: 无权访问这次上传所属的房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing:
                  value:
                    code: unauthorized
                    error: Authentication required
                    message: 需要认证令牌
        '500':
          description: 存消息失败。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                storeFailed:
                  value:
                    code: store_failed
                    error: Failed to store message
                    message: 保存消息失败
        '405':
          $ref: '#/components/responses/OnlyPost'
  /file/{uuid}:
    get:
      tags:
      - Files
      summary: 下载或预览文件
      description: "把文件字节流出去。支持 `Range`（拖视频进度条、断点续传都能用），\n`If-Modified-Since` / `If-None-Match` 也原样透传。\n\
        \n- `Content-Type` 是按**原始文件名**猜的，不是按磁盘上的名字（磁盘上是不带扩展名的裸 uuid）。\n  文本类型会带上 `; charset=utf-8`，这样中文\
        \ `.txt` 不会被渲染成 Latin-1 乱码。\n- `?download=true` 会加上 `Content-Disposition: attachment`；否则是 `inline`，浏览器直接渲染。\n\
        - 鉴权用**文件上记录的那个房间** —— 客户端传的 `?room=` 被忽略，这正是「`?room=default`\n  读不到受保护房间的文件」的原因。\n- **分享令牌**（`?t=`）只对\
        \ `GET` 有效，而且必须指向**这个**文件。界面上那颗分享按钮签的是\n  `content` 类型的令牌、目标是文件 —— 那种也接受，但只在这个内容确实引用该 uuid 时。\n"
      operationId: downloadFile
      parameters:
      - name: uuid
        in: path
        required: true
        schema:
          type: string
        example: 11111111-2222-3333-4444-555555555555
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      - $ref: '#/components/parameters/ShareTokenQuery'
      - name: download
        in: query
        required: false
        description: 为 `true` 时强制下载，而不是内联渲染。
        schema:
          type: boolean
        example: true
      - name: Range
        in: header
        required: false
        description: 标准的 HTTP 字节区间；**不消耗**分享令牌的次数。
        schema:
          type: string
        example: bytes=0-1023
      - $ref: '#/components/parameters/SharePasswordHeader'
      security:
      - bearerAuth: []
      - authQuery: []
      - shareTokenQuery: []
      responses:
        '200':
          description: 文件字节。
          headers:
            Content-Type:
              description: 按原始文件名猜出来的。
              schema:
                type: string
            Content-Disposition:
              description: 默认 `inline`，带 `?download=true` 时是 `attachment`。
              schema:
                type: string
            Accept-Ranges:
              description: 有这一条是因为支持区间请求。
              schema:
                type: string
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '206':
          description: 部分响应（Range 请求）。
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '304':
          description: 未修改（条件请求）。
        '401':
          description: 无权访问该文件所在的房间，且没有可用的分享令牌。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing:
                  value:
                    code: unauthorized
                    error: Authentication required
                    message: 需要认证令牌
                invalid:
                  value:
                    code: unauthorized_invalid_token
                    error: Invalid auth token
                    message: 无效的认证令牌
        '404':
          description: 没这个文件、它已过期，或者字节已从磁盘上消失。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  value:
                    code: file_not_found
                    error: File not found or expired
                    message: 文件未找到或已过期
                expired:
                  value:
                    code: file_expired
                    error: File expired
                    message: 文件已过期
                missingOnDisk:
                  value:
                    code: file_missing_on_disk
                    error: File missing on disk
                    message: 文件在磁盘上未找到
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
    delete:
      tags:
      - Files
      summary: 删文件
      description: 从磁盘删掉字节、从存储里删掉登记。删一个**本来就没了**的文件不算错（`NotFound` 被容忍）。分享令牌在这里**不管用**：它是只读的。
      operationId: deleteFile
      parameters:
      - name: uuid
        in: path
        required: true
        schema:
          type: string
        example: 11111111-2222-3333-4444-555555555555
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 文件已删除。
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
              examples:
                deleted:
                  value:
                    status: 文件删除成功
        '401':
          description: 无权访问该文件所在的房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing:
                  value:
                    code: unauthorized
                    error: Authentication required
                    message: 需要认证令牌
        '404':
          description: 没这个文件，或者它已过期。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  value:
                    code: file_not_found
                    error: File not found or expired
                    message: 文件未找到或已过期
                expired:
                  value:
                    code: file_expired
                    error: File expired
                    message: 文件已过期
        '500':
          description: 字节还在，但删不掉。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                deleteFailed:
                  value:
                    code: file_delete_failed
                    error: Failed to delete file
                    message: 删除文件失败
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
  /file/{uuid}/{name}:
    parameters:
    - name: uuid
      in: path
      required: true
      schema:
        type: string
      example: 11111111-2222-3333-4444-555555555555
    - name: name
      in: path
      required: true
      description: 只是给人看的 —— 服务端认的是 uuid。
      schema:
        type: string
      example: photo.png
    get:
      tags:
      - Files
      summary: 下载或预览文件（带文件名那种写法）
      description: 与 `GET /file/{uuid}` 完全相同；末尾那段文件名是为了让 URL 看起来像个真文件名。
      operationId: downloadFileNamed
      parameters:
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      - $ref: '#/components/parameters/ShareTokenQuery'
      - name: download
        in: query
        required: false
        schema:
          type: boolean
      - name: Range
        in: header
        required: false
        schema:
          type: string
      - $ref: '#/components/parameters/SharePasswordHeader'
      security:
      - bearerAuth: []
      - authQuery: []
      - shareTokenQuery: []
      responses:
        '200':
          description: 文件字节。
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '206':
          description: 部分响应（Range 请求）。
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '304':
          description: 未修改（条件请求）。
        '401':
          description: 无权访问该文件所在的房间，且没有可用的分享令牌。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid:
                  value:
                    code: unauthorized_invalid_token
                    error: Invalid auth token
                    message: 无效的认证令牌
        '404':
          description: 没这个文件、它已过期，或者字节已从磁盘上消失。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  value:
                    code: file_not_found
                    error: File not found or expired
                    message: 文件未找到或已过期
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
    delete:
      tags:
      - Files
      summary: 删文件（带文件名那种写法）
      description: 与 `DELETE /file/{uuid}` 完全相同。
      operationId: deleteFileNamed
      parameters:
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 文件已删除。
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
              examples:
                deleted:
                  value:
                    status: 文件删除成功
        '401':
          description: 无权访问该文件所在的房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing:
                  value:
                    code: unauthorized
                    error: Authentication required
                    message: 需要认证令牌
        '404':
          description: 没这个文件，或者它已过期。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  value:
                    code: file_not_found
                    error: File not found or expired
                    message: 文件未找到或已过期
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
  /revoke/{id}:
    post:
      tags:
      - Content
      summary: 删掉一条
      description: '删掉这条，并广播 `revoke`（`{"id": <number>}`）。成功响应**没有 body**（光秃秃一个 200），

        与参考实现一致。


        NOTE (code)：这个服务端**只接受 POST**。其他任何方法（包括 GET）都回 `405`。

        更早的实现根本没有方法检查，于是**在浏览器里访问一下 `/revoke/5` 就真的删掉了第 5 条**；

        这个洞在这里堵上了。

        '
      operationId: revokeEntry
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          format: int32
        example: 7
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 条目已删除。响应体为空。
        '400':
          description: id 不是数字。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badId:
                  value:
                    code: invalid_revoke_id
                    error: Invalid revoke id
                    message: 无效的撤销 ID
        '401':
          description: 无权访问该条目所在的房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: room_forbidden
                    error: No access to this room
                    message: 无权访问该房间
        '404':
          description: 没这条，或者它不在请求的那个房间里。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  value:
                    code: message_not_found
                    error: Message not found
                    message: 消息未找到
        '500':
          description: 删除失败。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                storeFailed:
                  value:
                    code: store_failed
                    error: Failed to revoke message
                    message: 撤销消息失败
        '405':
          $ref: '#/components/responses/OnlyPost'
  /revoke/all:
    post:
      tags:
      - Content
      summary: 清空一个房间
      description: '删掉这个房间里的**每一条**，并广播 `clearAll`（`{"room": "..."}`）。只清被点名的那个房间 ——

        **刻意没有**「清空一切」这个能力。`?room=` 为空或不传即 `default` 房间。


        NOTE (code)：成功响应体是**纯文本**（`所有消息已清除\n`），不是 JSON，照参考实现来的。

        在这里硬编码 JSON 的客户端会挂。

        '
      operationId: clearRoom
      parameters:
      - $ref: '#/components/parameters/RoomQuery'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 房间里的每条消息都已清除。
          content:
            text/plain:
              schema:
                type: string
              examples:
                cleared:
                  value: '所有消息已清除

                    '
        '401':
          description: 无权访问该房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: room_forbidden
                    error: No access to this room
                    message: 无权访问该房间
        '405':
          $ref: '#/components/responses/OnlyPost'
  /share:
    post:
      tags:
      - Sharing
      summary: 签一个分享令牌
      description: '签一枚带签名、自描述的令牌，授予对**一条内容或一个文件**的**读**权限。有效期、次数上限、密码

        全都写在令牌里，所以**总是**签令牌 —— 包括对开放的房间，那里如果只回一个裸 URL，

        就等于把调用方要的那几道限制**悄悄丢掉**了。


        请求体（JSON，所有字段可选；**空体即全部用默认值**）：


        | 字段 | 说明 |

        |---|---|

        | `type` | `content` 或 `file`。缺失 → 400 `missing_type` |

        | `id` | `type: content` 时是条目 id（字符串） |

        | `uuid` | `type: file` 时是文件 uuid。也接受 `id`（老客户端） |

        | `ttl` | 秒；默认 900，夹到 60 – 86400 |

        | `maxUses` | `0`（默认）= 不限次 |

        | `password` | 可选；给了之后收件方必须带 `X-Share-Password` |


        ⚠️ **不认识的字段会被拒（400），而不是忽略** —— 这样把 `max_uses` 写成下划线的人

        不会**悄悄**丢掉他配的那道限制。


        响应里有两种寻址形式：`url` / `pageUrl`（落地页，**爬虫和人看到的是同一个地址**），

        以及 `rawUrl`（带同一枚令牌直达内容/文件接口）。

        '
      operationId: createShare
      parameters:
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ShareRequest'
            examples:
              content:
                summary: 分享一条文本，15 分钟
                value:
                  type: content
                  id: '7'
                  ttl: 900
                  maxUses: 0
                  password: ''
              file:
                summary: 分享一个文件，5 次，带密码
                value:
                  type: file
                  uuid: 11111111-2222-3333-4444-555555555555
                  ttl: 3600
                  maxUses: 5
                  password: hunter2
      responses:
        '200':
          description: 分享已创建。
          content:
            application/json:
              schema:
                oneOf:
                - $ref: '#/components/schemas/ShareCreatedContent'
                - $ref: '#/components/schemas/ShareCreatedFile'
              examples:
                content:
                  summary: 内容分享
                  value:
                    type: content
                    id: '7'
                    room: default
                    ttl: 900
                    expiresAt: 1750000000
                    maxUses: 0
                    token: eyJ0eXAiOiJzaGFyZSJ9.9f2c
                    jti: 9f2c8b1e4d5a4f7d
                    url: https://clip.example.com/s/eyJ0eXAiOiJzaGFyZSJ9.9f2c
                    pageUrl: https://clip.example.com/s/eyJ0eXAiOiJzaGFyZSJ9.9f2c
                    rawUrl: https://clip.example.com/content/7?t=eyJ0eXAiOiJzaGFyZSJ9.9f2c
                    visits: 0
                    scans: 0
                file:
                  summary: 文件分享
                  value:
                    type: file
                    uuid: 11111111-2222-3333-4444-555555555555
                    room: default
                    ttl: 3600
                    expiresAt: 1750003600
                    maxUses: 5
                    token: eyJ0eXAiOiJzaGFyZSJ9.a1b2
                    jti: 1a2b3c4d5e6f7a8b
                    url: https://clip.example.com/s/eyJ0eXAiOiJzaGFyZSJ9.a1b2
                    pageUrl: https://clip.example.com/s/eyJ0eXAiOiJzaGFyZSJ9.a1b2
                    rawUrl: https://clip.example.com/file/11111111-2222-3333-4444-555555555555/photo.png?t=eyJ0eXAiOiJzaGFyZSJ9.a1b2
                    visits: 0
                    scans: 0
        '400':
          description: JSON 体不合法（`invalid_request_body`）、有未知字段，或者 `type` / `id` / `uuid` 缺失或非法。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missingType:
                  value:
                    code: missing_type
                    error: Missing type
                    message: 缺少 type
                unsupportedType:
                  value:
                    code: unsupported_type
                    error: Unsupported type
                    message: 不支持的 type
                badId:
                  value:
                    code: invalid_id
                    error: Invalid id
                    message: 无效的 id
                badBody:
                  value:
                    code: invalid_request_body
                    error: Invalid request body
                    message: 无效的请求体
        '401':
          description: 无权访问目标所属的那个房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: room_forbidden
                    error: No access to this room
                    message: 无权访问该房间
        '404':
          description: 目标条目或文件不存在（或已过期），或者不在请求的那个房间里。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                contentMissing:
                  value:
                    code: content_not_found
                    error: Content not found
                    message: 内容未找到
                fileMissing:
                  value:
                    code: file_not_found
                    error: File not found or expired
                    message: 文件未找到或已过期
                expired:
                  value:
                    code: file_expired
                    error: File expired
                    message: 文件已过期
        '405':
          $ref: '#/components/responses/OnlyPost'
    get:
      tags:
      - Sharing
      summary: 分享的元信息（**不消耗次数**）
      description: "分享落地页在取任何东西**之前**先问这个：类型、文件名与大小、还剩多久、还剩几次、要不要密码。\n\n- **它不消耗次数** —— 光打开页面不该烧掉一次。\n\
        - 密码走 `X-Share-Password`，**永远不**进 URL。\n- 受密码保护的分享还会多回一个 `previewToken`（以及 `previewExpiresAt`）。\n\
        \  浏览器没法给 `<img>` / `<video>` / `<a download>` 加自定义头，所以页面需要这枚令牌来内联预览。\n  ⚠️ 不受保护的分享**刻意不给**预览令牌：那会是个不限次的东西，等于把\
        \ `maxUses` 废掉。\n- `used` 只在「限次且还有活的记录」的分享上出现。\n"
      operationId: getShareInfo
      parameters:
      - $ref: '#/components/parameters/ShareTokenQueryRequired'
      - $ref: '#/components/parameters/SharePasswordHeader'
      security: []
      responses:
        '200':
          description: 分享的元信息。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShareInfo'
              examples:
                textNoPassword:
                  summary: 文本分享，无密码，不限次
                  value:
                    type: content
                    kind: text
                    id: '7'
                    room: default
                    expiresAt: 1750000000
                    maxUses: 0
                    needsPassword: false
                fileWithPassword:
                  summary: 文件分享，带密码与次数上限
                  value:
                    type: content
                    kind: file
                    id: '8'
                    room: default
                    expiresAt: 1750003600
                    maxUses: 5
                    used: 2
                    needsPassword: true
                    uuid: 11111111-2222-3333-4444-555555555555
                    name: photo.png
                    size: 20480
                    previewToken: eyJ0eXAiOiJwcmV2aWV3In0.zz99
                    previewExpiresAt: 1750003600
        '400':
          description: 这枚令牌带的是本接口描述不了的分享类型。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                unsupported:
                  value:
                    code: unsupported_type
                    error: Unsupported type
                    message: 不支持的分享类型
        '401':
          description: 令牌不对/已过期，或者需要密码/密码不对。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid:
                  value:
                    code: share_token_invalid
                    error: Share link is invalid or expired
                    message: 分享链接无效或已过期
                password:
                  value:
                    code: share_password_required
                    error: Share password required
                    message: 需要分享密码
        '404':
          description: 分享指向的东西已经不在了。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                contentMissing:
                  value:
                    code: content_not_found
                    error: Content not found
                    message: 内容未找到
                fileMissing:
                  value:
                    code: file_not_found
                    error: File not found or expired
                    message: 文件未找到或已过期
                expired:
                  value:
                    code: file_expired
                    error: File expired
                    message: 文件已过期
    head:
      tags:
      - Sharing
      summary: 分享的元信息（只要响应头）
      description: 与 `GET /share?t=` 相同，只是不要响应体。
      operationId: headShareInfo
      parameters:
      - $ref: '#/components/parameters/ShareTokenQueryRequired'
      - $ref: '#/components/parameters/SharePasswordHeader'
      security: []
      responses:
        '200':
          description: 分享存在，且令牌可用。
        '401':
          description: 令牌不对/已过期，或者需要密码/密码不对。
        '404':
          description: 分享指向的东西已经不在了。
  /share/list:
    get:
      tags:
      - Sharing
      summary: 这个房间最近的分享
      description: "列出某个房间最近的分享记录，以及每个链接被打开了几次。鉴权与「在该房间创建分享」完全一致 ——\n所以**不可能**出现「能创建分享却看不到自己的」。\n\n- `limit`\
        \ 默认 50，上限 200。\n- ⚠️ **列表里永远不含令牌。** 令牌是持有即用的凭据；列出来的话，任何能读这个房间的人\n  都能拿去复用别人的分享。\n- `password`\
        \ 只报**有没有**设密码，绝不报它的哈希。\n- `expired` 是按当前时间算出来的。\n"
      operationId: listShares
      parameters:
      - $ref: '#/components/parameters/RoomQuery'
      - name: limit
        in: query
        required: false
        description: 每页条数，默认 50，最大 200。回给你的就是实际用的那个值。
        schema:
          type: integer
          minimum: 1
          maximum: 200
        example: 50
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 该房间的分享记录。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShareList'
              examples:
                list:
                  value:
                    room: default
                    total: 3
                    limit: 50
                    records:
                    - jti: 9f2c8b1e4d5a4f7d
                      type: content
                      kind: text
                      id: '7'
                      room: default
                      name: first line of the text
                      size: 0
                      createdAt: 1749999000
                      expiresAt: 1750000000
                      maxUses: 0
                      used: 0
                      visits: 2
                      scans: 1
                      password: false
                      expired: false
        '401':
          description: 无权访问该房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: room_forbidden
                    error: No access to this room
                    message: 无权访问该房间
        '405':
          $ref: '#/components/responses/OnlyGet'
  /share/visit:
    post:
      tags:
      - Sharing
      summary: 上报「有人打开了分享」
      description: "分享页挂载时调一次。服务端自己校验令牌；无效或已过期的令牌回 401，**永远不计入**。\n\n- 同一个访客十分钟内再报一次时 `tracked` 是 `false`\
        \ —— 重复上报不能把数字灌大。\n  返回的计数仍然是最新的。\n- `qr: true`（或 `?q=1`）在「打开」之外**再记一次扫码**。二维码里应该编 `/s/<token>?q=1`。\n\
        - **不需要鉴权**：拿着链接就是上报的资格，而响应也只描述这一个分享。\n- 光是请求 `/s/{token}` **本身从不计数** —— 这就是为什么聊天软件的爬虫\n  （它们按自己的节奏反复抓那个地址、也不执行\
        \ JavaScript）灌不了水。\n"
      operationId: reportShareVisit
      parameters:
      - $ref: '#/components/parameters/ShareTokenQuery'
      - name: q
        in: query
        required: false
        description: '''''''''''''''`1` 表示这次访问来自扫二维码。'''''''''''''''
        schema:
          type: string
          enum:
          - '1'
        example: '1'
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ShareVisitRequest'
            examples:
              qr:
                value:
                  token: eyJ0eXAiOiJzaGFyZSJ9.9f2c
                  qr: true
      responses:
        '200':
          description: 上报已处理。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShareVisitResult'
              examples:
                tracked:
                  value:
                    ok: true
                    tracked: true
                    visits: 3
                    scans: 1
                debounced:
                  summary: 同一访客十分钟内 —— 不重复计数
                  value:
                    ok: true
                    tracked: false
                    visits: 3
                    scans: 1
        '400':
          description: 没给令牌，或者请求体不是合法 JSON。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing:
                  value:
                    code: missing_token
                    error: Missing token
                    message: 缺少 token
                badBody:
                  value:
                    code: invalid_request_body
                    error: Invalid request body
                    message: 无效的请求体
        '401':
          description: 令牌不对或已过期（过期链接不计入）。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid:
                  value:
                    code: share_token_invalid
                    error: Share link is invalid or expired
                    message: 分享链接无效或已过期
        '405':
          $ref: '#/components/responses/OnlyPost'
  /s/{token}:
    get:
      tags:
      - Sharing
      summary: 分享落地页（HTML）
      description: "分享链接的**唯一地址**：一个 SPA 外壳，`<head>` 里注入了 Open Graph 标签。\n聊天软件展开这个 URL 时看到的是一张真的预览卡（它不执行\
        \ JavaScript，所以摘要必须服务端渲染好）；\n人点开**同一个** URL 则落到分享页本身。不跳转、没有第二个地址、没有第二个身份。\n\n- **它既不计数也不消耗次数**：只有\
        \ `POST /share/visit` 计。\n- 受密码保护的分享**绝不**把内容摘要泄进卡片里。\n- `og:image` 只给**不限次**分享里的图片加上 —— 爬虫去取那张图会在\
        \ `/file` 那条路上消耗一次，\n  而预览把额度烧光，等于把一条已经用尽的链接递给真人。\n- 响应头：`X-Robots-Tag: noindex, nofollow`、`Cache-Control:\
        \ private, max-age=0, must-revalidate`、\n  `Referrer-Policy: no-referrer`、`X-Content-Type-Options:\
        \ nosniff`。\n- 没有静态资源时（纯 API 部署）回一个自带的兜底页，标签一样。\n"
      operationId: getShareLandingPage
      parameters:
      - name: token
        in: path
        required: true
        schema:
          type: string
        example: eyJ0eXAiOiJzaGFyZSJ9.9f2c
      security: []
      responses:
        '200':
          description: 分享页的 HTML。
          headers:
            X-Robots-Tag:
              schema:
                type: string
            Cache-Control:
              schema:
                type: string
            Referrer-Policy:
              schema:
                type: string
          content:
            text/html:
              schema:
                type: string
              examples:
                html:
                  summary: 节选
                  value: '<!doctype html><html><head>

                    <title>photo.png</title>

                    <meta property="og:title" content="photo.png" />

                    <meta property="og:description" content="文件 · 20 KB · 15 分钟后过期" />

                    <meta property="og:url" content="https://clip.example.com/s/eyJ0eXAiOiJzaGFyZSJ9.9f2c"
                    />

                    </head><body><div id="app"></div></body></html>

                    '
        '405':
          $ref: '#/components/responses/OnlyGet'
  /push:
    get:
      tags:
      - Realtime
      summary: WebSocket 推送通道
      description: "升级成 WebSocket，流式推这个房间的事件。注册成 `GET` 是因为 WebSocket 握手本来就是 GET。\n\n**握手顺序（这个顺序就是契约）：**\n\
        \n1. 房间受保护时：没带凭据回 `401 unauthorized_missing_token`，带错了回\n   `401 unauthorized_invalid_token`。两者**分开**，客户端才能分辨「该去要密码」和「密码是错的」。\n\
        \   拒绝发生在升级**之前**，所以那是一个普通的 JSON 响应。\n2. `Sec-WebSocket-Protocol` 要**回显**。客户端给了子协议而服务端不选一个，浏览器就会判定握手失败\
        \ ——\n   客户端正是靠它把令牌传进来、又不放进 URL。\n3. 升级。\n4. 帧序：先给房间里已有的每台设备各来一条 `connect`，再给这条连接向其他人发一条 `connect`，\n\
        \   然后是 `config`，最后才是实时事件。\n\n⚠️★ **握手不推历史。** 历史用 `GET /content` 取，并用 `config.latestId`\n—— 连接那一刻房间里最大的消息\
        \ id（空房间为 `0`）—— 来对齐历史与实时的边界。\n`id <= latestId` 当历史（记下来，**不要**写剪贴板），`id > latestId` 才是实时。\n**绝不要**用时间窗口去猜边界：窗口早结束一点，就会**悄悄**把整段历史灌进剪贴板。\n\
        拿不到 `latestId`（服务端太旧）时，**宁可拒绝写入，也不要猜**。\n\n服务端每 5 秒发一次协议级 ping（否则空闲连接会在代理后面**悄无声息地**死掉），\n并且会把**跟不上广播**的客户端断开\
        \ —— 让它重连、重新同步，而不是安静地漏掉一条 `revoke`。\n重连与退避是客户端的事。\n"
      operationId: pushChannel
      parameters:
      - $ref: '#/components/parameters/RoomQuery'
      - $ref: '#/components/parameters/NameQuery'
      - $ref: '#/components/parameters/AuthQuery'
      - name: Sec-WebSocket-Protocol
        in: header
        required: false
        description: 凭据优先从 `Authorization` / `?auth=` 取，取不到才看第一个子协议。**优先用这个载体**：它让令牌不进 URL，也就不进访问日志。
        schema:
          type: string
        example: Bearer eyJhbGciOiJIUzI1NiJ9.abc
      - name: Sec-WebSocket-Version
        in: header
        required: true
        schema:
          type: integer
          enum:
          - 13
      - name: Upgrade
        in: header
        required: true
        schema:
          type: string
          enum:
          - websocket
      - name: Connection
        in: header
        required: true
        schema:
          type: string
          enum:
          - Upgrade
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '101':
          description: 正在切换协议。之后事件帧按下面写的流动。
        '401':
          description: 房间受保护，而凭据缺失或不对。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing:
                  value:
                    code: unauthorized_missing_token
                    error: Missing auth token
                    message: 'Unauthorized: Missing token'
                invalid:
                  value:
                    code: unauthorized_invalid_token
                    error: Invalid auth token
                    message: 'Unauthorized: Invalid token'
      x-websocket-events:
        frameFormat: '{"event": "<name>", "data": <payload>}'
        clientToServer:
        - event: ping
          description: 应用层 ping。服务端会把 `data` 原样放进 `pong` 回显，客户端据此用 `Date.now() - data` 算 RTT。
          example:
            event: ping
            data: 1758000000123
        serverToClient:
        - event: connect
          description: 房间里的一台设备，或者这条连接在向其他人宣告自己。
          schemaRef: '#/components/schemas/DeviceMeta'
        - event: disconnect
          description: 某台设备的**最后一条**连接断了。一台设备开多个标签页时，关掉其中一个**不**报断开。
          schemaRef: '#/components/schemas/DisconnectData'
        - event: config
          description: 连上之后发一次，排在任何实时消息**之前**。`version` 与那几个大小限额**只在这里**给，也是前端 `app.config` 的唯一来源。
          schemaRef: '#/components/schemas/WsConfig'
        - event: receive
          description: 房间里来了新内容。
          schemaRef: '#/components/schemas/ReceiveEntry'
        - event: update
          description: 已有条目被**原地**改了（同一个 `id`）—— 改写正文或挪看板列。
          schemaRef: '#/components/schemas/ReceiveEntry'
        - event: revoke
          description: 某一条被删了。
          schemaRef: '#/components/schemas/RevokeData'
        - event: clearAll
          description: 房间被清空了。
          schemaRef: '#/components/schemas/ClearAllData'
        - event: pong
          description: 对客户端 `ping` 的回复，`data` 原样带回。
        - event: ok
          description: 组件之间的内部事件；**不会**发给 WebSocket 客户端。
  /tasks:
    get:
      tags:
      - Automation
      summary: 列出这个房间的定时任务
      description: '列出**凭据对应的那个房间**的定时任务。列表**永远**按房间过滤，管理员也一样。

        `tier` 是 `admin` / `room` / `single` / `none`；`max` 为 `0` 表示不限。

        `single` 档的房间只返回与出示的任务令牌匹配的那些，`foreignCount` 报被藏起来了几条。

        每个任务上只读的有：`nextRunAt`、`lastRunAt`、`lastStatus`。

        '
      operationId: listTasks
      parameters:
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      - $ref: '#/components/parameters/TaskTokenQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      - taskToken: []
      responses:
        '200':
          description: 该房间的定时任务。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskList'
              examples:
                list:
                  value:
                    room: home
                    tier: room
                    admin: false
                    max: 20
                    now: 1758000000
                    foreignCount: 0
                    tasks:
                    - id: 5f3c1e2a-9b7d-4c6e-8a1f-2d3e4f5a6b7c
                      name: On-call reminder
                      enabled: true
                      freq: cron
                      time: ''
                      cron: 30 9 * * *
                      byWeekday: []
                      runAt: ''
                      tz: Asia/Shanghai
                      room: home
                      template: Today is {{date}}
                      chain:
                      - text.trimLines
                      keepHistory: false
                      sender: 定时任务
                      createdAt: 1757900000
                      updatedAt: 1757900000
                      nextRunAt: 1758072600
                      lastRunAt: 1757986200
                      lastStatus: ok
                      lastError: ''
                      lastOutput: Today is 2026-09-16
                      desc:
                        mode: minutesEachHour
                        minutes: '30'
                        hours: '9'
                        day: daily
                    vars:
                    - date
                    - weekday
                    - time
                    - datetime
                    - timestamp
                    - uuid
                    - task
                    - room
                    actions:
                    - id: text.trimLines
                      group: text
                      groupKey: actionGroupText
                      key: actionTrimLines
        '403':
          description: 这个房间不允许用定时任务。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: automation_forbidden
                    error: Automation is not allowed in this room
                    message: 房间 home 未开放自动化（需要该房间的凭据，或由管理员在 roomAuth 里设置 automation）
        '404':
          description: 服务端整体关掉了定时任务（`automation.enabled = false`）。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                disabled:
                  value:
                    code: automation_disabled
                    error: Automation is disabled
                    message: 定时自动化未启用（automation.enabled = false）
        '405':
          $ref: '#/components/responses/OnlyGetPost'
    post:
      tags:
      - Automation
      summary: 新建或整份替换一个定时任务
      description: '不给 `id` 就是新建；给了已存在的 `id` 就是**整份替换**那个任务。

        ⚠️ 房间取自**凭据** —— 请求体里的 `room` 字段**不会被读**。

        时间、模板、动作 id 都在**保存时**校验（`invalid_task`），绝不静默忽略。

        值得注意的是：模板变量不认识、或者偏移量写坏，都会在这里失败；

        而 `freq: cron` 配一条根本不会触发的表达式同样失败。

        '
      operationId: upsertTask
      parameters:
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      - $ref: '#/components/parameters/TaskTokenQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      - taskToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskRequest'
            examples:
              create:
                summary: 一条每天提醒
                value:
                  name: On-call reminder
                  freq: daily
                  time: 09:30
                  tz: Asia/Shanghai
                  template: Today is {{date}}, tomorrow is {{date:+1d}} ({{weekday:+1d}})
                  chain:
                  - text.trimLines
                  keepHistory: false
              cron:
                summary: 工作日每半小时，09:00–18:00
                value:
                  name: Weekday pinger
                  freq: cron
                  cron: '*/30 9-18 * * 1-5'
                  tz: Asia/Shanghai
                  template: 'Now: {{time}}'
      responses:
        '200':
          description: 任务已保存。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskSaved'
              examples:
                roomTier:
                  value:
                    task:
                      id: 5f3c1e2a-9b7d-4c6e-8a1f-2d3e4f5a6b7c
                      name: On-call reminder
                      enabled: true
                      freq: daily
                      time: 09:30
                      cron: ''
                      byWeekday: []
                      runAt: ''
                      tz: Asia/Shanghai
                      room: home
                      template: Today is {{date}}
                      chain: []
                      keepHistory: false
                      sender: 定时任务
                      createdAt: 1757900000
                      updatedAt: 1757900000
                      nextRunAt: 1758072600
                      lastRunAt: 0
                      lastStatus: ''
                      lastError: ''
                      lastOutput: ''
                singleTier:
                  summary: single 档房间 —— 任务令牌只发一次
                  value:
                    task:
                      id: 5f3c1e2a-9b7d-4c6e-8a1f-2d3e4f5a6b7c
                      name: One-off order
                      room: lobby
                      enabled: true
                      freq: once
                      runAt: '2026-10-01T09:30:00+08:00'
                      tz: Asia/Shanghai
                      template: 'Order: {{uuid}}'
                      chain: []
                      keepHistory: false
                      sender: 定时任务
                      createdAt: 1757900000
                      updatedAt: 1757900000
                    taskToken: f4a2c9e1b7d3450689abcdef01234567
        '400':
          description: 请求体不对、任务非法、房间任务数到上限，或者源房间读不到。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badBody:
                  value:
                    code: invalid_body
                    error: Cannot parse request body
                    message: 请求体无法解析
                invalidTask:
                  value:
                    code: invalid_task
                    error: Invalid task
                    message: 未知的模板变量 {{nope}}
                limitReached:
                  value:
                    code: task_limit_reached
                    error: Task limit reached
                    message: 房间 home 的定时任务已达到上限 20
                sourceForbidden:
                  value:
                    code: source_room_forbidden
                    error: Source room not readable
                    message: 正文里的 {{latest:secret}} 读不了：房间 secret 需要密码，而定时任务是无人值守的、没有密码可带。
        '403':
          description: 任务属于别人或别的房间（`task_forbidden`），或者该房间不允许定时任务（`automation_forbidden`）。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: task_forbidden
                    error: Task belongs to someone else
                    message: 这条任务不是你创建的，无法修改
        '404':
          description: 替换一个不存在的任务，或者定时任务被关掉了。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  value:
                    code: task_not_found
                    error: Task not found
                    message: 任务不存在
                disabled:
                  value:
                    code: automation_disabled
                    error: Automation is disabled
                    message: 定时自动化未启用（automation.enabled = false）
        '413':
          description: 请求体超过 64 KiB。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                tooLarge:
                  value:
                    code: invalid_body
                    error: Cannot parse request body
                    message: 请求体过大
        '500':
          description: 存任务失败。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                saveFailed:
                  value:
                    code: task_save_failed
                    error: Failed to save task
                    message: 保存任务失败
        '405':
          $ref: '#/components/responses/OnlyGetPost'
  /tasks/preview:
    post:
      tags:
      - Automation
      summary: 渲染一个还没保存的定时任务
      description: '渲染一个**不落库、也不发送**的草稿任务。请求体形状与 `POST /tasks` 相同（去掉 `id`）。


        参考时刻默认取这个任务的**下一次计划触发时刻**，而不是「现在」—— 否则一条配成

        「每天 09:30，正文 `{{date:+1d}}`」的任务，下午 15:00 预览会显示今天+1，

        而它明天早上真正发出去的是明天+1。`?at=` 可以覆盖（RFC3339，或 `2026-09-25` / `2026-09-25 09:30`）。

        '
      operationId: previewTask
      parameters:
      - $ref: '#/components/parameters/RoomQueryOptional'
      - name: at
        in: query
        required: false
        description: 用来渲染的参考时刻。解析不了就是 400，**绝不静默回落**。
        schema:
          type: string
        example: '2026-09-24T09:30:00+08:00'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 渲染出来的结果。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskPreview'
              examples:
                preview:
                  value:
                    preview: true
                    output: Tomorrow is 2026-09-25 (Friday)
                    room: home
                    keepHistory: false
                    referenceAt: 1758677400
                    nextRunAt: 1758677400
                    referenceAt2: '2026-09-24T09:30:00+08:00'
        '400':
          description: 请求体/任务非法、`?at=` 解析不了，或者渲染失败。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badReference:
                  value:
                    code: invalid_reference
                    error: Invalid reference time
                    message: 无法解析的时间：2026-13-45
                renderFailed:
                  value:
                    code: render_failed
                    error: Render failed
                    message: '{{latest:secret}} 读不了：房间 secret 需要密码'
                invalidTask:
                  value:
                    code: invalid_task
                    error: Invalid task
                    message: 未知的动作 text.nope
        '403':
          description: 这个房间不允许用定时任务。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: automation_forbidden
                    error: Automation is not allowed in this room
                    message: 房间 home 未开放自动化（需要该房间的凭据，或由管理员在 roomAuth 里设置 automation）
        '404':
          description: 服务端整体关掉了定时任务。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                disabled:
                  value:
                    code: automation_disabled
                    error: Automation is disabled
                    message: 定时自动化未启用（automation.enabled = false）
        '405':
          $ref: '#/components/responses/OnlyPost'
  /tasks/cron:
    get:
      tags:
      - Automation
      summary: 校验一条 cron 表达式
      description: '解析一条 5 字段表达式，返回接下来几次触发时刻，外加一个结构化的 `desc`，

        界面拿它渲染成一句「意思是……」。


        ⚠️ 表达式非法时返回的是 **200 且 `valid: false`**（带 `error`），**不是 400**：

        「校验」正是这个接口的**用途**，所以「不合法」是它的正常输出之一。

        客户端每次按键都会调它，回 4xx 会让页面看起来坏了。

        '
      operationId: checkCron
      parameters:
      - name: expr
        in: query
        required: true
        description: '''''''`<分> <时> <日> <月> <周>`。**只支持 5 个字段**；支持区间、步长、列表与英文名。'''''''
        schema:
          type: string
        example: '*/30 9-18 * * 1-5'
      - name: tz
        in: query
        required: false
        description: 返回的那些时刻用的时区名。默认取 `automation.defaultTZ`。
        schema:
          type: string
        example: Asia/Shanghai
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: '''校验结果 —— `valid: true` 带接下来的触发时刻，或 `valid: false` 带一句解释。'''
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CronCheck'
              examples:
                valid:
                  value:
                    expr: '*/30 9-18 * * 1-5'
                    tz: Asia/Shanghai
                    valid: true
                    room: home
                    next:
                    - 1758675600
                    - 1758677400
                    - 1758679200
                    nextFormatted:
                    - 2026-09-24 09:00 Thu
                    - 2026-09-24 09:30 Thu
                    - 2026-09-24 10:00 Thu
                    desc:
                      mode: everyNMinutes
                      n: 30
                      hours: 9-18
                      day: weekly
                      weekdays:
                      - 1
                      - 2
                      - 3
                      - 4
                      - 5
                invalid:
                  value:
                    expr: 0 0 30 2 *
                    tz: Asia/Shanghai
                    valid: false
                    room: home
                    error: 这个表达式在未来算不出任何触发时刻（检查「日」和「月」是不是不可能的组合）
        '400':
          description: 时区名不认识。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badTz:
                  value:
                    code: invalid_timezone
                    error: Invalid time zone
                    message: 无法识别的时区 "Mars/Olympus"（写法如 Asia/Shanghai）
        '403':
          description: 这个房间不允许用定时任务。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: automation_forbidden
                    error: Automation is not allowed in this room
                    message: 房间 home 未开放自动化（需要该房间的凭据，或由管理员在 roomAuth 里设置 automation）
        '404':
          description: 服务端整体关掉了定时任务。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                disabled:
                  value:
                    code: automation_disabled
                    error: Automation is disabled
                    message: 定时自动化未启用（automation.enabled = false）
        '405':
          $ref: '#/components/responses/OnlyGetPost'
    post:
      tags:
      - Automation
      summary: 校验一条 cron 表达式（POST 形式）
      description: 与 `GET /tasks/cron` 完全相同；表达式一律从查询串取，所以两种方法等价。两个都留着是为了「按任一种写法写的客户端都能用」。
      operationId: checkCronPost
      parameters:
      - name: expr
        in: query
        required: true
        schema:
          type: string
        example: 0 9 * * *
      - name: tz
        in: query
        required: false
        schema:
          type: string
        example: Asia/Shanghai
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 校验结果。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CronCheck'
              examples:
                valid:
                  value:
                    expr: 0 9 * * *
                    tz: Asia/Shanghai
                    valid: true
                    room: home
                    next:
                    - 1758675600
                    nextFormatted:
                    - 2026-09-24 09:00 Thu
                    desc:
                      mode: minutesEachHour
                      minutes: '0'
                      hours: '9'
                      day: daily
        '400':
          description: 时区名不认识。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badTz:
                  value:
                    code: invalid_timezone
                    error: Invalid time zone
                    message: 无法识别的时区 "Mars/Olympus"（写法如 Asia/Shanghai）
        '403':
          description: 这个房间不允许用定时任务。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: automation_forbidden
                    error: Automation is not allowed in this room
                    message: 房间 home 未开放自动化（需要该房间的凭据，或由管理员在 roomAuth 里设置 automation）
        '404':
          description: 服务端整体关掉了定时任务。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                disabled:
                  value:
                    code: automation_disabled
                    error: Automation is disabled
                    message: 定时自动化未启用（automation.enabled = false）
        '405':
          $ref: '#/components/responses/OnlyGetPost'
  /tasks/rooms:
    get:
      tags:
      - Automation
      summary: 哪些房间有定时任务
      description: 列出当前至少持有一条定时任务的房间，各带一个计数。管理员看得到所有房间；其他人只看得到自己房间那一条。
      operationId: listTaskRooms
      parameters:
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      responses:
        '200':
          description: 每个房间的条数。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskRooms'
              examples:
                admin:
                  value:
                    room: home
                    admin: true
                    rooms:
                    - room: home
                      count: 3
                    - room: lobby
                      count: 1
        '403':
          description: 这个房间不允许用定时任务。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: automation_forbidden
                    error: Automation is not allowed in this room
                    message: 房间 home 未开放自动化（需要该房间的凭据，或由管理员在 roomAuth 里设置 automation）
        '404':
          description: 服务端整体关掉了定时任务。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                disabled:
                  value:
                    code: automation_disabled
                    error: Automation is disabled
                    message: 定时自动化未启用（automation.enabled = false）
        '405':
          $ref: '#/components/responses/OnlyGet'
  /tasks/{id}:
    delete:
      tags:
      - Automation
      summary: 删掉一个定时任务
      description: 删掉这个任务。调用方必须拥有它（管理员；`room` 档要同房间；`single` 档要出示匹配的任务令牌）。
      operationId: deleteTask
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        example: 5f3c1e2a-9b7d-4c6e-8a1f-2d3e4f5a6b7c
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      - $ref: '#/components/parameters/TaskTokenQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      - taskToken: []
      responses:
        '200':
          description: 任务已删除。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskDeleted'
              examples:
                deleted:
                  value:
                    deleted: true
                    id: 5f3c1e2a-9b7d-4c6e-8a1f-2d3e4f5a6b7c
        '403':
          description: 任务属于别人或别的房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: task_forbidden
                    error: Task belongs to someone else
                    message: 这条任务不是你创建的
        '404':
          description: 没这个任务（或者定时任务被关掉了）。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  value:
                    code: task_not_found
                    error: Task not found
                    message: 任务不存在
        '405':
          description: '方法不对，或者动作不支持。


            NOTE (code)：任务是**先解析、后判方法**的，所以 `GET /tasks/{id}` 打一个不存在的 id 会回

            `404 task_not_found`，而不是 405。


            NOTE (code)：路由里还为 `/tasks/{id}/{action}` 挂了一条兜底，专门回这个 405 ——

            否则它会掉进静态资源的兜底、拿回一整页 HTML。

            '
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                unsupported:
                  value:
                    code: method_not_allowed
                    error: Unsupported method or action
                    message: 仅支持 DELETE /tasks/:id、POST /tasks/:id/run、POST /tasks/:id/toggle
  /tasks/{id}/run:
    post:
      tags:
      - Automation
      summary: 试跑一个任务，或者立刻发一次
      description: '拿这个已保存的任务，对着它**下一次计划时刻**渲染，并返回结果。


        - 不带 `send` 就什么都不发 —— 纯试跑。

        - 带 `?send=1` 则**现在真的广播**。

        - `?at=` 覆盖参考时刻（RFC3339，或 `2026-09-25` / `2026-09-25 09:30`）；解析不了就是 400，**绝不静默回落**。

        '
      operationId: runTask
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        example: 5f3c1e2a-9b7d-4c6e-8a1f-2d3e4f5a6b7c
      - name: send
        in: query
        required: false
        description: 为真时**现在就发**，而不是只渲染。
        schema:
          $ref: '#/components/schemas/Truthy'
        example: 1
      - name: at
        in: query
        required: false
        description: 试跑用的参考时刻。
        schema:
          type: string
        example: '2026-09-24T09:30:00+08:00'
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      - $ref: '#/components/parameters/TaskTokenQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      - taskToken: []
      responses:
        '200':
          description: 渲染结果（试跑）或发送确认。
          content:
            application/json:
              schema:
                oneOf:
                - $ref: '#/components/schemas/TaskRunDry'
                - $ref: '#/components/schemas/TaskRunSent'
              examples:
                dry:
                  summary: 试跑（不带 `send`）
                  value:
                    preview: true
                    output: Today is 2026-09-24
                    taskId: 5f3c1e2a-9b7d-4c6e-8a1f-2d3e4f5a6b7c
                    room: home
                    keepHistory: false
                    referenceAt: 1758677400
                    nextRunAt: 1758677400
                    scheduledAt: '2026-09-24T09:30:00+08:00'
                sent:
                  summary: 带 ?send=1 发出去了
                  value:
                    sent: true
                    output: Today is 2026-09-24
                    taskId: 5f3c1e2a-9b7d-4c6e-8a1f-2d3e4f5a6b7c
                    room: home
                    keepHistory: false
                    sentAt: 1758000000
        '400':
          description: '''''''`?at=` 不对、渲染失败，或者任务非法。'''''''
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badReference:
                  value:
                    code: invalid_reference
                    error: Invalid reference time
                    message: 无法解析的时间：2026-13-45
                renderFailed:
                  value:
                    code: render_failed
                    error: Render failed
                    message: 房间 secret 需要密码，无法作为来源
        '403':
          description: 任务属于别人或别的房间，或者该房间不允许定时任务。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: task_forbidden
                    error: Task belongs to someone else
                    message: 这条任务不是你创建的
        '404':
          description: 没这个任务（或者定时任务被关掉了）。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  value:
                    code: task_not_found
                    error: Task not found
                    message: 任务不存在
        '405':
          $ref: '#/components/responses/UnsupportedTaskAction'
  /tasks/{id}/toggle:
    post:
      tags:
      - Automation
      summary: 启用 / 停用一个定时任务
      description: '默认**翻转** `enabled`；`?enabled=1` / `?enabled=0` 则显式设定。

        ⚠️ 只动这一个开关 —— **绝不**碰模板、动作链或幂等键，所以关掉再打开**不会多发一条**。

        '
      operationId: toggleTask
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        example: 5f3c1e2a-9b7d-4c6e-8a1f-2d3e4f5a6b7c
      - name: enabled
        in: query
        required: false
        description: 显式设定开关，而不是翻转它。为真的值：`1`、`true`、`yes`、`on`。
        schema:
          type: string
          enum:
          - '0'
          - '1'
          - 'true'
          - 'false'
          - 'yes'
          - 'no'
          - 'on'
          - 'off'
        example: '0'
      - $ref: '#/components/parameters/RoomQueryOptional'
      - $ref: '#/components/parameters/AuthQuery'
      - $ref: '#/components/parameters/TaskTokenQuery'
      security:
      - bearerAuth: []
      - authQuery: []
      - taskToken: []
      responses:
        '200':
          description: 更新后的任务。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskSaved'
              examples:
                toggled:
                  value:
                    task:
                      id: 5f3c1e2a-9b7d-4c6e-8a1f-2d3e4f5a6b7c
                      name: On-call reminder
                      enabled: false
                      freq: daily
                      time: 09:30
                      cron: ''
                      byWeekday: []
                      runAt: ''
                      tz: Asia/Shanghai
                      room: home
                      template: Today is {{date}}
                      chain: []
                      keepHistory: false
                      sender: 定时任务
                      createdAt: 1757900000
                      updatedAt: 1758000000
                      nextRunAt: 0
                      lastRunAt: 0
                      lastStatus: ''
                      lastError: ''
                      lastOutput: ''
        '403':
          description: 任务属于别人或别的房间。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                forbidden:
                  value:
                    code: task_forbidden
                    error: Task belongs to someone else
                    message: 这条任务不是你创建的
        '404':
          description: 没这个任务（或者定时任务被关掉了）。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  value:
                    code: task_not_found
                    error: Task not found
                    message: 任务不存在
        '405':
          $ref: '#/components/responses/UnsupportedTaskAction'
  /automation:
    get:
      tags:
      - Automation
      summary: 定时任务的管理页（HTML）
      description: '一个**服务端渲染**的页面，用来新建和编辑定时任务。它有自己的 HTML

        （注册在 SPA 兜底**之前**，否则会解析成一个没有表单的空壳）。


        ⚠️ 它**自己不做任何鉴权** —— 能不能建、能不能改，完全由 `/tasks` 决定。

        页面拿密码换一枚会话令牌，存在 `sessionStorage` 里（关掉标签页就没了）。

        '
      operationId: automationPage
      parameters:
      - $ref: '#/components/parameters/RoomQueryOptional'
      - name: lang
        in: query
        required: false
        schema:
          type: string
          enum:
          - zh
          - zh-TW
          - en
          - ja
        example: zh
      security: []
      responses:
        '200':
          description: 管理页。
          content:
            text/html:
              schema:
                type: string
        '405':
          $ref: '#/components/responses/OnlyGet'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: '`Authorization: Bearer <凭据>`，凭据可以是全局密码、房间密码，或 `POST /auth/token` 换来的会话令牌。

        头里的值如果不是「以 `Bearer` 开头的两个空格分隔的词」，就**原样**当凭据用 ——

        这样手写的 `Authorization: mypass` 也能用。

        '
    authQuery:
      type: apiKey
      in: query
      name: auth
      description: '同一份凭据，走 `?auth=`。**只在** `Authorization` 头缺失或为空时才看它。为 Apple 快捷指令保留的。

        ⚠️ **永远不要把密码放进可以分享的 URL** —— 查询串会进浏览器历史和访问日志。

        '
    shareTokenQuery:
      type: apiKey
      in: query
      name: t
      description: 分享令牌，授予对**它被签出来的那一条内容或文件**的**读**权限。只读：写接口一律忽略它。
    sharePassword:
      type: apiKey
      in: header
      name: X-Share-Password
      description: 受密码保护的分享的密码。**永远不进 URL**，因为查询串会被记日志。
    taskToken:
      type: apiKey
      in: header
      name: X-Task-Token
      description: '`single` 档定时任务的任务令牌（也接受 `?taskToken=`）。任务创建时**只发一次**。'
  parameters:
    RoomQuery:
      name: room
      in: query
      required: false
      description: 房间名。空、不传、或者字面量 `default` 都表示 `default` 房间。名字**区分大小写**，只去首尾空白。
      schema:
        type: string
      example: home
    RoomQueryOptional:
      name: room
      in: query
      required: false
      description: '房间名。在那些「完全不传」也是一种独立情形的接口上，不传与传空**可能不一样** ——

        `GET /content/latest` 就是要留神的那一个：`?room=` 是 `default` 房间，`?all=1` 才是「哪都行，取最新」。

        '
      schema:
        type: string
      example: home
    AuthQuery:
      name: auth
      in: query
      required: false
      description: 凭据，只在 `Authorization` 头缺失或为空时才用。
      schema:
        type: string
        writeOnly: true
      example: correct-horse-battery-staple
    NameQuery:
      name: name
      in: query
      required: false
      description: '发送方自称的**设备显示名**。会先清洗（去掉控制字符、去首尾空白）、截到 32 个**字符**，

        再存成 `senderDevice.name`。为空时服务端从 User-Agent 推一个名字。

        它和 `client` **不是一回事**，不能互换。

        '
      schema:
        type: string
        maxLength: 32
      example: iPhone
    ClientQuery:
      name: client
      in: query
      required: false
      description: 一个**稳定的客户端 id**，存成 `senderClientID`，用来判断哪些消息是调用方自己发的。**每台安装设一次**即可。
      schema:
        type: string
      example: web-1a2b
    FormatQuery:
      name: format
      in: query
      required: false
      description: '显式指定表示形式。`raw`（别名 `text` / `plain`）强制原始字节；`json` 强制 JSON。

        ⚠️ 其他任何非空值都是 `400 unsupported_format` —— **绝不回落**。它压过 `?json=` 与 `Accept`。

        '
      schema:
        type: string
        enum:
        - raw
        - text
        - plain
        - json
      example: json
    JsonQuery:
      name: json
      in: query
      required: false
      description: 早期的 JSON 开关。`1` 或 `true` 选 JSON，其他值忽略。为老客户端保留 —— 新代码请用 `?format=json`。
      schema:
        type: string
        enum:
        - '1'
        - 'true'
      example: '1'
    ShareTokenQuery:
      name: t
      in: query
      required: false
      description: 分享令牌。给了的话，它在房间凭据**之后**再试一次。
      schema:
        type: string
      example: eyJ0eXAiOiJzaGFyZSJ9.9f2c
    ShareTokenQueryRequired:
      name: t
      in: query
      required: true
      description: 要描述的分享令牌。
      schema:
        type: string
      example: eyJ0eXAiOiJzaGFyZSJ9.9f2c
    SharePasswordHeader:
      name: X-Share-Password
      in: header
      required: false
      description: 受密码保护的分享的密码。**绝不**放进 URL。
      schema:
        type: string
        writeOnly: true
      example: hunter2
    TaskTokenQuery:
      name: taskToken
      in: query
      required: false
      description: '`single` 档定时任务的任务令牌，作为 `X-Task-Token` 头的替代（头优先）。'
      schema:
        type: string
        writeOnly: true
      example: f4a2c9e1b7d3450689abcdef01234567
  responses:
    OnlyGet:
      description: 这个接口只接受 GET。
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            onlyGet:
              value:
                code: method_not_allowed
                error: Only GET is allowed
                message: 仅允许 GET 请求
    RoomForbidden:
      description: 这个凭据打不开该房间。
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            forbidden:
              value:
                code: room_forbidden
                error: Room forbidden
                message: 无权访问该房间
    OnlyPost:
      description: 这个接口只接受 POST。
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            onlyPost:
              value:
                code: method_not_allowed
                error: Only POST is allowed
                message: 仅允许 POST 请求
    OnlyGetPost:
      description: 这个接口接受 GET 与 POST。
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            onlyGetPost:
              value:
                code: method_not_allowed
                error: Only GET and POST are allowed
                message: 仅允许 GET / POST 请求
    MethodNotAllowed:
      description: '这个方法不是这条路径接受的。⚠️ 措辞**按路径族不同**：`/file/*` 用的是下面那句通用文案，

        其他路径会把允许的方法名点出来。

        '
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            generic:
              value:
                code: method_not_allowed
                error: Method not allowed
                message: 方法不允许
    UnsupportedTaskAction:
      description: '`/tasks/{id}*` 上不支持这个方法或动作。文案里会把支持的列出来 —— 对用户来说比「只允许 POST」有用得多。'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unsupported:
              value:
                code: method_not_allowed
                error: Unsupported method or action
                message: 仅支持 DELETE /tasks/:id、POST /tasks/:id/run、POST /tasks/:id/toggle
    InvalidRequestBody:
      description: 请求体不是合法 JSON。
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidBody:
              value:
                code: invalid_request_body
                error: Invalid request body
                message: 无效的请求体
    UnsupportedFormat:
      description: '`?format=` 的值不是 raw / json 之一。'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unsupported:
              value:
                code: unsupported_format
                error: Unsupported format
                message: 不支持的格式（只支持 raw / json）
  schemas:
    Error:
      type: object
      description: '**唯一**的错误信封。每条错误路径都只返回这三个字段，`Content-Type` 恒为

        `application/json; charset=utf-8`，**绝不**随 `Accept` 变化，并且**末尾带一个换行**。

        '
      required:
      - code
      - error
      - message
      additionalProperties: false
      properties:
        code:
          type: string
          description: 机器码，蛇形命名。**一旦发布就稳定** —— 别拿别的字段做判断分支。
          examples:
          - text_too_long
        error:
          type: string
          description: 英文原句，给日志和英语用户看。
          examples:
          - Text too long
        message:
          type: string
          description: 中文原句，客户端**原样显示**。限额类错误里会嵌入服务端**当前**的配置值。
          examples:
          - 文本内容超出限制 (最大 4096 字符)
      examples:
      - code: text_too_long
        error: Text too long
        message: 文本内容超出限制 (最大 4096 字符)
    Truthy:
      type: string
      description: 一个「真值」开关。为真的值：`1`、`true`、`yes`、`on`（忽略大小写、去首尾空白）。其余一律为假。
      enum:
      - '1'
      - 'true'
      - 'yes'
      - 'on'
      examples:
      - '1'
    BoardColumn:
      type: string
      description: 看板列。`todo` 是默认 —— 缺失或空值都归一到它。列名**忽略大小写并去首尾空白**。
      enum:
      - todo
      - doing
      - done
      examples:
      - doing
    ServerInfo:
      type: object
      description: '`GET /server` 的响应。⚠️ 注意它**不带** `version`，也**不带**大小限额。'
      required:
      - server
      - auth
      - authorized
      - roomProtected
      - config
      - automation
      properties:
        server:
          type: string
          description: WebSocket 地址，协议头已经换成 `ws` / `wss`。
          examples:
          - ws://127.0.0.1:9501/push
        auth:
          type: boolean
          description: 解析出的上下文是否**需要**密码。不带 `?room=` 时它只反映全局密码。
        authorized:
          type: boolean
          description: '出示的凭据是否可用。⚠️ 初值是 `true` —— 不带 `?room=`、也没有全局密码时，

            **连凭据都没有也是 `true`**。

            '
        roomProtected:
          type: boolean
          description: '`?room=` 点名的房间是否要密码。**没传 `?room=` 时恒为 `false`**。'
        config:
          type: object
          properties:
            server:
              type: object
              properties:
                history:
                  type: integer
                  format: int64
                  description: 每个房间保留多少条。也是 `GET /content` 的默认每页条数。
                roomList:
                  type: boolean
                  description: '`GET /rooms` 有没有开。'
        automation:
          $ref: '#/components/schemas/AutomationCapability'
    AutomationCapability:
      type: object
      description: '描述调用方对 `/tasks` 能做什么。定时任务整体关掉时，这个对象就是 `{"enabled": false}`。'
      required:
      - enabled
      properties:
        enabled:
          type: boolean
        room:
          type: string
          description: 这次请求解析到的房间。
        tier:
          type: string
          description: 策略档位。`admin` = 全局密码；`none` = 不允许。
          enum:
          - admin
          - room
          - single
          - none
        allowed:
          type: boolean
        admin:
          type: boolean
        max:
          type: integer
          description: 任务数上限；`0` 表示不限。
        defaultTZ:
          type: string
          examples:
          - Asia/Shanghai
        vars:
          type: array
          description: 界面可以提供哪些模板变量名。
          items:
            type: string
          examples:
          - - date
            - weekday
            - time
            - datetime
            - timestamp
            - uuid
            - task
            - room
        actions:
          type: array
          description: '动作库，只是元数据。`key` / `groupKey` 是前端语言文件里的 i18n **键** ——

            服务端只发键，从不发译文。

            '
          items:
            $ref: '#/components/schemas/AutomationAction'
    AutomationAction:
      type: object
      required:
      - id
      - group
      - groupKey
      - key
      properties:
        id:
          type: string
          examples:
          - text.trimLines
        group:
          type: string
          examples:
          - text
        groupKey:
          type: string
          examples:
          - actionGroupText
        key:
          type: string
          examples:
          - actionTrimLines
        params:
          type: array
          description: 不需要参数的动作这里是空的（并且省略）。
          items:
            $ref: '#/components/schemas/AutomationParam'
    AutomationParam:
      type: object
      required:
      - key
      - labelKey
      properties:
        key:
          type: string
          examples:
          - find
        labelKey:
          type: string
          examples:
          - actionParamFind
        type:
          type: string
          description: 单行文本输入时省略；`select` 表示下拉选择。
          enum:
          - select
        options:
          type: array
          description: 为空时省略。
          items:
            type: object
            required:
            - value
            - labelKey
            properties:
              value:
                type: string
              labelKey:
                type: string
        visibleWhen:
          type: object
          description: 没有时省略。用于「当另一个参数等于某值时，才显示这个参数」。
          required:
          - key
          - equals
          properties:
            key:
              type: string
            equals:
              type: string
    TokenRequest:
      type: object
      required:
      - password
      properties:
        password:
          type: string
          description: 全局密码或房间密码。空值会被拒，回 `password_required`。
          writeOnly: true
    SessionToken:
      type: object
      description: 一枚会话令牌。有效期约一小时。
      required:
      - token
      - expiresAt
      - scope
      properties:
        token:
          type: string
        expiresAt:
          type: integer
          format: int64
          description: Unix **秒**。
        scope:
          type: string
          description: 由全局密码签出的令牌是 `global`；房间密码签出的是 `""`。
    TextSendResult:
      type: object
      description: '`POST /text` 的响应（新建与改写两条路都是它）。'
      required:
      - url
      - id
      - type
      properties:
        url:
          type: string
          format: uri
          examples:
          - http://localhost:9501/content/7
        id:
          type: string
          description: 条目 id，**是字符串**。
          examples:
          - '7'
        type:
          type: string
          const: text
    ContentEntry:
      description: '`GET /content` 与 `GET /content/{id}?format=json` 用的 JSON 投影。

        分支要看的是**有没有 `content`（文本）还是 `name`（文件）**。

        ⚠️ **不要**拿 `type` 分支：文件时它装的是探测出的媒体种类，不是 `file` 这个词。

        '
      oneOf:
      - $ref: '#/components/schemas/TextContentEntry'
      - $ref: '#/components/schemas/FileContentEntry'
    TextContentEntry:
      type: object
      required:
      - type
      - id
      - timestamp
      - room
      properties:
        type:
          type: string
          const: text
        content:
          type: string
          description: 为空时省略（空串与「没有正文」在这里分不出来）。
        id:
          type: string
          description: 条目 id，字符串。
        timestamp:
          type: integer
          format: int64
          description: Unix **秒**。秒级精度，同一秒里会重复 —— **绝不要**拿它当游标。
        room:
          type: string
        senderIP:
          type: string
        senderClientID:
          type: string
          description: 为空时省略。
        senderDevice:
          description: '发送方的设备信息 —— 一张自由形状的字符串表，**不是** WebSocket `connect` 事件用的那个更严格的

            `DeviceMeta`。未知时是 `null`，而它与 `{}` **不是一回事**，所以客户端三种状态都要处理。

            '
          oneOf:
          - $ref: '#/components/schemas/SenderDevice'
          - type: 'null'
        column:
          $ref: '#/components/schemas/BoardColumn'
        source:
          type: string
          description: 没设就省略。`automation` 表示这条是定时任务发的。
        scheduledAt:
          type: integer
          format: int64
          description: 没设就省略。任务**计划**触发的那一刻；补发时它与 `timestamp` 不同。
        late:
          type: boolean
          description: 不为真就省略。表示这条发晚了，错过了自己的窗口。
    FileContentEntry:
      type: object
      required:
      - type
      - name
      - size
      - uuid
      - cache
      - url
      - id
      - timestamp
      - expire
      - room
      properties:
        type:
          type: string
          description: 按文件名探测出的媒体种类。
          enum:
          - image
          - audio
          - video
          - document
          - archive
          - file
        name:
          type: string
        size:
          type: integer
          format: int64
          description: 磁盘上的字节数。条目本身不带正文 —— 字节从 `url` 取。
        uuid:
          type: string
          description: 与 `cache` 同一个值。
        cache:
          type: string
        thumbnail:
          type: string
          description: 一个 `data:image/jpeg;base64,…` 缩略图；没生成时是空串（压缩包、PDF、视频没生成是正常的）。
        url:
          type: string
          format: uri
          description: 绝对下载地址，**文件名已经拼好了**，可以直接用。
        id:
          type: string
        timestamp:
          type: integer
          format: int64
        expire:
          type: integer
          format: int64
          description: Unix 秒；`0` 表示永不过期。
        room:
          type: string
        senderIP:
          type: string
        senderClientID:
          type: string
        senderDevice:
          oneOf:
          - $ref: '#/components/schemas/SenderDevice'
          - type: 'null'
        column:
          $ref: '#/components/schemas/BoardColumn'
        source:
          type: string
        scheduledAt:
          type: integer
          format: int64
        late:
          type: boolean
    ContentList:
      type: object
      description: 一页历史，升序。**空数组表示没有更多历史了**。
      required:
      - messages
      properties:
        messages:
          type: array
          items:
            $ref: '#/components/schemas/ContentEntry'
    LatestTextEntry:
      type: object
      description: '`GET /content/latest` 返回文本条目时的形状 —— 是**紧凑**的，不是 `GET /content` 那个丰富投影。'
      required:
      - type
      - id
      - timestamp
      properties:
        type:
          type: string
          const: text
        content:
          type: string
        id:
          type: string
        timestamp:
          type: integer
          format: int64
        column:
          $ref: '#/components/schemas/BoardColumn'
    LatestFileEntry:
      type: object
      description: '`GET /content/latest` 返回文件条目时的形状 —— 也是**紧凑**的。⚠️ 它没有 `cache` / `room` / `senderIP`，与
        `GET /content` 的投影不同。'
      required:
      - type
      - name
      - size
      - uuid
      - url
      - id
      - timestamp
      - expire
      properties:
        type:
          type: string
          enum:
          - image
          - audio
          - video
          - document
          - archive
          - file
        name:
          type: string
        size:
          type: integer
          format: int64
        uuid:
          type: string
        url:
          type: string
          format: uri
        id:
          type: string
        timestamp:
          type: integer
          format: int64
        expire:
          type: integer
          format: int64
        column:
          $ref: '#/components/schemas/BoardColumn'
    ColumnResult:
      type: object
      required:
      - id
      - type
      - column
      properties:
        id:
          type: string
        type:
          type: string
          description: 条目自己的种类（`text` / `file`），**不是**媒体种类。
        column:
          $ref: '#/components/schemas/BoardColumn'
    RoomListResponse:
      type: object
      required:
      - rooms
      properties:
        rooms:
          type: array
          items:
            $ref: '#/components/schemas/RoomInfo'
    RoomInfo:
      type: object
      required:
      - name
      - messageCount
      - deviceCount
      - lastActive
      - isActive
      - isProtected
      properties:
        name:
          type: string
          description: 房间名。`default` 房间报成**空串**。
        messageCount:
          type: integer
          format: int64
        deviceCount:
          type: integer
          format: int64
        lastActive:
          type: integer
          format: int64
          description: Unix 秒。有设备连着时会不断刷成「现在」。
        isActive:
          type: boolean
          description: 至少有一台设备连着时为真。
        isProtected:
          type: boolean
          description: '房间**确实**要不要密码（配了 `{"open": true}` 的房间报 `false`）。'
    UploadedEntry:
      type: object
      description: 一次上传完成后的响应（`/upload` 带文件 part，或 `/upload/finish/{uuid}`）。
      required:
      - url
      - id
      - type
      properties:
        url:
          type: string
          format: uri
          description: '**条目**地址（`/content/{id}`），不是文件的裸地址。'
        id:
          type: string
        type:
          type: string
          description: 按文件名探测出的媒体种类。
          enum:
          - image
          - audio
          - video
          - document
          - archive
          - file
    ChunkInitResult:
      type: object
      description: 分片上传初始化的响应（`/upload/chunk`，或 `/upload` 带一个 `text/plain` 的文件名）。
      required:
      - result
      properties:
        result:
          type: object
          required:
          - uuid
          properties:
            uuid:
              type: string
              format: uuid
    UploadResult:
      description: 要么是一次完成的上传，要么（`text/plain` 文件名那种形式）是一个初始化结果。
      oneOf:
      - $ref: '#/components/schemas/UploadedEntry'
      - $ref: '#/components/schemas/ChunkInitResult'
    ShareRequest:
      type: object
      description: '`POST /share` 的请求体。所有字段可选（空体即全部默认），

        但 ⚠️ **未知字段会被拒** —— 一个拼写错误绝不能**悄悄**丢掉用户配的限制。

        '
      additionalProperties: false
      properties:
        type:
          type: string
          description: '`content` 或 `file`。不传 → `400 missing_type`。'
          enum:
          - content
          - file
        id:
          type: string
          description: '`type: content` 时是条目 id（字符串）。`type: file` 时也可以用它代替 `uuid`。'
        uuid:
          type: string
          description: '`type: file` 时是文件 uuid。**优先于** `id`。'
        ttl:
          type: integer
          format: int64
          description: 过期秒数。默认 900；夹到 60 – 86400。
          minimum: 60
          maximum: 86400
        maxUses:
          type: integer
          format: int64
          description: 最多能被读几次。`0`（默认）表示不限。
          minimum: 0
        password:
          type: string
          description: 可选密码，之后收件方必须带 `X-Share-Password`。
          writeOnly: true
    ShareCreatedContent:
      type: object
      required:
      - type
      - id
      - room
      - ttl
      - expiresAt
      - maxUses
      - token
      - jti
      - url
      - pageUrl
      - rawUrl
      - visits
      - scans
      properties:
        type:
          type: string
          const: content
        id:
          type: string
        room:
          type: string
        ttl:
          type: integer
          format: int64
        expiresAt:
          type: integer
          format: int64
        maxUses:
          type: integer
          format: int64
        token:
          type: string
        jti:
          type: string
        url:
          type: string
          format: uri
          description: 分享页。**爬虫和人看到的是同一个地址**。
        pageUrl:
          type: string
          format: uri
          description: 为兼容保留；目前与 `url` 同值。
        rawUrl:
          type: string
          format: uri
          description: 带同一枚令牌直达内容接口。
        visits:
          type: integer
        scans:
          type: integer
    ShareCreatedFile:
      type: object
      required:
      - type
      - uuid
      - room
      - ttl
      - expiresAt
      - maxUses
      - token
      - jti
      - url
      - pageUrl
      - rawUrl
      - visits
      - scans
      properties:
        type:
          type: string
          const: file
        uuid:
          type: string
        room:
          type: string
        ttl:
          type: integer
          format: int64
        expiresAt:
          type: integer
          format: int64
        maxUses:
          type: integer
          format: int64
        token:
          type: string
        jti:
          type: string
        url:
          type: string
          format: uri
        pageUrl:
          type: string
          format: uri
        rawUrl:
          type: string
          format: uri
        visits:
          type: integer
        scans:
          type: integer
    ShareInfo:
      type: object
      description: '`GET /share?t=` —— 落地页在取任何东西之前需要的东西。**不消耗次数**。'
      required:
      - type
      - expiresAt
      - maxUses
      - needsPassword
      properties:
        type:
          type: string
          enum:
          - content
          - file
        kind:
          type: string
          description: 目标的种类（`text` / `file`）。
          enum:
          - text
          - file
        id:
          type: string
        room:
          type: string
        expiresAt:
          type: integer
          format: int64
        maxUses:
          type: integer
          format: int64
        used:
          type: integer
          description: '**只**在「限次且还有活的记录」的分享上出现。'
        needsPassword:
          type: boolean
        uuid:
          type: string
          description: 目标是文件时才有。
        name:
          type: string
          description: 目标是文件时才有。
        size:
          type: integer
          format: int64
          description: 目标是文件时才有。
        previewToken:
          type: string
          description: '**只**在分享受密码保护时出现。可以安全地塞进 `<img src>` 之类。'
        previewExpiresAt:
          type: integer
          format: int64
          description: 与 `previewToken` 一起出现。
    ShareList:
      type: object
      required:
      - room
      - total
      - limit
      - records
      properties:
        room:
          type: string
        total:
          type: integer
          description: 该房间的记录**总数**（不只是这一页）。
        limit:
          type: integer
          description: 实际用的每页条数。
        records:
          type: array
          items:
            $ref: '#/components/schemas/ShareRecord'
    ShareRecord:
      type: object
      required:
      - jti
      - type
      - kind
      - id
      - room
      - name
      - size
      - createdAt
      - expiresAt
      - maxUses
      - used
      - visits
      - scans
      - password
      - expired
      properties:
        jti:
          type: string
          description: 在这份列表里标识这个分享。**令牌本身永远不在里面**。
        type:
          type: string
          enum:
          - content
          - file
        kind:
          type: string
          enum:
          - text
          - file
        id:
          type: string
        room:
          type: string
        name:
          type: string
          description: 文本分享带首行摘要；文件分享带文件名。
        size:
          type: integer
          format: int64
        createdAt:
          type: integer
          format: int64
        expiresAt:
          type: integer
          format: int64
        maxUses:
          type: integer
          format: int64
        used:
          type: integer
        visits:
          type: integer
        scans:
          type: integer
        password:
          type: boolean
          description: 只报**有没有**设密码 —— 绝不是哈希。
        expired:
          type: boolean
    ShareVisitRequest:
      type: object
      additionalProperties: false
      properties:
        token:
          type: string
          description: 分享令牌。也可以走 `?t=`。
        qr:
          type: boolean
          description: 在「打开」之外，把这次访问再记成一次扫码。
    ShareVisitResult:
      type: object
      required:
      - ok
      - tracked
      - visits
      - scans
      properties:
        ok:
          type: boolean
          const: true
        tracked:
          type: boolean
          description: 同一访客十分钟内再报时为 `false`。
        visits:
          type: integer
        scans:
          type: integer
    SenderDevice:
      type: object
      description: '挂在内容条目上的 `senderDevice` 表。与 WebSocket `connect` 事件的 `DeviceMeta` 不同，

        这是**一张普通字符串表**：没有 `id` 也没有 `device` 键，`type` 装的是小写的设备类别。

        '
      additionalProperties:
        type: string
      properties:
        type:
          type: string
          description: 设备类别；定时任务发的消息是 `Automation`。
          enum:
          - desktop
          - smartphone
          - tablet
          - Automation
        name:
          type: string
          description: 客户端用 `?name=` 声明的名字。它没声明时**不出现**。
        os:
          type: string
          description: '`<家族> <主版本>`，如 `iOS 17`。会去首尾空白，所以认不出的家族就只是 `Other`。'
        browser:
          type: string
          description: '`<家族> <主版本>`，如 `Safari 17` 或 `curl 8`。'
      examples:
      - type: smartphone
        name: iPhone
        os: iOS 18
        browser: Safari 18
    DeviceMeta:
      type: object
      description: '一台连着的设备。**只有** `name` 在为空时省略；`id`、`type`、`device`、`os`、`browser`

        **永远都在**，哪怕是空串。

        '
      required:
      - id
      - type
      - device
      - os
      - browser
      properties:
        id:
          type: string
          description: 这条连接的稳定哈希，用来去重。
        type:
          type: string
          examples:
          - Desktop
          - mobile
          - Automation
        name:
          type: string
          description: 客户端声明的名字（如果有）。为空时省略。
        device:
          type: string
          examples:
          - Apple Mac
        os:
          type: string
          examples:
          - macOS 14
        browser:
          type: string
          examples:
          - Chrome 120
    DisconnectData:
      type: object
      required:
      - id
      properties:
        id:
          type: string
          description: '**最后一条**连接断掉的那台设备的 id。'
    RevokeData:
      type: object
      required:
      - id
      properties:
        id:
          type: integer
          format: int32
          description: 被删条目的 id。⚠️ 注意这里它是**数字**，而内容负载里的 id 是字符串。
    ClearAllData:
      type: object
      required:
      - room
      properties:
        room:
          type: string
    WsConfig:
      type: object
      description: '`config` 帧，连上之后、任何实时消息**之前**发一次。它是 `version` 与那几个大小限额的**唯一**来源，

        也是前端 `app.config` 的唯一来源。

        '
      required:
      - version
      - server
      - text
      - file
      - auth
      - latestId
      - automation
      properties:
        version:
          type: string
          examples:
          - 0.1.0
        server:
          type: object
          properties:
            history:
              type: integer
              format: int64
            prefix:
              type: string
            roomList:
              type: boolean
        text:
          type: object
          properties:
            limit:
              type: integer
              format: int64
              description: 文本长度上限，单位**字节**。`0` 表示不限。
        file:
          type: object
          properties:
            expire:
              type: integer
              format: int64
              description: 文件默认存活秒数；`0` 表示永不过期。
            chunk:
              type: integer
              format: int64
            limit:
              type: integer
              format: int64
        auth:
          type: boolean
          description: 握手那一刻这个房间要不要凭据。
        latestId:
          type: integer
          format: int32
          description: '连接那一刻房间里最大的消息 id（空房间为 `0`）。拿它当历史/实时的边界。

            ⚠️ 老服务端没有这个字段 —— 那种情况下客户端**必须不写剪贴板**。

            '
        automation:
          type: object
          required:
          - enabled
          properties:
            enabled:
              type: boolean
    ReceiveEntry:
      description: '`receive` / `update` 的负载 —— 条目**存进去时的原样**，也是 `GET /content` 投影出来的形状。

        分支看 `type`：`text` 或 `file`。

        '
      oneOf:
      - $ref: '#/components/schemas/ReceiveTextEntry'
      - $ref: '#/components/schemas/ReceiveFileEntry'
    ReceiveTextEntry:
      type: object
      required:
      - id
      - type
      - room
      - timestamp
      properties:
        id:
          type: integer
          format: int32
          description: 条目 id —— 在实时负载里是**数字**。
        type:
          type: string
          const: text
        room:
          type: string
        timestamp:
          type: integer
          format: int64
        senderIP:
          type: string
        senderClientID:
          type: string
          description: 为空时省略。
        senderDevice:
          oneOf:
          - $ref: '#/components/schemas/SenderDevice'
          - type: 'null'
        column:
          $ref: '#/components/schemas/BoardColumn'
        source:
          type: string
        scheduledAt:
          type: integer
          format: int64
        late:
          type: boolean
        content:
          type: string
          description: 为空时省略。
        deviceConnection:
          $ref: '#/components/schemas/DeviceMeta'
        deviceID:
          type: string
    ReceiveFileEntry:
      type: object
      required:
      - id
      - type
      - room
      - timestamp
      - name
      - size
      - cache
      - expire
      properties:
        id:
          type: integer
          format: int32
        type:
          type: string
          const: file
        room:
          type: string
        timestamp:
          type: integer
          format: int64
        senderIP:
          type: string
        senderClientID:
          type: string
        senderDevice:
          oneOf:
          - $ref: '#/components/schemas/SenderDevice'
          - type: 'null'
        column:
          $ref: '#/components/schemas/BoardColumn'
        source:
          type: string
        scheduledAt:
          type: integer
          format: int64
        late:
          type: boolean
        name:
          type: string
        size:
          type: integer
          format: int64
        cache:
          type: string
          description: 通常就是文件 uuid。
        expire:
          type: integer
          format: int64
        thumbnail:
          type: string
        url:
          type: string
          format: uri
          description: 为空时省略。
    ChainStep:
      description: '动作链里的一步。不带参数的一步写成**裸字符串**；带参数的写成对象。

        两种形式都接受，而**没有参数的一步在写回时仍然是字符串** —— 这样已有数据永远不会被改写形状。

        '
      oneOf:
      - type: string
        description: 一个动作 id，如 `text.trimLines`。
      - type: object
        required:
        - id
        properties:
          id:
            type: string
          params:
            type: object
            description: '**只**属于这一步的参数。'
            additionalProperties:
              type: string
    TaskRequest:
      type: object
      description: '`POST /tasks` 与 `POST /tasks/preview` 的请求体。

        ⚠️ **没有 `room` 字段** —— 房间取自凭据。

        '
      properties:
        id:
          type: string
          description: 不传即新建；传了即**整份替换**那个任务。
        name:
          type: string
          description: 为空时回落到渲染后正文的第一行。
        enabled:
          type: boolean
          description: 默认 `true`。
        freq:
          type: string
          description: 计划类型。
          enum:
          - once
          - daily
          - weekly
          - cron
        time:
          type: string
          description: '`HH:MM`，用于 `daily` / `weekly`。'
        cron:
          type: string
          description: '5 字段表达式，用于 `freq: cron` —— `<分> <时> <日> <月> <周>`。'
        byWeekday:
          type: array
          description: 用于 `weekly`。`0` = 周日 … `6` = 周六，与 `Date.getDay()` 一致。
          items:
            type: integer
            minimum: 0
            maximum: 6
        runAt:
          type: string
          description: 用于 `once`：RFC3339，或按 `tz` 解释的 `2026-10-01T09:30`。会归一成 RFC3339。
        tz:
          type: string
          description: 不传 → 从 `automation.defaultTZ` 填上，并**存进任务里**。
        template:
          type: string
          description: 正文模板。`{{ }}` 变量在**触发时**求值；不认识的变量和写坏的偏移量在**保存时**就被拒。
        chain:
          type: array
          description: 可选的有序步骤列表，作用在渲染后的文本上。某一步失败会**中断整条链**。
          items:
            $ref: '#/components/schemas/ChainStep'
        keepHistory:
          type: boolean
          description: 默认 `false` —— 消息照常广播，但**不占**房间的历史额度。
        sender:
          type: string
          description: 显示名。默认 `定时任务`。
    Task:
      type: object
      description: 接口返回的定时任务。`ownerHash` **从不**暴露。
      required:
      - id
      - name
      - enabled
      - freq
      - room
      - template
      - createdAt
      - updatedAt
      properties:
        id:
          type: string
        name:
          type: string
        enabled:
          type: boolean
        freq:
          type: string
          enum:
          - once
          - daily
          - weekly
          - cron
        time:
          type: string
        cron:
          type: string
        byWeekday:
          type: array
          items:
            type: integer
        runAt:
          type: string
        tz:
          type: string
        room:
          type: string
        template:
          type: string
        chain:
          type: array
          items:
            $ref: '#/components/schemas/ChainStep'
        keepHistory:
          type: boolean
        sender:
          type: string
        createdAt:
          type: integer
          format: int64
        updatedAt:
          type: integer
          format: int64
        nextRunAt:
          type: integer
          format: int64
          description: 下次触发时刻，Unix 秒；停用或已用尽时为 `0`。
        lastRunAt:
          type: integer
          format: int64
        lastStatus:
          type: string
          description: 上一次触发的结局。
          enum:
          - ''
          - ok
          - error
          - skipped
        lastError:
          type: string
        lastOutput:
          type: string
        desc:
          $ref: '#/components/schemas/CronDesc'
    CronDesc:
      type: object
      description: '对一条 cron 表达式的**结构化、不含散文**的概括。它只描述形状，

        句子由客户端用自己的文案表拼出来。⚠️ 里面**永远不含**表达式语法（`*`、`/`、`?`）。

        '
      required:
      - mode
      properties:
        mode:
          type: string
          enum:
          - everyMinute
          - everyNMinutes
          - everyNHours
          - minutesEachHour
          - times
          - unknown
        n:
          type: integer
        minutes:
          type: string
        hours:
          type: string
        day:
          type: string
          enum:
          - daily
          - weekly
          - monthly
          - monthlyOrWeekly
          - everyNDays
          - everyNDaysOrWeekly
        weekdays:
          type: array
          items:
            type: integer
        dom:
          type: string
        month:
          type: string
        dayN:
          type: integer
        times:
          type: array
          items:
            type: string
        more:
          type: boolean
          description: 概括用的列表被截断时为真。
    TaskList:
      type: object
      required:
      - room
      - tier
      - admin
      - tasks
      - max
      - now
      - actions
      - vars
      properties:
        room:
          type: string
        tier:
          type: string
          enum:
          - admin
          - room
          - single
          - none
        admin:
          type: boolean
        tasks:
          type: array
          items:
            $ref: '#/components/schemas/Task'
        max:
          type: integer
          description: 该房间的任务数上限；`0` 表示不限。
        now:
          type: integer
          format: int64
        foreignCount:
          type: integer
          description: '**只**在房间里有些任务因为不是你的而被藏起来时出现。'
        actions:
          type: array
          items:
            $ref: '#/components/schemas/AutomationAction'
        vars:
          type: array
          items:
            type: string
    TaskSaved:
      type: object
      required:
      - task
      properties:
        task:
          $ref: '#/components/schemas/Task'
        taskToken:
          type: string
          description: '**只**在 `single` 档房间创建任务时出现。它**只显示这一次**，而它是之后编辑或删除该任务的**唯一**办法。

            '
    TaskDeleted:
      type: object
      required:
      - deleted
      - id
      properties:
        deleted:
          type: boolean
          const: true
        id:
          type: string
    TaskRooms:
      type: object
      required:
      - room
      - admin
      - rooms
      properties:
        room:
          type: string
        admin:
          type: boolean
        rooms:
          type: array
          items:
            type: object
            required:
            - room
            - count
            properties:
              room:
                type: string
              count:
                type: integer
    CronCheck:
      type: object
      description: '校验 cron 表达式的结果。⚠️ 表达式非法时是 **200** 且 `valid: false`；

        **只有**时区名不可用才是 400。

        '
      required:
      - expr
      - tz
      - valid
      - room
      properties:
        expr:
          type: string
          description: 归一化之后的表达式（合法时与输入相同）。
        tz:
          type: string
        valid:
          type: boolean
        room:
          type: string
        error:
          type: string
          description: '`valid` 为假时才有。'
        next:
          type: array
          description: 接下来的触发时刻，Unix 秒。`valid` 为假时没有。
          items:
            type: integer
            format: int64
        nextFormatted:
          type: array
          description: 同样那些时刻，格式化成 `YYYY-MM-DD HH:MM 星期`。
          items:
            type: string
        desc:
          $ref: '#/components/schemas/CronDesc'
    TaskPreview:
      type: object
      description: 一个**没保存**的任务的渲染结果。什么都没落库、什么都没发。
      required:
      - preview
      - output
      - room
      - keepHistory
      - referenceAt
      - nextRunAt
      - referenceAt2
      properties:
        preview:
          type: boolean
          const: true
        output:
          type: string
        room:
          type: string
        keepHistory:
          type: boolean
        referenceAt:
          type: integer
          format: int64
        nextRunAt:
          type: integer
          format: int64
        referenceAt2:
          type: string
          description: '参考时刻，RFC3339。


            NOTE (code)：预览接口上这个字段叫 `referenceAt2`，而**试跑一个已存任务**时，

            同一个概念叫 `scheduledAt`。

            '
    TaskRunDry:
      type: object
      required:
      - preview
      - output
      - taskId
      - room
      - keepHistory
      - referenceAt
      - nextRunAt
      - scheduledAt
      properties:
        preview:
          type: boolean
          const: true
        output:
          type: string
        taskId:
          type: string
        room:
          type: string
        keepHistory:
          type: boolean
        referenceAt:
          type: integer
          format: int64
        nextRunAt:
          type: integer
          format: int64
        scheduledAt:
          type: string
          description: 参考时刻，RFC3339。
    TaskRunSent:
      type: object
      required:
      - sent
      - output
      - taskId
      - room
      - keepHistory
      - sentAt
      properties:
        sent:
          type: boolean
          const: true
        output:
          type: string
        taskId:
          type: string
        room:
          type: string
        keepHistory:
          type: boolean
        sentAt:
          type: integer
          format: int64
