удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка входа при старте, секция настроек и зависимость go-telegram-bot-api; из конвейера ушла доставка ответа отправителю — исход виден опросом готовности. Колонки адресата и значение источника остались в схеме: применённые шаги не переписываются - шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла; существующие строки он не проверяет, и это принято сознательно — искать их надо запросом до выкладки - ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
||||
Главные: комментариями снабжена половина полей; единого места проверки на старте
|
||||
нет: у секций `[auth]` и `[telegram]` свой `Validate()` в `main.go`, а пустые
|
||||
нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в `main.go`, а пустые
|
||||
ключи `[yandex]` ловит конструктор распознавателя.
|
||||
|
||||
**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
|
||||
@@ -46,7 +46,6 @@
|
||||
port = <N> # порт HTTP-сервера
|
||||
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
|
||||
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
|
||||
users_while_list = ["<@name>"] # кому отвечает бот; строка автора Telegram
|
||||
```
|
||||
|
||||
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
|
||||
@@ -58,9 +57,6 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
|
||||
|
||||
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
|
||||
|
||||
*Расхождение:* секции `[server]` в `config.example.toml` не хватает поля
|
||||
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
|
||||
|
||||
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
|
||||
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
|
||||
говорит, какой формы значение здесь ждут. Пустым оставлен только
|
||||
@@ -91,7 +87,7 @@ Ansible из `pet-project-server`). Приложение просто читае
|
||||
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
|
||||
репозиторий и не окружение.
|
||||
|
||||
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
|
||||
- Секретные поля transcriber: `yandex.speech_kit_api_key`,
|
||||
`yandex.object_storage_access_key_id`,
|
||||
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
|
||||
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||
@@ -130,16 +126,8 @@ Ansible из `pet-project-server`). Приложение просто читае
|
||||
TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
|
||||
выходит с кодом 1. Единого места проверки нет.
|
||||
|
||||
Под это расхождение больше не подпадают два ключа секции `[telegram]` — признак
|
||||
включения и ключ доступа, — и проверок у них две. Третий ключ секции,
|
||||
`update_timeout`, границ по-прежнему не проверяет никто, и ноль в нём обращает
|
||||
длинный опрос в непрерывный. Обязательность признака включения судит загрузчик — только разбор отличает
|
||||
«ключ не задан» от «ключ задан ложным», потому что нулевое значение `bool` у
|
||||
обоих одинаковое. Заполненность ключа доступа судит `TelegramConfig.Validate()` из
|
||||
`main.go`, рядом с проверкой `[auth]`: пустой `bot_token` при `enabled = true` —
|
||||
ошибка настройки и отказ старта. Непустой негодный по-прежнему судится при сборке
|
||||
клиента, до подъёма сервера. Нормирует это `openspec/specs/intake`, «Признак
|
||||
включения решает, поднимается ли вход Telegram».
|
||||
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
|
||||
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
|
||||
|
||||
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
|
||||
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
|
||||
@@ -165,5 +153,6 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
|
||||
комментария у самого поля), ни по нулевому значению типа; присутствие ключа
|
||||
судит **разбор** — `MetaData.IsDefined` из `toml.DecodeFile`, — потому что
|
||||
значение отличить «не задано» от «задано нулём» не позволяет. В
|
||||
`config.example.toml` у поля стоит значение свежей установки. Первое такое
|
||||
поле — `telegram.enabled`.
|
||||
`config.example.toml` у поля стоит значение свежей установки. Первым таким
|
||||
полем был `telegram.enabled`; секция убрана 2026-08-14, и живого примера у
|
||||
правила сейчас нет.
|
||||
|
||||
+12
-14
@@ -69,11 +69,10 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
|
||||
(состояние), `contract.LostAcquisitionError` (идентификатор задачи).
|
||||
|
||||
`tg.EmptyBotTokenError` был ровно тем случаем, против которого написано правило —
|
||||
тип без полей, — и снят задачей `local-run-without-telegram-token` 2026-08-13;
|
||||
его место занял sentinel `telegram.ErrEmptyToken`. Рядом живёт
|
||||
`contract.ErrDeliveryChannelDown` — тоже sentinel и по той же причине: заглушка
|
||||
отправителя не знает ни задачи, ни чата, и нести ей нечего.
|
||||
Правило это однажды нарушал `tg.EmptyBotTokenError` — тип без полей, — и был
|
||||
снят задачей `local-run-without-telegram-token` 2026-08-13 в пользу sentinel'а.
|
||||
Оба ушли из проекта 2026-08-14 вместе с входом Telegram; пример остаётся здесь
|
||||
как случай, а не как живой код.
|
||||
|
||||
## Граница и трансляция: приватный и публичный канал
|
||||
|
||||
@@ -83,8 +82,8 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
|
||||
`%w` и контекстом. Пишется один раз на доменной границе — см.
|
||||
[logging.md](logging.md).
|
||||
- **Публичный канал — пользовательские поверхности** (Telegram, веб-UI, HTTP
|
||||
API). Сюда отдаём:
|
||||
- **Публичный канал — пользовательские поверхности** (веб-UI, HTTP API). Сюда
|
||||
отдаём:
|
||||
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
|
||||
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
|
||||
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
|
||||
@@ -118,8 +117,7 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
|
||||
У публичной границы две поверхности, и правило сырого текста для них разное.
|
||||
|
||||
- **Разовый ответ на действие** (тело HTTP-ответа, сообщение бота по результату
|
||||
команды) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
||||
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
||||
полная ошибка остаётся в логах по идентификатору задачи.
|
||||
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
|
||||
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
|
||||
@@ -131,9 +129,9 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
- **внешнее значение в тексте усекается на границе, а его размер называется
|
||||
числом рядом**: без этого непонятно, насколько сокращать.
|
||||
|
||||
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения, а
|
||||
пользователь Telegram видит отдельный человекочитаемый текст — это часть
|
||||
правила соблюдена.
|
||||
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
|
||||
Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без
|
||||
машинного текста — эту часть правила держит спека `intake`.
|
||||
|
||||
## panic
|
||||
|
||||
@@ -145,8 +143,8 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
ронял процесс. В transcriber его вешает роутер хранилища сам
|
||||
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
|
||||
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
|
||||
живёт. Своего слоя мы не пишем. У воркеров и у бота такой границы **нет**:
|
||||
паника в шаге конвейера роняет процесс целиком.
|
||||
живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в
|
||||
шаге конвейера роняет процесс целиком.
|
||||
|
||||
## Несколько ошибок
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
| --- | --- |
|
||||
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
||||
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules` → `TestОшибкаНеУзнаётсяПоТексту` |
|
||||
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove` и `send` |
|
||||
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `os.Remove` |
|
||||
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml` → `errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
|
||||
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml` → `nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
|
||||
| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml` → `rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
|
||||
@@ -89,7 +89,7 @@
|
||||
| Правило | Где механизировано |
|
||||
| --- | --- |
|
||||
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
|
||||
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
||||
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
||||
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
|
||||
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком |
|
||||
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
|
||||
@@ -117,7 +117,7 @@
|
||||
| --- | --- |
|
||||
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml` → `forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
|
||||
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml` → `testifylint` |
|
||||
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
|
||||
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик и логгер; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
|
||||
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml` → `nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
|
||||
|
||||
### Форма кода и файлов вне Go
|
||||
@@ -152,7 +152,7 @@
|
||||
|
||||
| Подавлено | Где | Почему |
|
||||
| --- | --- | --- |
|
||||
| `errcheck` на `defer Close`, `os.Remove` и `send` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
|
||||
| `errcheck` на `defer Close` и `os.Remove` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
|
||||
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
|
||||
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
|
||||
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
|
||||
|
||||
+16
-29
@@ -28,7 +28,7 @@ OpenSpec.
|
||||
`jq` без регулярных выражений.
|
||||
|
||||
```json
|
||||
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"telegram","duration_seconds":137}
|
||||
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
|
||||
```
|
||||
|
||||
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
|
||||
@@ -37,7 +37,7 @@ OpenSpec.
|
||||
|
||||
- `msg` — короткая константа в нижнем регистре: `record accepted`,
|
||||
`recognition done`, `conversion failed`. Данные — в атрибутах:
|
||||
`log.Info("record accepted", "record_id", id, "source", "telegram")`.
|
||||
`log.Info("record accepted", "record_id", id, "source", "api")`.
|
||||
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
|
||||
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
|
||||
- **Смена состояния задачи — единая категория `state transition`** с полями
|
||||
@@ -60,7 +60,7 @@ OpenSpec.
|
||||
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
|
||||
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
|
||||
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
|
||||
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, задача ушла в `failed`, необработанная ошибка |
|
||||
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, запись остановлена признаком, необработанная ошибка |
|
||||
|
||||
Правила:
|
||||
|
||||
@@ -99,7 +99,7 @@ OpenSpec.
|
||||
|
||||
| Когда добавляем | Поля |
|
||||
| --- | --- |
|
||||
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
|
||||
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
|
||||
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
|
||||
| на запись об ошибке | `error` |
|
||||
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
||||
@@ -137,7 +137,7 @@ log := log.With("record_id", record.Id, "capability", "conversion")
|
||||
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
|
||||
исход операции. Логирует эта единая точка, а не каждый транспорт — так
|
||||
транспорты остаются тонкими. Границы в transcriber:
|
||||
- приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`);
|
||||
- приём записи (`CreateJobFromApi`);
|
||||
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
|
||||
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
|
||||
- завершение и отказ задачи (`completeJob`, `failJob`).
|
||||
@@ -169,9 +169,9 @@ log := log.With("record_id", record.Id, "capability", "conversion")
|
||||
|
||||
**Каждый** вызов внешнего сервиса логируется. Поля:
|
||||
|
||||
- `ext.service` — `telegram`, `speechkit`, `object-storage`, `ffmpeg`;
|
||||
- `ext.operation` — логическая операция (`getFile`, `sendMessage`,
|
||||
`RecognizeFile`, `GetOperation`, `PutObject`, `convert`);
|
||||
- `ext.service` — `speechkit`, `object-storage`, `ffmpeg`;
|
||||
- `ext.operation` — логическая операция (`RecognizeFile`, `GetOperation`,
|
||||
`PutObject`, `convert`);
|
||||
- `ext.status_code` — код ответа, если применим;
|
||||
- `duration_ms` — длительность вызова;
|
||||
- `retry` — номер попытки, если повторы были.
|
||||
@@ -191,8 +191,7 @@ log := log.With("record_id", record.Id, "capability", "conversion")
|
||||
|
||||
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
|
||||
конвертация (через метрику длительности) и запуск распознавания; заливка в
|
||||
Object Storage, скачивание файла из Telegram и опрос операции не логируются
|
||||
никак.
|
||||
Object Storage и опрос операции не логируются никак.
|
||||
|
||||
## HTTP и проверка здоровья
|
||||
|
||||
@@ -218,7 +217,6 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
||||
|
||||
Никаких секретов в полях и сообщениях. Под запретом:
|
||||
|
||||
- токен бота Telegram;
|
||||
- ключ SpeechKit и заголовок `Authorization`;
|
||||
- пара ключей Object Storage;
|
||||
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
|
||||
@@ -234,28 +232,17 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
||||
- При сомнении не логируем значение, логируем факт его наличия
|
||||
(`"has_api_key", true`).
|
||||
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
|
||||
`*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram
|
||||
живёт прямо в пути (`…/bot<TOKEN>/…`). Такую ошибку разворачивают в
|
||||
`*url.Error` из `net/http` встраивает полный URL запроса, а секрет иногда
|
||||
живёт прямо в пути. Такую ошибку разворачивают в
|
||||
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
|
||||
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
||||
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
||||
|
||||
Обращения к Telegram этому правилу следуют, и точка чистки одна на все вызовы —
|
||||
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
|
||||
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из всех:
|
||||
|
||||
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
|
||||
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
|
||||
`sendMessage`, скачивание записи и `getMe` из конструктора;
|
||||
- длинный опрос печатает свои отказы **пакетным логгером самой библиотеки**,
|
||||
минуя наш `slog`; логгер подменён на вычищающий (`tgbotapi.SetLogger`), и
|
||||
замена точная — токен известен.
|
||||
|
||||
Прежде здесь стоял `http.Get(file.Link(token))`, отказ уезжал в журнал вместе с
|
||||
токеном, а конвенция числила это расхождением с оценкой «не логируется», которая
|
||||
была неверной. Запись — [../review.md](../review.md), 2026-08-13; оракулом
|
||||
служат проверки `internal/adapter/telegram/bot_test.go`, судящие по тексту
|
||||
отказа и строке журнала.
|
||||
Живого случая у этого правила сейчас нет: единственный секрет, стоявший в пути
|
||||
обращения, — токен бота, и он ушёл вместе с входом Telegram 2026-08-14. Разбор
|
||||
случая и цена промаха записаны в [../review.md](../review.md), 2026-08-13:
|
||||
конвенция числила утечку расхождением с оценкой «не логируется», и оценка была
|
||||
неверной.
|
||||
|
||||
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
|
||||
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
||||
|
||||
@@ -108,5 +108,5 @@
|
||||
узнала»).
|
||||
- **Устройство service worker и версионирование статики** — задача
|
||||
[installable-pwa](../../tasks/items/installable-pwa.md).
|
||||
- **Как связываются пользователь Telegram и пользователь веба** — открытый вопрос
|
||||
- **Как связать чат Telegram с учётной записью** — открытый вопрос; от него зависит возвращение убранного 2026-08-14 входа
|
||||
«Учётные записи» в [../architecture.md](../architecture.md).
|
||||
|
||||
Reference in New Issue
Block a user