Сократить служебные теги SHiNE и сохранить обратную совместимость

This commit is contained in:
AidarKC
2026-08-12 13:14:23 +04:00
parent 4d80ab639e
commit 8f32e82d14
15 changed files with 144 additions and 86 deletions
@@ -6,6 +6,8 @@ import java.util.Map;
public final class ChannelMetaTextParser {
public static final int MAX_TITLE_CHARS = 50;
public static final int MAX_DESCRIPTION_CHARS = 250;
private static final String LEGACY_PREFIX = "<SHiNE:";
private static final String SHORT_PREFIX = "<S:";
private ChannelMetaTextParser() {}
@@ -19,15 +21,16 @@ public final class ChannelMetaTextParser {
boolean seenAvatar = false;
int offset = 0;
while (text.startsWith("<SHiNE:", offset)) {
while (startsWithTagPrefix(text, offset)) {
int end = text.indexOf('>', offset);
if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag");
String body = text.substring(offset + "<SHiNE:".length(), end);
int prefixLength = tagPrefixLength(text, offset);
String body = text.substring(offset + prefixLength, end);
if (body.startsWith("title;")) {
if (seenTitle) throw new IllegalArgumentException("duplicate_channel_meta_title");
seenTitle = true;
title = parseTitleTag(body);
} else if (body.startsWith("avatar;")) {
} else if (body.startsWith("avatar;") || body.startsWith("ava;")) {
if (seenAvatar) throw new IllegalArgumentException("duplicate_channel_meta_avatar");
seenAvatar = true;
Avatar avatar = parseAvatarTag(body);
@@ -61,11 +64,14 @@ public final class ChannelMetaTextParser {
}
private static Avatar parseAvatarTag(String body) {
Map<String, String> fields = parseFields(body.substring("avatar;".length()));
String rawFields = body.startsWith("ava;")
? body.substring("ava;".length())
: body.substring("avatar;".length());
Map<String, String> fields = parseFields(rawFields);
if (!"1".equals(fields.get("v"))) throw new IllegalArgumentException("bad_channel_meta_avatar_version");
String ar = String.valueOf(fields.getOrDefault("ar", "")).trim();
String sha256 = String.valueOf(fields.getOrDefault("sha256", "")).trim().toLowerCase();
String sizeRaw = String.valueOf(fields.getOrDefault("size", "")).trim();
String sizeRaw = String.valueOf(fields.containsKey("sz") ? fields.get("sz") : fields.getOrDefault("size", "")).trim();
if (!ar.matches("^[A-Za-z0-9_-]{43}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_ar");
if (!sha256.matches("^[0-9a-f]{64}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_sha256");
long size;
@@ -78,6 +84,16 @@ public final class ChannelMetaTextParser {
return new Avatar(ar, sha256, size);
}
private static boolean startsWithTagPrefix(String text, int offset) {
return text.startsWith(LEGACY_PREFIX, offset) || text.startsWith(SHORT_PREFIX, offset);
}
private static int tagPrefixLength(String text, int offset) {
if (text.startsWith(LEGACY_PREFIX, offset)) return LEGACY_PREFIX.length();
if (text.startsWith(SHORT_PREFIX, offset)) return SHORT_PREFIX.length();
return 0;
}
private static Map<String, String> parseFields(String raw) {
Map<String, String> out = new HashMap<>();
for (String part : String.valueOf(raw == null ? "" : raw).split(";")) {
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.5.36
server.version=1.4.11
client.version=1.5.37
server.version=1.4.12
+9 -9
View File
@@ -160,20 +160,20 @@
### Вложения в сообщениях
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `SHiNE:attach v=1` или `v=2`.
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `S:att v=1`.
Пример текстового содержимого body:
```text
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
<S:att;v=1;nm=photo.jpg;sz=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
<S:att;v=1;nm=report.pdf;sz=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
Текст сообщения
```
Пример вложения с отдельным preview-файлом:
```text
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
<S:att;v=1;nm=video.mp4;sz=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;preAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;preSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
```
Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`.
@@ -183,8 +183,8 @@
Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст:
```text
<SHiNE:title;v=1;Человекочитаемое имя канала>
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
<S:title;v=1;Человекочитаемое имя канала>
<S:ava;v=1;sz=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
Описание канала
```
@@ -197,12 +197,12 @@
Текстовое содержимое body использует тот же формат полного снимка профиля:
```text
<SHiNE:title;v=1;Новое имя канала>
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
<S:title;v=1;Новое имя канала>
<S:ava;v=1;sz=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
Новое описание канала
```
Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `SHiNE:avatar` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`.
Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `S:ava` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`.
## 7. Хватает ли функций сейчас
@@ -16,8 +16,8 @@ Payload включает:
Для публичных каналов (`channelType=1`) поле является начальным снимком профиля канала и использует тот же текстовый meta-формат, что `TEXT_CHANNEL_META`:
```text
<SHiNE:title;v=1;Название канала>
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
<S:title;v=1;Название канала>
<S:ava;v=1;sz=248193;sha256=3f2c8a...;ar=AbCdEf...>
Описание канала.
```
+10 -10
View File
@@ -34,7 +34,7 @@ TEXT-тип хранит сообщения, материалы и редакт
7. `subType=90``TEXT_CHANNEL_META`
- скрытый технический снимок профиля канала;
- содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием;
- содержит line-поля + текст с тегами `S:title`/`S:ava` и описанием;
- не отображается как обычное сообщение ленты;
- применяется сервером к текущему состоянию канала.
@@ -73,7 +73,7 @@ TEXT-тип хранит сообщения, материалы и редакт
- Такой edit трактуется как логическое удаление содержимого сообщения.
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
## Вложения в текстовых сообщениях (`SHiNE:attach v=1/v=2`)
## Вложения в текстовых сообщениях (`S:att v=1`)
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`.
@@ -84,31 +84,31 @@ TEXT-тип хранит сообщения, материалы и редакт
Один блок вложения:
```text
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123...;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
<S:att;v=1;nm=report.pdf;sz=845221;sha256=0123...;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
```
Для файлов с отдельным превью клиент может использовать расширенный вариант:
```text
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbb...;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeee...>
<S:att;v=1;nm=video.mp4;sz=5820193;sha256=bbbb...;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;preAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;preSha256=eeee...>
```
Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст.
Обязательные поля:
- `v=1` или `v=2`
- `name` - имя файла, закодированное через `encodeURIComponent`
- `size` - размер файла в байтах
- `v=1`
- `nm` - имя файла, закодированное через `encodeURIComponent`
- `sz` - размер файла в байтах
- `sha256` - SHA-256 исходного файла в hex
- `ar` - короткий Arweave Transaction ID, без полного URL
- для `v=2` дополнительно могут присутствовать `previewAr` и `previewSha256`
- при наличии превью дополнительно могут присутствовать `preAr` и `preSha256`
Правила клиента:
- если сообщение начинается с одного или нескольких валидных `<SHiNE:attach;...>` блоков, новый клиент скрывает эти блоки и показывает карточки вложений;
- если сообщение начинается с одного или нескольких валидных attach-блоков (`<SHiNE:attach;...>`, `<S:attach;...>` или `<S:att;...>`), новый клиент скрывает эти блоки и показывает карточки вложений;
- если блок битый, клиент может игнорировать только этот блок и продолжить разбор остальных;
- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст;
- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок.
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `previewAr/previewSha256`.
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `preAr/preSha256`.
+19 -17
View File
@@ -1,4 +1,4 @@
# Вложения в TEXT-сообщениях (`SHiNE:attach v=1/v=2`)
# Вложения в TEXT-сообщениях (`S:att v=1`)
Документ фиксирует текущий формат вложений в текстовых блоках SHiNE.
@@ -15,23 +15,23 @@
## Общий вид
Один attach-блок:
Канонический attach-блок:
```text
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
<S:att;v=1;nm=report.pdf;sz=845221;sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
```
Блок с превью:
```text
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
<S:att;v=1;nm=video.mp4;sz=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;preAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;preSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
```
Несколько вложений идут подряд в самом начале текста:
```text
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
<S:att;v=1;nm=photo.jpg;sz=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
<S:att;v=1;nm=report.pdf;sz=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
Текст сообщения
```
@@ -41,18 +41,18 @@
## Поля
Обязательные поля:
Обязательные поля канонического формата:
- `v=1` - версия формата attach-блока;
- `name` - имя файла, закодированное через `encodeURIComponent`;
- `size` - размер файла в байтах;
- `nm` - имя файла, закодированное через `encodeURIComponent`;
- `sz` - размер файла в байтах;
- `sha256` - SHA-256 исходного файла в hex, 64 символа;
- `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL.
Дополнительные поля для `v=2`:
Дополнительные поля для превью:
- `previewAr` - короткий Arweave Transaction ID файла-превью;
- `previewSha256` - SHA-256 файла-превью в hex.
- `preAr` - короткий Arweave Transaction ID файла-превью;
- `preSha256` - SHA-256 файла-превью в hex.
## Хранение файла
@@ -63,7 +63,7 @@
- SHA-256;
- Arweave `txId`.
Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `previewAr/previewSha256`.
Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `preAr/preSha256`.
## Создание вложения в UI
@@ -78,7 +78,7 @@ UI поддерживает три сценария:
- основной видеофайл;
- отдельное изображение-превью.
После успешной загрузки в журнале хранится один элемент основного файла, но с полями `previewAr/previewSha256`. В UI такой элемент помечается как файл `С превью`.
После успешной загрузки в журнале хранится один элемент основного файла, но с полями `preAr/preSha256`. В UI такой элемент помечается как файл `С превью`.
При ручном добавлении существующего `txId` для видео UI также позволяет вручную указать `txId` файла-превью.
@@ -99,7 +99,7 @@ UI поддерживает три сценария:
- изображение показывает как ограниченное по размеру превью;
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
6. если у видео есть `previewAr/previewSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок;
6. если у видео есть `preAr/preSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок;
7. при перелистывании останавливает воспроизводящееся видео;
8. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
@@ -118,7 +118,9 @@ UI поддерживает три сценария:
- `ar` допускает только короткий Arweave `txId`, полный URL не используется;
- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
- старые блоки `v=1` без превью остаются валидными и читаются без изменений;
- `previewAr/previewSha256` используются только если присутствуют оба поля и оба валидны;
- старые блоки `<SHiNE:attach ...>`, промежуточные `<S:attach ...>` и новые `<S:att ...>` читаются одинаково;
- старые поля `name/size/previewAr/previewSha256` и новые `nm/sz/preAr/preSha256` читаются одинаково;
- старые блоки `v=2` продолжают читаться как legacy-форма вложения с превью;
- `preAr/preSha256` используются только если присутствуют оба поля и оба валидны;
- MIME type, width, height, duration и thumbnail не записываются в блокчейн отдельными полями;
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
+11 -5
View File
@@ -18,15 +18,15 @@
В начале текста могут идти технические теги, после них обычный текст описания:
```text
<SHiNE:title;v=1;Название канала>
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
<S:title;v=1;Название канала>
<S:ava;v=1;sz=248193;sha256=3f2c8a...;ar=AbCdEf...>
Описание канала.
```
Поддерживаемые теги:
- `<SHiNE:title;v=1;...>` — человекочитаемое имя канала.
- `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>` — аватар канала в Arweave.
- `<S:title;v=1;...>` — человекочитаемое имя канала.
- `<S:ava;v=1;sz=...;sha256=...;ar=...>` — аватар канала в Arweave.
## Правила
@@ -44,7 +44,7 @@
- Длина описания — максимум 250 Unicode code points.
- `avatar.ar` — Arweave transaction id из 43 символов.
- `avatar.sha256` — 64 hex-символа.
- `avatar.size` — положительный размер файла в байтах.
- `avatar.sz` — положительный размер файла в байтах.
Если meta-блок невалиден, сервер не применяет его целиком.
@@ -59,3 +59,9 @@
## Замена старых команд
Команда `/.desc` больше не используется и не применяется сервером. Описание канала меняется только через `TEXT_CHANNEL_META`.
## Совместимость
- Новый UI пишет сокращённые теги `<S:title ...>` и `<S:ava ...>`.
- Серверное чтение поддерживает и старые теги `<SHiNE:title ...>` / `<SHiNE:avatar ...>`.
- Для размера аватара чтение поддерживает и старое поле `size`, и новое поле `sz`.
+12
View File
@@ -1,5 +1,17 @@
# История изменений документации блокчейна
## 2026-08-12 13:00:00 +0400
- Базовый коммит-ориентир: `working tree`.
- Канонический текстовый формат служебных тегов сокращён:
- `TEXT_CHANNEL_META` теперь пишет `<S:title>` и `<S:ava>`;
- вложения теперь пишутся как `<S:att;v=1;nm=...;sz=...;sha256=...;ar=...>`;
- превью во вложениях задаётся полями `preAr/preSha256` без отдельной новой версии `v=2`;
- DM-вставки теперь пишутся с префиксом `<S:`.
- Зафиксирована обратная совместимость чтения:
- старые теги `<SHiNE:...>` продолжают поддерживаться;
- старые поля `name/size/previewAr/previewSha256` продолжают поддерживаться;
- legacy-вложения `v=2` продолжают читаться как вложения с превью.
## 2026-08-09 23:30:06 +0400
- Базовый коммит-ориентир: `ee185cf`.
- Нумерация `STATUS_ACTION` уточнена под дневник действий:
+1 -1
View File
@@ -20,7 +20,7 @@
8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md)
Статусные действия пользователя (`msg_type=5`).
9. [16_TEXT_Attachments.md](./16_TEXT_Attachments.md)
Вложения в TEXT-сообщениях через `SHiNE:attach v=1/v=2`, включая опциональные `previewAr/previewSha256` для видео и крупных изображений.
Вложения в TEXT-сообщениях через `S:att v=1`, включая опциональные `preAr/preSha256` для видео и крупных изображений.
10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md)
Скрытый `TEXT_CHANNEL_META` для профиля канала.
11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
+1 -1
View File
@@ -6,7 +6,7 @@
- `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers`
- `docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM`
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<SHiNE:...>` вставок внутри plaintext DM после расшифровки
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<S:...>` вставок внутри plaintext DM после расшифровки
Исторический устаревший документ сохранён отдельно:
@@ -96,7 +96,7 @@
- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`;
- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`.
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<SHiNE:...>` в начале текста.
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<S:...>` в начале текста. Legacy-вставки `<SHiNE:...>` также продолжают поддерживаться при чтении.
Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope.
### 2.4. Источник истины по пользователю
@@ -18,7 +18,7 @@
Если в начале plaintext стоит один или несколько специальных блоков формата:
```text
<SHiNE:...>
<S:...>
```
то клиент трактует их как технические вставки.
@@ -26,14 +26,14 @@
Техническими считаются только блоки, которые:
- стоят строго в начале plaintext;
- начинаются с точного префикса `<SHiNE:`;
- начинаются с префикса `<S:` или legacy-префикса `<SHiNE:`;
- заканчиваются первым символом `>`.
Если текст не начинается с `<SHiNE:`, никакие технические вставки не ищутся.
Если текст не начинается с `<S:` или `<SHiNE:`, никакие технические вставки не ищутся.
## 2. Правило скрытия
Все корректно распознанные блоки `<SHiNE:...>` в начале plaintext:
Все корректно распознанные блоки `<S:...>` или `<SHiNE:...>` в начале plaintext:
- не показываются пользователю как сырой текст;
- используются клиентом для UI-логики;
@@ -45,7 +45,7 @@
Перед отправкой обычного текстового сообщения клиент обязан проверить:
- если пользовательский текст начинается с `<SHiNE:`
- если пользовательский текст начинается с `<S:` или `<SHiNE:`
то клиент автоматически превращает начало в:
@@ -60,7 +60,7 @@
Общий вид:
```text
<SHiNE:kind;key=value;key=value;...>
<S:kind;key=value;key=value;...>
```
Правила:
@@ -69,14 +69,15 @@
- параметры отделяются `;`;
- ключ и значение отделяются `=`;
- значения не экранируются в v1;
- формат чувствителен к точному префиксу `<SHiNE:`.
- канонический новый префикс: `<S:`;
- legacy-префикс `<SHiNE:` продолжает поддерживаться при чтении.
## 5. Тип `reply`
Формат:
```text
<SHiNE:reply;v=1;id=user1|user2|1720612345678|77>Текст ответа
<S:reply;v=1;id=user1|user2|1720612345678|77>Текст ответа
```
Где поле `id` — это логический идентификатор сообщения:
@@ -91,14 +92,14 @@ fromLogin|toLogin|timeMs|nonce
- после него может идти обычный текст ответа;
- официальный UI формирует такой блок при отправке ответа через пункт `Ответить` в меню сообщения;
- если клиент не находит сообщение, на которое ссылается `reply`, он просто не показывает reply-preview;
- в таком случае само сообщение отображается как обычный текст без блока `<SHiNE:reply...>`.
- в таком случае само сообщение отображается как обычный текст без блока `<S:reply...>`.
## 6. Тип `call`
### Успешный звонок
```text
<SHiNE:call;v=1;status=completed;duration=367>
<S:call;v=1;status=completed;duration=367>
```
Где:
@@ -108,7 +109,7 @@ fromLogin|toLogin|timeMs|nonce
### Неуспешный звонок
```text
<SHiNE:call;v=1;status=failed;reason=offline>
<S:call;v=1;status=failed;reason=offline>
```
Допустимые причины в v1:
@@ -130,7 +131,7 @@ fromLogin|toLogin|timeMs|nonce
Официальный UI SHiNE в v1:
- скрывает все корректные `<SHiNE:...>` блоки в начале plaintext;
- скрывает все корректные `<S:...>` и legacy `<SHiNE:...>` блоки в начале plaintext;
- для `call` строит специальный человекочитаемый текст:
- `Звонок: M:SS`
- `Звонок: H:MM:SS`
+19 -12
View File
@@ -1,7 +1,6 @@
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js';
const ATTACH_PREFIX = '<SHiNE:attach;';
const ATTACH_BLOCK_RE = /^<SHiNE:attach;([^>]*)>\n?/u;
const ATTACH_BLOCK_RE = /^<(?:SHiNE|S):(attach|att);([^>]*)>\n?/u;
export const MAX_MESSAGE_ATTACHMENTS = 10;
const RECENT_UNAVAILABLE_MS = 20 * 60 * 1000;
const IMAGE_EXTENSIONS = new Set(['apng', 'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp']);
@@ -33,8 +32,8 @@ function normalizeName(name) {
}
function normalizePreview(input = {}) {
const previewTxId = String(input.previewAr || input.ar || input.txId || '').trim();
const previewSha256Hex = String(input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase();
const previewTxId = String(input.preAr || input.previewAr || input.ar || input.txId || '').trim();
const previewSha256Hex = String(input.preSha256 || input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase();
if (!previewTxId || !previewSha256Hex) return null;
if (!validateArweaveTxId(previewTxId)) return null;
if (!validateSha256Hex(previewSha256Hex)) return null;
@@ -47,9 +46,11 @@ function normalizePreview(input = {}) {
export function normalizeAttachment(input = {}) {
const txId = String(input.ar || input.txId || '').trim();
const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase();
const size = Number(input.size || input.sizeBytes || 0);
const name = normalizeName(input.name || input.fileName || 'file');
const size = Number(input.sz || input.size || input.sizeBytes || 0);
const name = normalizeName(input.nm || input.name || input.fileName || 'file');
const preview = normalizePreview(input.preview || {
preAr: input.preAr,
preSha256: input.preSha256,
previewAr: input.previewAr,
previewSha256: input.previewSha256,
});
@@ -75,9 +76,9 @@ export function buildAttachmentBlock(attachment) {
const item = normalizeAttachment(attachment);
const encodedName = encodeURIComponent(item.name);
const previewFields = item.preview
? `;previewAr=${item.preview.ar};previewSha256=${item.preview.sha256}`
? `;preAr=${item.preview.ar};preSha256=${item.preview.sha256}`
: '';
return `<SHiNE:attach;v=${item.preview ? 2 : 1};name=${encodedName};size=${item.size};sha256=${item.sha256};ar=${item.ar}${previewFields}>`;
return `<S:att;v=1;nm=${encodedName};sz=${item.size};sha256=${item.sha256};ar=${item.ar}${previewFields}>`;
}
export function composeMessageWithAttachments(text, attachments = []) {
@@ -105,16 +106,22 @@ export function parseMessageAttachments(rawText) {
let rest = String(rawText || '');
const attachments = [];
while (rest.startsWith(ATTACH_PREFIX)) {
while (true) {
const match = rest.match(ATTACH_BLOCK_RE);
if (!match) break;
const fields = parseFields(match[1]);
const fields = parseFields(match[2]);
try {
const version = Number(fields.v || 0);
if (version !== 1 && version !== 2) {
throw new Error('unsupported attachment version');
}
attachments.push(normalizeAttachment({
name: decodeURIComponent(String(fields.name || 'file')),
size: fields.size,
nm: decodeURIComponent(String(fields.nm || fields.name || 'file')),
sz: fields.sz || fields.size,
sha256: fields.sha256,
ar: fields.ar,
preAr: fields.preAr,
preSha256: fields.preSha256,
previewAr: fields.previewAr,
previewSha256: fields.previewSha256,
}));
+2 -2
View File
@@ -795,7 +795,7 @@ function composeChannelMetaText({ title = '', description = '', avatar = null }
const cleanTitle = validateChannelMetaTitle(title);
const cleanDescription = normalizeChannelMetaDescription(description);
const rows = [];
if (cleanTitle) rows.push(`<SHiNE:title;v=1;${cleanTitle}>`);
if (cleanTitle) rows.push(`<S:title;v=1;${cleanTitle}>`);
if (avatar?.ar) {
const size = Number(avatar.size || 0);
const sha256 = String(avatar.sha256 || '').trim().toLowerCase();
@@ -803,7 +803,7 @@ function composeChannelMetaText({ title = '', description = '', avatar = null }
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер аватара.');
if (!/^[0-9a-f]{64}$/u.test(sha256)) throw new Error('Некорректный SHA-256 аватара.');
if (!/^[A-Za-z0-9_-]{43}$/u.test(ar)) throw new Error('Некорректный Arweave txId аватара.');
rows.push(`<SHiNE:avatar;v=1;size=${size};sha256=${sha256};ar=${ar}>`);
rows.push(`<S:ava;v=1;sz=${size};sha256=${sha256};ar=${ar}>`);
}
if (cleanDescription) rows.push(cleanDescription);
return rows.join('\n');
+21 -7
View File
@@ -11,6 +11,16 @@ function defaultParsed(rawText = '') {
};
}
function hasTechPrefix(text = '', offset = 0) {
return String(text || '').startsWith('<SHiNE:', offset) || String(text || '').startsWith('<S:', offset);
}
function getTechPrefixLength(text = '', offset = 0) {
if (String(text || '').startsWith('<SHiNE:', offset)) return 7;
if (String(text || '').startsWith('<S:', offset)) return 3;
return 0;
}
function parseReplyId(value = '') {
const parts = String(value || '').split('|');
if (parts.length !== 4) return null;
@@ -57,41 +67,45 @@ function buildCallDisplayText(callSummary) {
export function sanitizeUserDmTextForSend(rawText = '') {
const text = String(rawText || '');
return text.startsWith('<SHiNE:') ? `< SHiNE:${text.slice(7)}` : text;
if (text.startsWith('<SHiNE:')) return `< SHiNE:${text.slice(7)}`;
if (text.startsWith('<S:')) return `< S:${text.slice(3)}`;
return text;
}
export function buildDmCallTechBlock({ status = '', durationSec = 0, reason = '' } = {}) {
const cleanStatus = String(status || '').trim().toLowerCase();
if (cleanStatus === 'completed') {
return `<SHiNE:call;v=1;status=completed;duration=${Math.max(0, Math.floor(Number(durationSec || 0)))}>`;
return `<S:call;v=1;status=completed;duration=${Math.max(0, Math.floor(Number(durationSec || 0)))}>`;
}
const cleanReason = String(reason || '').trim().toLowerCase();
return `<SHiNE:call;v=1;status=failed;reason=${cleanReason || 'connect_failed'}>`;
return `<S:call;v=1;status=failed;reason=${cleanReason || 'connect_failed'}>`;
}
export function buildDmReplyTechBlock({ baseKey = '' } = {}) {
const cleanBaseKey = String(baseKey || '').trim();
if (!cleanBaseKey) return '';
return `<SHiNE:reply;v=1;id=${cleanBaseKey}>`;
return `<S:reply;v=1;id=${cleanBaseKey}>`;
}
export function parseDmTechBlocks(rawText = '') {
const text = String(rawText || '');
if (!text.startsWith('<SHiNE:')) return defaultParsed(text);
if (!hasTechPrefix(text)) return defaultParsed(text);
const blocks = [];
let cursor = 0;
let replyRef = null;
let callSummary = null;
while (text.startsWith('<SHiNE:', cursor)) {
while (hasTechPrefix(text, cursor)) {
const end = text.indexOf('>', cursor);
if (end < 0) {
if (!blocks.length) return defaultParsed(text);
break;
}
const inner = text.slice(cursor + 7, end);
const prefixLength = getTechPrefixLength(text, cursor);
if (!prefixLength) break;
const inner = text.slice(cursor + prefixLength, end);
const segments = inner.split(';').map((part) => String(part || '').trim()).filter(Boolean);
if (!segments.length) {
if (!blocks.length) return defaultParsed(text);