Добавить SHiNE теги в DM и безопасный рендер превью

This commit is contained in:
AidarKC
2026-07-10 18:57:44 +04:00
parent 076b43e542
commit 0d2d7b6dc5
10 changed files with 358 additions and 18 deletions
@@ -0,0 +1,26 @@
## Краткое описание
В DM добавлен клиентский формат технических вставок в начале plaintext:
- `<SHiNE:reply;v=1;id=...>`
- `<SHiNE:call;v=1;status=...;...>`
UI скрывает такие вставки из текста сообщения, а call-вставки отображает специальным человекочитаемым видом.
Также добавлена защита от пользовательского текста, начинающегося с `<SHiNE:`, и исправлен безопасный рендер превью последнего сообщения в списке личных диалогов.
## Что проверять
- Если пользователь отправляет обычный текст, начинающийся с `<SHiNE:`, он уходит как `< SHiNE:` и отображается как обычный текст.
- DM с `<SHiNE:call...>` показывается в чате как специальное call-сообщение, а не как сырой техтекст.
- В списке личных сообщений превью такого сообщения показывает человекочитаемый итог звонка.
- DM с `<SHiNE:reply...>Текст ответа` скрывает техблок и показывает только `Текст ответа`.
- Если reply-цель отсутствует, сообщение всё равно показывается как обычный текст ответа.
- Текст вида `<script>alert(1)</script>` или похожие HTML-теги не исполняются ни в чате, ни в списке личных сообщений, ни в списке каналов.
## Ожидаемый результат
Технические SHiNE-вставки работают только как управляющие метаданные UI, а отображаемый пользователю текст и превью остаются безопасными и не рендерят HTML.
## Статус
pending
+1
View File
@@ -6,6 +6,7 @@
- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers`
- `Dev_Docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM`
- `Dev_Docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<SHiNE:...>` вставок внутри plaintext DM после расшифровки
Исторический устаревший документ сохранён отдельно:
@@ -22,6 +22,10 @@
- `Dev_Docs/Personal_Messages/Формат_DM_v1.md`
Формат клиентских технических вставок внутри plaintext вынесен отдельно:
- `Dev_Docs/Personal_Messages/Технические_вставки_DM_v1.md`
Устаревшая предыдущая версия сохранена отдельно:
- `Dev_Docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md`
@@ -92,6 +96,9 @@
- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`;
- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`.
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<SHiNE:...>` в начале текста.
Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope.
### 2.4. Источник истины по пользователю
Для DM-проверки сервер использует:
@@ -0,0 +1,145 @@
# Технические вставки внутри plaintext DM v1
## Статус документа
Этот файл описывает внутренний формат технических вставок, которые находятся внутри уже расшифрованного plaintext DM.
Важно:
- это не отдельный серверный DM-envelope;
- это часть plaintext контентного DM после E2EE-расшифровки;
- сервер не обязан знать этот формат;
- клиент может использовать эти вставки для специального UI-рендера.
## 1. Общий принцип
Обычное сообщение по-прежнему остаётся обычным текстом.
Если в начале plaintext стоит один или несколько специальных блоков формата:
```text
<SHiNE:...>
```
то клиент трактует их как технические вставки.
Техническими считаются только блоки, которые:
- стоят строго в начале plaintext;
- начинаются с точного префикса `<SHiNE:`;
- заканчиваются первым символом `>`.
Если текст не начинается с `<SHiNE:`, никакие технические вставки не ищутся.
## 2. Правило скрытия
Все корректно распознанные блоки `<SHiNE:...>` в начале plaintext:
- не показываются пользователю как сырой текст;
- используются клиентом для UI-логики;
- неизвестные будущие типы тоже скрываются, если они распознаны как корректный SHiNE-блок.
Если блок битый и не закрыт символом `>`, он не считается техническим и сообщение показывается как обычный текст целиком.
## 3. Защита от случайного пользовательского ввода
Перед отправкой обычного текстового сообщения клиент обязан проверить:
- если пользовательский текст начинается с `<SHiNE:`
то клиент автоматически превращает начало в:
```text
< SHiNE:
```
Такой текст уже не считается техническим блоком и должен отображаться как обычное сообщение.
## 4. Формат блока
Общий вид:
```text
<SHiNE:kind;key=value;key=value;...>
```
Правила:
- `kind` — ASCII-идентификатор типа вставки;
- параметры отделяются `;`;
- ключ и значение отделяются `=`;
- значения не экранируются в v1;
- формат чувствителен к точному префиксу `<SHiNE:`.
## 5. Тип `reply`
Формат:
```text
<SHiNE:reply;v=1;id=user1|user2|1720612345678|77>Текст ответа
```
Где поле `id` — это логический идентификатор сообщения:
```text
fromLogin|toLogin|timeMs|nonce
```
Правила:
- этот блок должен стоять в начале plaintext;
- после него может идти обычный текст ответа;
- если клиент не находит сообщение, на которое ссылается `reply`, он просто не показывает reply-preview;
- в таком случае само сообщение отображается как обычный текст без блока `<SHiNE:reply...>`.
## 6. Тип `call`
### Успешный звонок
```text
<SHiNE:call;v=1;status=completed;duration=367>
```
Где:
- `duration` — длительность разговора в секундах.
### Неуспешный звонок
```text
<SHiNE:call;v=1;status=failed;reason=offline>
```
Допустимые причины в v1:
- `offline`
- `no_answer`
- `connect_failed`
- `busy`
- `declined`
Правила:
- call-блок в v1 должен содержать только техническую запись звонка;
- пользовательский текст после такого блока в штатной логике не предполагается;
- UI может рисовать такие сообщения отдельным специальным стилем.
## 7. Поведение официального UI
Официальный UI SHiNE в v1:
- скрывает все корректные `<SHiNE:...>` блоки в начале plaintext;
- для `call` строит специальный человекочитаемый текст:
- `Звонок: M:SS`
- `Звонок: H:MM:SS`
- `Звонил, но недозвонился: ...`
- для `reply` скрывает сам блок и показывает только текст ответа;
- если исходное reply-сообщение не найдено, reply-preview не показывается.
## 8. Совместимость
Так как это часть plaintext, а не часть серверного envelope:
- сервер не обязан понимать этот формат;
- будущие клиенты могут добавлять новые `kind`;
- клиенты, которые распознают SHiNE-вставки, должны скрывать неизвестные блоки целиком, если они стоят в начале и корректно закрыты.
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.2.307
server.version=1.2.286
client.version=1.2.308
server.version=1.2.287
+4 -1
View File
@@ -15,6 +15,7 @@ import {
startDebugConnectionAsInitiator,
startDebugConnectionAsResponder,
} from './services/call-service.js';
import { parseDmTechBlocks } from './services/dm-tech-blocks.js';
import {
authService,
addAppLogEntry,
@@ -1154,11 +1155,13 @@ async function init() {
shouldRefreshToolbarUnread = true;
}
if (added && isIncomingForCurrent && !payload.backlog) {
const parsedText = parseDmTechBlocks(text);
const displayText = String(parsedText.displayText || '');
if (document.visibilityState === 'visible') {
void playDmSignal({ extended: false });
} else if (Notification.permission === 'granted') {
try {
void notifyHiddenIncomingMessage(fromLogin, text || '');
void notifyHiddenIncomingMessage(fromLogin, displayText || '');
} catch {}
} else {
void playDmSignal({ extended: true });
+8 -4
View File
@@ -20,6 +20,7 @@ import { startOutgoingCall } from '../services/call-service.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js';
import { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js';
import { parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
@@ -483,12 +484,14 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
}
const bubble = document.createElement('div');
const bubbleKind = String(msg?.kind || '').trim();
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
const autoKind = parsedText.callSummary ? 'call-tech' : '';
const bubbleKind = String(msg?.kind || autoKind || '').trim();
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
const textNode = document.createElement('div');
textNode.className = 'bubble-text';
textNode.textContent = msg.text || '';
textNode.textContent = parsedText.displayText || '';
bubble.append(textNode);
const metaNode = document.createElement('div');
@@ -582,7 +585,8 @@ export function render({ navigate, route }) {
showTtsMissingConfigDialog(navigate);
return;
}
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings);
};
const handleStartCall = async () => {
@@ -888,7 +892,7 @@ export function render({ navigate, route }) {
};
const sendTextMessage = async (rawText) => {
const text = String(rawText || '').trim();
const text = sanitizeUserDmTextForSend(String(rawText || '')).trim();
if (!text) return;
const editing = activeEdit;
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
+19 -4
View File
@@ -10,6 +10,7 @@ import {
import { loadCurrentRelations } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const dmAvatarSnapshotCache = new Map();
@@ -64,6 +65,12 @@ function createDmAvatar(login) {
return avatarEl;
}
function resolveLastMessagePreview(text = '') {
const parsed = parseDmTechBlocks(String(text || ''));
const display = String(parsed.displayText || '').trim();
return display || '';
}
function formatChatRowTime(ts) {
const value = Number(ts || 0);
if (!Number.isFinite(value) || value <= 0) return '';
@@ -88,12 +95,14 @@ export function render({ navigate }) {
<div class="dm-head-brand">
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
<div class="dm-head-id">
<span class="dm-head-name">${login}</span>
<span class="dm-head-name"></span>
</div>
</div>
<h1 class="dm-head-title">Контакты</h1>
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
`;
const headName = head.querySelector('.dm-head-name');
if (headName) headName.textContent = login;
head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view'));
const divider = document.createElement('div');
@@ -113,19 +122,25 @@ export function render({ navigate }) {
row.innerHTML = `
<div class="dm-row-main">
<div class="dm-row-titleline dm-row-titlewrap">
<strong class="dm-row-title">${item.name}</strong>
<strong class="dm-row-title"></strong>
${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''}
</div>
<p class="dm-row-last-message">${item.lastMessage}</p>
<p class="dm-row-last-message"></p>
</div>
<div class="dm-row-meta-col">
${item.unread ? `<span class="dm-unread-badge">${item.unread > 99 ? '99+' : item.unread}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
<div class="dm-row-meta-line">
${item.time ? `<span class="dm-row-time">${item.time}</span>` : '<span class="dm-row-time dm-row-time--empty"></span>'}
${item.time ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
<span class="dm-chevron">${SVG_CHEVRON}</span>
</div>
</div>
`;
const titleEl = row.querySelector('.dm-row-title');
const previewEl = row.querySelector('.dm-row-last-message');
const timeEl = row.querySelector('.dm-row-time');
if (titleEl) titleEl.textContent = String(item.name || '');
if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.';
if (timeEl) timeEl.textContent = String(item.time || '');
row.prepend(avatarWrap);
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(normalizeDmChatId(item.id))}`));
return row;
+10 -7
View File
@@ -1,4 +1,5 @@
import { addSignedMessageToChat, authService, authorizeSession, state } from '../state.js';
import { buildDmCallTechBlock } from './dm-tech-blocks.js';
const TYPES = {
INVITE: 100,
@@ -113,23 +114,25 @@ function isInviteUndelivered(call) {
function buildOutgoingCallSummaryText(call, summaryCode) {
if (!call || call.direction !== 'out') return '';
if (summaryCode === 'completed') {
const duration = formatDuration(nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs()));
return `Звонок: ${duration}`;
return buildDmCallTechBlock({
status: 'completed',
durationSec: Math.round(Math.max(0, nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs())) / 1000),
});
}
if (summaryCode === 'busy') {
return 'Звонил, но недозвонился: абонент занят';
return buildDmCallTechBlock({ status: 'failed', reason: 'busy' });
}
if (summaryCode === 'declined') {
return 'Звонил, но недозвонился: звонок отклонён';
return buildDmCallTechBlock({ status: 'failed', reason: 'declined' });
}
if (summaryCode === 'no_answer') {
if (isInviteUndelivered(call)) {
return 'Звонил, но недозвонился: абонент не в сети';
return buildDmCallTechBlock({ status: 'failed', reason: 'offline' });
}
return 'Звонил, но недозвонился: нет ответа';
return buildDmCallTechBlock({ status: 'failed', reason: 'no_answer' });
}
if (summaryCode === 'error') {
return 'Звонил, но недозвонился: не удалось установить соединение';
return buildDmCallTechBlock({ status: 'failed', reason: 'connect_failed' });
}
return '';
}
+136
View File
@@ -0,0 +1,136 @@
function defaultParsed(rawText = '') {
const text = String(rawText || '');
return {
rawText: text,
visibleText: text,
displayText: text,
blocks: [],
replyRef: null,
callSummary: null,
};
}
function parseReplyId(value = '') {
const parts = String(value || '').split('|');
if (parts.length !== 4) return null;
const fromLogin = String(parts[0] || '').trim();
const toLogin = String(parts[1] || '').trim();
const timeMs = Number(parts[2] || 0);
const nonce = Number(parts[3] || 0);
if (!fromLogin || !toLogin || !Number.isFinite(timeMs) || timeMs <= 0 || !Number.isFinite(nonce) || nonce < 0) {
return null;
}
return {
fromLogin,
toLogin,
timeMs,
nonce,
baseKey: `${fromLogin}|${toLogin}|${timeMs}|${nonce}`,
};
}
function formatCallDuration(totalSeconds) {
const total = Math.max(0, Math.floor(Number(totalSeconds || 0)));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function buildCallDisplayText(callSummary) {
if (!callSummary) return '';
if (callSummary.status === 'completed') {
return `Звонок: ${formatCallDuration(callSummary.durationSec)}`;
}
const reason = String(callSummary.reason || '').trim().toLowerCase();
if (reason === 'offline') return 'Звонил, но недозвонился: абонент не в сети';
if (reason === 'no_answer') return 'Звонил, но недозвонился: нет ответа';
if (reason === 'connect_failed') return 'Звонил, но недозвонился: не удалось установить соединение';
if (reason === 'busy') return 'Звонил, но недозвонился: абонент занят';
if (reason === 'declined') return 'Звонил, но недозвонился: звонок отклонён';
return 'Звонил, но недозвонился';
}
export function sanitizeUserDmTextForSend(rawText = '') {
const text = String(rawText || '');
return text.startsWith('<SHiNE:') ? `< SHiNE:${text.slice(7)}` : 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)))}>`;
}
const cleanReason = String(reason || '').trim().toLowerCase();
return `<SHiNE:call;v=1;status=failed;reason=${cleanReason || 'connect_failed'}>`;
}
export function parseDmTechBlocks(rawText = '') {
const text = String(rawText || '');
if (!text.startsWith('<SHiNE:')) return defaultParsed(text);
const blocks = [];
let cursor = 0;
let replyRef = null;
let callSummary = null;
while (text.startsWith('<SHiNE:', cursor)) {
const end = text.indexOf('>', cursor);
if (end < 0) {
if (!blocks.length) return defaultParsed(text);
break;
}
const inner = text.slice(cursor + 7, end);
const segments = inner.split(';').map((part) => String(part || '').trim()).filter(Boolean);
if (!segments.length) {
if (!blocks.length) return defaultParsed(text);
break;
}
const kind = String(segments[0] || '').trim().toLowerCase();
const fields = {};
for (let i = 1; i < segments.length; i += 1) {
const part = segments[i];
const eq = part.indexOf('=');
if (eq <= 0) continue;
const key = part.slice(0, eq).trim().toLowerCase();
const value = part.slice(eq + 1).trim();
if (key) fields[key] = value;
}
const block = { kind, version: Number(fields.v || 0), fields };
blocks.push(block);
if (kind === 'reply' && !replyRef) {
replyRef = parseReplyId(fields.id || '');
} else if (kind === 'call' && !callSummary) {
const status = String(fields.status || '').trim().toLowerCase();
if (status === 'completed') {
const durationSec = Math.max(0, Math.floor(Number(fields.duration || 0)));
callSummary = { status: 'completed', durationSec };
} else {
callSummary = {
status: 'failed',
reason: String(fields.reason || '').trim().toLowerCase(),
};
}
}
cursor = end + 1;
}
const visibleText = text.slice(cursor);
const displayText = callSummary ? buildCallDisplayText(callSummary) : visibleText;
return {
rawText: text,
visibleText,
displayText,
blocks,
replyRef,
callSummary,
};
}