292 lines
28 KiB
Markdown
292 lines
28 KiB
Markdown
## MODIFIED Requirements
|
||
|
||
### Requirement: Маршруты агента
|
||
Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном):
|
||
- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at, update_state, launcher_version}` — долгий
|
||
запрос до 30 с; ответ `{command: {id, type, params, pause_seconds}, settings_version, agent_release}` или `{command:
|
||
null, settings_version, agent_release}`, где `agent_release` — `{version, sha256}` опубликованной версии агента или
|
||
`null`; обновляет `agent_status` (`last_jira_call_at = null` не затирает прежнее значение). `update_state` (объект или
|
||
`null`, `error` хранится до 500 символов) и `launcher_version` (целое или `null`) необязательны; хаб SHALL хранить
|
||
последние значения в памяти процесса, значения неверного типа игнорировать, не отвергая `claim`. Если есть команда в
|
||
`claimed`, `claim` SHALL выдать её снова (повторная выдача, см. `commands`), а не следующую из очереди.
|
||
- `GET /agent/settings` — настройки для агента (без токенов).
|
||
- `GET /agent/commands/{id}` — команда по id (для восстановления из журнала), включая `pause_seconds`.
|
||
- `POST /agent/commands/{id}/result` `{status, result, error, jira_calls, rate_limited}` — идемпотентно: повторный
|
||
отчёт по команде в терминальном статусе отвечает 200 без изменений, кроме позднего отчёта `done` с `result.key` по
|
||
`ensure_issue` в `cancelled` или `failed` без результата (результат записывается, статус не меняется, заметка `new:N`
|
||
переносится на ключ). Отчёт по команде в `queued` SHALL отвергаться 409 `not_claimed`. `error` хаб SHALL обрезать до
|
||
1500 символов.
|
||
- `POST /agent/state` `{command_id, issues: [...], links: [...], neighbor_keys: [...], page, last_page}` — загрузка состояния
|
||
пачками; `neighbor_keys` — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и `sync_run`. Каждая
|
||
пачка применяется независимо; `page` и `last_page` необязательны и хабом не используются. Поле задачи `comments`
|
||
необязательно; если оно есть, хаб SHALL до записи проверить его форму: список объектов, `id` — непустая строка, `body` —
|
||
строка, `author`, `created`, `updated` — строка или `null`; нарушение — 422 `bad_state` с ключом задачи и именем поля,
|
||
пачка не применяется. Поле задачи `attachments` (агент v14) необязательно и проверяется так же: список объектов, `id` —
|
||
непустая строка, `filename` — непустая строка, `size` — неотрицательное целое или `null`, `mime_type`, `author`,
|
||
`created` — строка или `null`.
|
||
- `GET /agent/known-keys` — ключи зеркала (для определения соседей).
|
||
- `GET /agent/release` — опубликованная версия агента `{version, sha256, content}` (`content` — текст `agent.py`);
|
||
нет публикации или файл не сходится с манифестом по sha256 — 404 `no_release`.
|
||
- `GET /agent/files/{sha256}` — файл вложения из хранилища хаба `{file, size, content}` (`content` — base64 содержимого);
|
||
файла нет — 404 `no_file`.
|
||
- `POST /agent/files` `{content}` — файл вложения, скачанный агентом из Jira, в хранилище хаба; ответ `{file, size}`;
|
||
проверки и ошибки — как у `POST /files` (см. «Хранилище файлов вложений»), кроме пустого файла: от агента он
|
||
принимается (в Jira бывают пустые вложения).
|
||
|
||
#### Scenario: Выдача с подстановкой ключа
|
||
- **WHEN** следующая команда ссылается на `new:60`, а `ensure_issue` для `new:60` завершилась с `AURORA-1590`
|
||
- **THEN** `claim` отдаёт команду с `AURORA-1590` в параметрах
|
||
|
||
#### Scenario: Повторный отчёт
|
||
- **WHEN** агент дважды прислал `result` для команды 15
|
||
- **THEN** второй ответ 200, статус и результат не изменились
|
||
|
||
#### Scenario: Отчёт по невыданной команде
|
||
- **WHEN** `POST /agent/commands/16/result` с `status = done`, команда 16 в `queued`
|
||
- **THEN** 409 `not_claimed`, команда осталась `queued`
|
||
|
||
#### Scenario: Длинная ошибка
|
||
- **WHEN** агент прислал `failed` с `error` длиной 5000 символов
|
||
- **THEN** в `command.error` первые 1500 символов
|
||
|
||
#### Scenario: Поздний отчёт после предела выдач
|
||
- **WHEN** `ensure_issue {temp_key: new:8, …}` переведена хабом в `failed` «агент не отчитался после 3 выдач», затем агент
|
||
прислал `done` с `result.key = AURORA-1630`
|
||
- **THEN** команда `failed`, в `result` — `AURORA-1630`; заметка `new:8` перенесена на `AURORA-1630`
|
||
|
||
#### Scenario: Публикация в ответе claim
|
||
- **WHEN** опубликована v6, агент v5 отправил `claim`, команд нет
|
||
- **THEN** ответ `{command: null, settings_version, agent_release: {version: 6, sha256: …}}`
|
||
|
||
#### Scenario: Состояние обновления от агента
|
||
- **WHEN** агент прислал в `claim` `update_state = {state: rolled_back, from_version: 6, to_version: 7, error: "код 1 через 3 с"}`
|
||
и `launcher_version = 1`
|
||
- **THEN** `GET /agent` отдаёт это `update_state`, `launcher_version = 1` и `update_hint` «откат с v7 на v6: код 1 через 3 с»
|
||
|
||
#### Scenario: Скачивание версии
|
||
- **WHEN** агентским токеном `GET /agent/release`, опубликована v6
|
||
- **THEN** 200 `{version: 6, sha256, content}`, sha256 от `content` в UTF-8 равен `sha256`; клиентским токеном — 401;
|
||
без публикации — 404 `no_release`
|
||
|
||
#### Scenario: Скачивание файла вложения
|
||
- **WHEN** агентским токеном `GET /agent/files/<sha>`, файл загружен
|
||
- **THEN** 200 `{file: <sha>, size, content}`, sha256 от декодированного `content` равен `<sha>`; клиентским токеном — 401;
|
||
файла нет — 404 `no_file`
|
||
|
||
#### Scenario: Неверный комментарий в пачке
|
||
- **WHEN** `POST /agent/state` с задачами X и Y, у Y комментарий `{id: "7", body: 42}`
|
||
- **THEN** 422 `bad_state` с ключом Y и полем `body`; ни X, ни Y в зеркале не изменились
|
||
|
||
### Requirement: Маршруты интерфейсов
|
||
Хаб SHALL предоставлять интерфейсам (MCP, веб-приложение) следующие маршруты под клиентским токеном:
|
||
- `GET /issues` с фильтрами `project` (разрез по проекту, см. `data-model`, «Проекты»: свои и соседи, `neighbor`
|
||
относительно проекта; неизвестный проект — 404), `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`,
|
||
`epic`, `neighbor`, `q` (подстрока темы), `hide_closed` (`1` / `true` — без задач, чей `status` входит в настройку
|
||
`closed_statuses`; задача без статуса не закрыта), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status`
|
||
и `work_kind` из заметок; сокращение до карточки — дело интерфейса. Без `project` — всё зеркало, `neighbor` — признак
|
||
зеркала (вне базовой JQL).
|
||
- `GET /issues/{key}` — задача целиком: поля, `epic_summary` (тема эпика из зеркала или `null`), связи в обе стороны
|
||
(`links_out: [{target_key, link_type, summary, status}]`, `links_in: [{source_key, link_type, summary, status}]`, где
|
||
`summary` и `status` — связанной задачи из зеркала или `null`, если её там нет), заметки, последние 10 команд по ней (без `fetch_attachment`),
|
||
`comments: [{comment_id, author, created, updated, body, attachment_ids}]` — все комментарии, новые сверху (по `created`, затем
|
||
`comment_id`, по убыванию); `attachments: [{attachment_id, filename, size, mime_type, author, created, state, error}]` —
|
||
все вложения задачи, новые сверху (по `created_at`, затем `attachment_id`, по убыванию), `state` и `error` — см.
|
||
`data-model`, «Скачанные вложения» (`error` — только при `failed`, иначе `null`); `description_attachment_ids` и
|
||
`attachment_ids` комментария — id вложений, на которые ссылается текст (см. «Ссылки на вложения в тексте»), в порядке
|
||
первого упоминания, без повторов.
|
||
- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела).
|
||
- `POST /batches` `{title, author, project?, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе,
|
||
неверных параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более
|
||
`ensure_issue` с одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся
|
||
темы), по проверкам постановки из `commands` (тип `bug` без настройки, файл вложения не загружен, доска для
|
||
`ensure_sprint`), а также по проверкам пакета проекта (см. `commands`), если передан `project`.
|
||
- `GET /attachments/{id}`, `POST /attachments/{id}/fetch`, `GET /attachments/{id}/content` — вложения задач (см.
|
||
«Скачивание вложений»).
|
||
- `POST /files` `{content}` (`content` — base64 содержимого файла) → `{file, size}`, где `file` — sha256 содержимого
|
||
(64 hex-символа, нижний регистр); см. «Хранилище файлов вложений».
|
||
- `GET /batches?limit=&project=` (с `project` — только пакеты этого проекта), `GET /batches/{id}` (с командами и
|
||
`project`), `POST /batches/{id}/cancel` — отменяет `queued` команды пакета; ответ — вид пакета с полем `still_claimed`:
|
||
список `{id, type}` команд пакета в `claimed` (они будут выполнены агентом; остановить — отмена команды с `force`).
|
||
- `POST /commands/{id}/retry` `{author?, project?}` — новая команда с теми же параметрами в новом пакете «Повтор
|
||
команды N»; хаб снимает с параметров то, что дописывает сам (`board_id` у `ensure_sprint`), и проверяет команду
|
||
заново. `project` передан и у исходного пакета другой проект (или его нет) — 422 «команда #N не из проекта P»; не
|
||
передан — новый пакет получает проект исходного и проходит его проверки.
|
||
- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`.
|
||
- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`; `full` — по базовой JQL из проектов, без
|
||
проектов — 422 «нет проектов».
|
||
- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`;
|
||
ответ — все настройки после записи, как у `GET /settings`, включая `settings_version`).
|
||
|
||
#### Scenario: Фильтр по спринту
|
||
- **WHEN** `GET /issues?sprint=Спринт 2`
|
||
- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу
|
||
|
||
#### Scenario: Список проекта
|
||
- **WHEN** свои задачи `STOCKS_DRRK` — AURORA-1 и AURORA-4, сосед — AURORA-2; `GET /issues?project=STOCKS_DRRK`
|
||
- **THEN** три задачи, у AURORA-2 `neighbor = true`; с `neighbor=1` — только AURORA-2; задач только с меткой `AWPCA` нет
|
||
|
||
#### Scenario: Неизвестный проект
|
||
- **WHEN** `GET /issues?project=NOPE`
|
||
- **THEN** 404
|
||
|
||
#### Scenario: Пакеты проекта
|
||
- **WHEN** пакет 10 поставлен с `project = STOCKS_DRRK`, пакет 11 — без проекта; `GET /batches?project=STOCKS_DRRK`
|
||
- **THEN** в ответе только пакет 10
|
||
|
||
#### Scenario: Повтор чужой команды
|
||
- **WHEN** команда 40 из пакета проекта `AWPCA`; `POST /commands/40/retry {project: "STOCKS_DRRK"}`
|
||
- **THEN** 422 «команда #40 не из проекта STOCKS_DRRK», пакет не создан
|
||
|
||
#### Scenario: Повтор ensure_sprint
|
||
- **WHEN** `ensure_sprint` 41 failed, в её `params` `board_id = 4216`; `POST /commands/41/retry`
|
||
- **THEN** новый пакет создан, `board_id` в новой команде вычислен хабом заново
|
||
|
||
#### Scenario: Полный прогон без проектов
|
||
- **WHEN** проектов нет, `POST /refresh {full: true}`
|
||
- **THEN** 422 «нет проектов», пакет не поставлен
|
||
|
||
#### Scenario: Отмена взятой команды
|
||
- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed`
|
||
- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется
|
||
|
||
#### Scenario: Поздний отчёт по отменённому созданию
|
||
- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620`
|
||
- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620`
|
||
|
||
#### Scenario: Дубль темы в пакете
|
||
- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"`
|
||
- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан
|
||
|
||
#### Scenario: Тема не строка
|
||
- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}`
|
||
- **THEN** 422 с указанием параметра `summary`, пакет не создан
|
||
|
||
#### Scenario: Отмена пакета с командой у агента
|
||
- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel`
|
||
- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed`
|
||
|
||
#### Scenario: Ответ на запись настроек
|
||
- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}`
|
||
- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки;
|
||
токенов в ответе нет
|
||
|
||
#### Scenario: Карточка с комментариями и связями
|
||
- **WHEN** у AURORA-9001 два комментария (14.10 и 15.10), исходящая связь на AURORA-9002 («API свода», «Новая») и на
|
||
AURORA-1, которой нет в зеркале; эпик AURORA-9100 «Тестовый эпик»
|
||
- **THEN** `GET /issues/AURORA-9001` отдаёт `comments` с комментарием от 15.10 первым, у связи на AURORA-9002 —
|
||
`summary = "API свода"`, `status = "Новая"`, у связи на AURORA-1 — `summary = null`, `status = null`;
|
||
`epic_summary = "Тестовый эпик"`
|
||
|
||
#### Scenario: Без закрытых
|
||
- **WHEN** `closed_statuses = ["Готово", "Снято"]`, в зеркале задачи в статусах «В работе», «Готово», «Снято» и без
|
||
статуса; `GET /issues?hide_closed=1`
|
||
- **THEN** в ответе задачи «В работе» и без статуса; без `hide_closed` — все четыре
|
||
|
||
### Requirement: Хранилище файлов вложений
|
||
Хаб SHALL хранить файлы вложений в каталоге `files/` внутри каталога данных, по одному файлу на содержимое с именем
|
||
sha256 (без расширения и имени вложения: имя задаёт команда). Каталог SHALL иметь права `0700` (выставляются при создании
|
||
и при каждом старте, как у `backups/`). `POST /files`:
|
||
- `content` не строка или не base64 — 400 `bad_request`; пустой файл — 422 `empty_file`;
|
||
- файл больше 10 МБ (10 485 760 байт) после декодирования — 413 `too_large` (тело запроса до 16 МБ проходит);
|
||
- запись атомарная: временный файл в `files/`, затем переименование; повторная загрузка того же содержимого отвечает тем
|
||
же `file` и не создаёт второй копии.
|
||
Файлы в бэкапы базы MUST NOT входить. Фоновый `tick` хаба SHALL не чаще раза в сутки в пределах работы процесса (после старта — на первом тике) удалять файлы старше 30 дней (по
|
||
времени изменения), на которые не ссылается ни одна команда `ensure_attachment` в `queued` или `claimed` (скачанные
|
||
вложения `attachment_file` от уборки не защищены: их можно скачать снова), и подкаталоги `downloads/<attachment_id>/`
|
||
(копии MCP, см. `mcp`) старше 30 дней по времени изменения; повтор команды
|
||
после уборки отвергается при постановке 422 «файл <sha> не загружен: POST /files». Повторная загрузка того же
|
||
содержимого SHALL обновлять время изменения файла; выдача файла `GET /attachments/{id}/content` — тоже.
|
||
|
||
#### Scenario: Загрузка файла
|
||
- **WHEN** `POST /files {content: base64("abc")}`
|
||
- **THEN** 200 `{file: "ba7816bf…", size: 3}`; в `files/` файл `ba7816bf…` с содержимым `abc`
|
||
|
||
#### Scenario: Повторная загрузка
|
||
- **WHEN** тот же `POST /files` второй раз
|
||
- **THEN** 200 с тем же `file`; в `files/` по-прежнему один файл
|
||
|
||
#### Scenario: Слишком большой файл
|
||
- **WHEN** `POST /files` с файлом 11 МБ
|
||
- **THEN** 413 `too_large`, в `files/` ничего не появилось
|
||
|
||
#### Scenario: Уборка старых файлов
|
||
- **WHEN** в `files/` файл A старше 30 дней без ссылок, файл B старше 30 дней, на него ссылается `ensure_attachment` в
|
||
`queued`, файл C загружен вчера
|
||
- **THEN** после уборки удалён только A
|
||
|
||
#### Scenario: Скачанное вложение и уборка
|
||
- **WHEN** в `files/` файл D скачан агентом (`attachment_file`) 40 дней назад и с тех пор не выдавался; в `downloads/`
|
||
каталог `31337/` создан 40 дней назад
|
||
- **THEN** после уборки D и `downloads/31337/` удалены; `state` вложения — `none`
|
||
|
||
### Requirement: Резервные копии
|
||
Перед выдачей первой команды пакета, содержащего записи в Jira (любая команда, кроме `refresh` и `fetch_attachment`), хаб
|
||
SHALL скопировать файл базы в `backups/<дата-время>-<batch>.sqlite` и хранить последние `backup_keep` копий.
|
||
|
||
#### Scenario: Ротация
|
||
- **WHEN** копий больше `backup_keep`
|
||
- **THEN** самые старые удаляются до `backup_keep`
|
||
|
||
#### Scenario: Скачивание без копии
|
||
- **WHEN** выдаётся `fetch_attachment` пакета «Скачать вложение …»
|
||
- **THEN** копия базы не создаётся
|
||
|
||
## ADDED Requirements
|
||
|
||
### Requirement: Скачивание вложений
|
||
Хаб SHALL предоставлять интерфейсам под клиентским токеном:
|
||
- `GET /attachments/{id}` — вложение из зеркала `{attachment_id, key, filename, size, mime_type, author, created, state,
|
||
error, command_id, batch_id}`, где `command_id`/`batch_id` — последней `fetch_attachment` по нему (или `null`); нет в
|
||
зеркале — 404 `no_attachment`.
|
||
- `POST /attachments/{id}/fetch` `{author?}` — запросить скачивание. Нет в зеркале — 404 `no_attachment`; `state =
|
||
ready` или `fetching` — ответ как у `GET /attachments/{id}`, новой команды нет; размер больше 10 МБ — 413 `too_large`;
|
||
последний известный хабу агент (`agent_status.agent_version`) старше v14 — 409 `agent_too_old` «агент v<N> не умеет
|
||
скачивать вложения — нужен v14» (версия неизвестна — проверка не делается); иначе хаб SHALL поставить пакет из одной
|
||
`fetch_attachment` (см. `commands`, автор — из тела, иначе `api`) и ответить как `GET /attachments/{id}` с `state =
|
||
fetching`. Проверка «уже ставится» и постановка SHALL выполняться в одной транзакции записи: два одновременных запроса
|
||
ставят одну команду.
|
||
- `GET /attachments/{id}/content` — содержимое при `state = ready`: 200, тело — байты файла, `Content-Type` — `mime_type`
|
||
вложения (нет или не вида `тип/подтип` — `application/octet-stream`), `Content-Disposition: attachment;
|
||
filename*=UTF-8''<имя в URL-кодировке>`, `X-Content-Type-Options: nosniff`, `Content-Length`; иначе 404 `not_fetched`.
|
||
Отчёт `done` по `fetch_attachment` (`POST /agent/commands/{id}/result`) SHALL записывать `attachment_file` из
|
||
`result.file` и `result.size`, если файл `file` есть в хранилище и его размер равен `size`; иначе команда SHALL
|
||
становиться `failed` «хаб: файл <file> не найден в хранилище» и `attachment_file` не меняется.
|
||
|
||
#### Scenario: Первый запрос
|
||
- **WHEN** вложение #31337 в зеркале, `state = none`, агент v14; `POST /attachments/31337/fetch {author: "web"}`
|
||
- **THEN** создан пакет «Скачать вложение скрин 1.png (AURORA-1650)» из одной `fetch_attachment`, без хвостового
|
||
`refresh`; ответ `state = fetching` с `command_id` и `batch_id`
|
||
|
||
#### Scenario: Повторный запрос во время скачивания
|
||
- **WHEN** по #31337 команда в `queued`; MCP и веб одновременно вызывают `POST /attachments/31337/fetch`
|
||
- **THEN** оба ответа — `state = fetching` с той же `command_id`; новых пакетов нет
|
||
|
||
#### Scenario: Файл уже у хаба
|
||
- **WHEN** #31337 в `state = ready`; `GET /attachments/31337/content`
|
||
- **THEN** 200, `Content-Type: image/png`, `Content-Disposition: attachment; filename*=UTF-8''%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png`,
|
||
тело — файл; время изменения файла в `files/` обновлено
|
||
|
||
#### Scenario: Старый агент
|
||
- **WHEN** `agent_status.agent_version = 13`; `POST /attachments/31337/fetch`
|
||
- **THEN** 409 `agent_too_old` «агент v13 не умеет скачивать вложения — нужен v14», пакета нет
|
||
|
||
#### Scenario: Отчёт без файла в хранилище
|
||
- **WHEN** агент отчитался `done {file: <sha>, size: 100}`, а файла `<sha>` в `files/` нет
|
||
- **THEN** команда `failed` «хаб: файл <sha> не найден в хранилище»; `state` вложения — `failed`
|
||
|
||
### Requirement: Ссылки на вложения в тексте
|
||
Хаб SHALL находить в описании задачи и в тексте каждого комментария ссылки на вложения задачи по вики-разметке Jira:
|
||
`!<имя>!` и `!<имя>|<параметры>!` (картинка; имя без `!`, `|` и переводов строк, не содержит `://`), `[^<имя>]` и
|
||
`[<текст>|^<имя>]` (ссылка на вложение; имя без `]` и переводов строк). Имя SHALL сравниваться с `filename` вложений
|
||
задачи точно (с учётом регистра); несколько вложений с одним именем — ссылка указывает на самое новое по `created_at`
|
||
(как в Jira); имени нет среди вложений — ссылка не учитывается. Ссылки внутри `{code}`/`{noformat}` тоже учитываются.
|
||
|
||
#### Scenario: Скриншот в комментарии
|
||
- **WHEN** у задачи вложения «скрин 1.png» (#31337) и «ОСВ 60.xlsx» (#31338); комментарий 101 — «см.
|
||
!скрин 1.png|thumbnail! и [^ОСВ 60.xlsx], ещё раз !скрин 1.png!»; описание ссылается на «старый.png», которого нет
|
||
- **THEN** у комментария 101 `attachment_ids = ["31337", "31338"]`; `description_attachment_ids = []`
|
||
|
||
#### Scenario: Два вложения с одним именем
|
||
- **WHEN** у задачи два вложения «скрин.png» (#10 от 14.10, #20 от 20.10); комментарий — «!скрин.png!»
|
||
- **THEN** у комментария `attachment_ids = ["20"]`
|