SHA256
Добавить SHiNE теги в DM и безопасный рендер превью
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user