From 0d2d7b6dc535dcc7c6aa56c100ffa62b1450bae8b1d4a906cc0effa64e8d8ef2 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 10 Jul 2026 18:57:44 +0400 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20SHiNE=20=D1=82=D0=B5=D0=B3=D0=B8=20=D0=B2=20DM=20?= =?UTF-8?q?=D0=B8=20=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-10_1845_dm_shine_tags_and_safe_preview.md | 26 ++++ Dev_Docs/Personal_Messages/README.md | 1 + Dev_Docs/Personal_Messages/Протокол_DM_v1.md | 7 + .../Технические_вставки_DM_v1.md | 145 ++++++++++++++++++ VERSION.properties | 4 +- shine-UI/js/app.js | 5 +- shine-UI/js/pages/chat-view.js | 12 +- shine-UI/js/pages/messages-list.js | 23 ++- shine-UI/js/services/call-service.js | 17 +- shine-UI/js/services/dm-tech-blocks.js | 136 ++++++++++++++++ 10 files changed, 358 insertions(+), 18 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-07-10_1845_dm_shine_tags_and_safe_preview.md create mode 100644 Dev_Docs/Personal_Messages/Технические_вставки_DM_v1.md create mode 100644 shine-UI/js/services/dm-tech-blocks.js diff --git a/Dev_Docs/Pending_Features/2026-07-10_1845_dm_shine_tags_and_safe_preview.md b/Dev_Docs/Pending_Features/2026-07-10_1845_dm_shine_tags_and_safe_preview.md new file mode 100644 index 0000000..d7716e8 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-07-10_1845_dm_shine_tags_and_safe_preview.md @@ -0,0 +1,26 @@ +## Краткое описание + +В DM добавлен клиентский формат технических вставок в начале plaintext: + +- `` +- `` + +UI скрывает такие вставки из текста сообщения, а call-вставки отображает специальным человекочитаемым видом. +Также добавлена защита от пользовательского текста, начинающегося с `` показывается в чате как специальное call-сообщение, а не как сырой техтекст. +- В списке личных сообщений превью такого сообщения показывает человекочитаемый итог звонка. +- DM с `Текст ответа` скрывает техблок и показывает только `Текст ответа`. +- Если reply-цель отсутствует, сообщение всё равно показывается как обычный текст ответа. +- Текст вида `` или похожие HTML-теги не исполняются ни в чате, ни в списке личных сообщений, ни в списке каналов. + +## Ожидаемый результат + +Технические SHiNE-вставки работают только как управляющие метаданные UI, а отображаемый пользователю текст и превью остаются безопасными и не рендерят HTML. + +## Статус + +pending diff --git a/Dev_Docs/Personal_Messages/README.md b/Dev_Docs/Personal_Messages/README.md index a84466a..587d94f 100644 --- a/Dev_Docs/Personal_Messages/README.md +++ b/Dev_Docs/Personal_Messages/README.md @@ -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` — формат специальных `` вставок внутри plaintext DM после расшифровки Исторический устаревший документ сохранён отдельно: diff --git a/Dev_Docs/Personal_Messages/Протокол_DM_v1.md b/Dev_Docs/Personal_Messages/Протокол_DM_v1.md index 09a5053..5466c95 100644 --- a/Dev_Docs/Personal_Messages/Протокол_DM_v1.md +++ b/Dev_Docs/Personal_Messages/Протокол_DM_v1.md @@ -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 может дополнительно содержать специальные клиентские вставки `` в начале текста. +Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope. + ### 2.4. Источник истины по пользователю Для DM-проверки сервер использует: diff --git a/Dev_Docs/Personal_Messages/Технические_вставки_DM_v1.md b/Dev_Docs/Personal_Messages/Технические_вставки_DM_v1.md new file mode 100644 index 0000000..1ba0fc6 --- /dev/null +++ b/Dev_Docs/Personal_Messages/Технические_вставки_DM_v1.md @@ -0,0 +1,145 @@ +# Технические вставки внутри plaintext DM v1 + +## Статус документа + +Этот файл описывает внутренний формат технических вставок, которые находятся внутри уже расшифрованного plaintext DM. + +Важно: + +- это не отдельный серверный DM-envelope; +- это часть plaintext контентного DM после E2EE-расшифровки; +- сервер не обязан знать этот формат; +- клиент может использовать эти вставки для специального UI-рендера. + +## 1. Общий принцип + +Обычное сообщение по-прежнему остаётся обычным текстом. + +Если в начале plaintext стоит один или несколько специальных блоков формата: + +```text + +``` + +то клиент трактует их как технические вставки. + +Техническими считаются только блоки, которые: + +- стоят строго в начале plaintext; +- начинаются с точного префикса ``. + +Если текст не начинается с `` в начале plaintext: + +- не показываются пользователю как сырой текст; +- используются клиентом для UI-логики; +- неизвестные будущие типы тоже скрываются, если они распознаны как корректный SHiNE-блок. + +Если блок битый и не закрыт символом `>`, он не считается техническим и сообщение показывается как обычный текст целиком. + +## 3. Защита от случайного пользовательского ввода + +Перед отправкой обычного текстового сообщения клиент обязан проверить: + +- если пользовательский текст начинается с ` +``` + +Правила: + +- `kind` — ASCII-идентификатор типа вставки; +- параметры отделяются `;`; +- ключ и значение отделяются `=`; +- значения не экранируются в v1; +- формат чувствителен к точному префиксу `Текст ответа +``` + +Где поле `id` — это логический идентификатор сообщения: + +```text +fromLogin|toLogin|timeMs|nonce +``` + +Правила: + +- этот блок должен стоять в начале plaintext; +- после него может идти обычный текст ответа; +- если клиент не находит сообщение, на которое ссылается `reply`, он просто не показывает reply-preview; +- в таком случае само сообщение отображается как обычный текст без блока ``. + +## 6. Тип `call` + +### Успешный звонок + +```text + +``` + +Где: + +- `duration` — длительность разговора в секундах. + +### Неуспешный звонок + +```text + +``` + +Допустимые причины в v1: + +- `offline` +- `no_answer` +- `connect_failed` +- `busy` +- `declined` + +Правила: + +- call-блок в v1 должен содержать только техническую запись звонка; +- пользовательский текст после такого блока в штатной логике не предполагается; +- UI может рисовать такие сообщения отдельным специальным стилем. + +## 7. Поведение официального UI + +Официальный UI SHiNE в v1: + +- скрывает все корректные `` блоки в начале plaintext; +- для `call` строит специальный человекочитаемый текст: + - `Звонок: M:SS` + - `Звонок: H:MM:SS` + - `Звонил, но недозвонился: ...` +- для `reply` скрывает сам блок и показывает только текст ответа; +- если исходное reply-сообщение не найдено, reply-preview не показывается. + +## 8. Совместимость + +Так как это часть plaintext, а не часть серверного envelope: + +- сервер не обязан понимать этот формат; +- будущие клиенты могут добавлять новые `kind`; +- клиенты, которые распознают SHiNE-вставки, должны скрывать неизвестные блоки целиком, если они стоят в начале и корректно закрыты. diff --git a/VERSION.properties b/VERSION.properties index 9efdb21..29f7837 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.307 -server.version=1.2.286 +client.version=1.2.308 +server.version=1.2.287 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index b3ea760..e15ba3c 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -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 }); diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 524ec3e..08460ce 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -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); diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index d29bf9f..790c5dd 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -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 }) {
${(login[0] || 'A').toUpperCase()}
- ${login} +

Контакты

`; + 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 = `
- ${item.name} + ${item.notInContacts ? 'не в контактах' : ''}
-

${item.lastMessage}

+

${item.unread ? `${item.unread > 99 ? '99+' : item.unread}` : ''}
- ${item.time ? `${item.time}` : ''} + ${item.time ? '' : ''} ${SVG_CHEVRON}
`; + 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; diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index 96a5d97..6ae96f5 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -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 ''; } diff --git a/shine-UI/js/services/dm-tech-blocks.js b/shine-UI/js/services/dm-tech-blocks.js new file mode 100644 index 0000000..575e097 --- /dev/null +++ b/shine-UI/js/services/dm-tech-blocks.js @@ -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('`; + } + const cleanReason = String(reason || '').trim().toLowerCase(); + return ``; +} + +export function parseDmTechBlocks(rawText = '') { + const text = String(rawText || ''); + if (!text.startsWith('', 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, + }; +}