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