From 01b38952e55f77e2eaa8d032de1d188aa90ea9fd8444c9e2835b038d7000458d Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 14 May 2026 17:35:54 +0300 Subject: [PATCH] =?UTF-8?q?UI:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20MCP?= =?UTF-8?q?-=D0=B4=D0=BE=D0=BA=20=D0=BF=D0=BE=20=D1=87=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8E/=D0=B4=D0=BE=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ие_и_дозапись_персонального_публичного_чата.md | 162 ++++++++++++++++++ ...ональный-чат-формат-блока-и-обратный-канал.md | 35 ++++ VERSION.properties | 4 +- shine-UI/js/pages/channel-view.js | 109 ++++++++++-- shine-UI/js/services/auth-service.js | 2 +- 5 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md create mode 100644 Dev_Docs/Pending_Features/2026-05-14_1945_персональный-чат-формат-блока-и-обратный-канал.md diff --git a/Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md b/Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md new file mode 100644 index 0000000..a313280 --- /dev/null +++ b/Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md @@ -0,0 +1,162 @@ +# MCP: чтение и дозапись персонального публичного чата (type=100) + +Документ для реализации MCP-инструмента, который: +- читает переписку между двумя логинами (`from`, `to`); +- добавляет новое сообщение от отправителя через серверный `AddBlock`. + +Важно: речь про **персональные публичные** каналы (`channelTypeCode=100`), а не приватные DM. + +## 1. Базовые предпосылки + +1. У каждого пользователя свой блокчейн (`-001`). +2. Персональный публичный чат хранится как канал типа `100`: + - у `A` канал с `channelName = B`; + - у `B` зеркальный канал с `channelName = A`. +3. Сообщения канала — `TEXT_POST` в линии `line_code = rootBlockNumber` канала. +4. Запись блока возможна только при валидной подписи blockchain-ключом владельца цепочки. + +## 2. Что должен уметь MCP-инструмент + +Минимальный набор операций: + +1. `read_personal_public_dialog(fromLogin, toLogin, limitPerSide=200)` +2. `append_personal_public_message(fromLogin, toLogin, text)` + +## 3. Алгоритм чтения переписки + +### 3.1 Найти оба канала (прямой и зеркальный) + +Для `fromLogin = A`, `toLogin = B`: + +1. Запросить `ListSubscriptionsFeed` для `A` и найти owned-канал: + - `ownerLogin == A` + - `channelName == B` + - `channelTypeCode == 100` +2. Запросить `ListSubscriptionsFeed` для `B` и найти owned-канал: + - `ownerLogin == B` + - `channelName == A` + - `channelTypeCode == 100` + +Если какой-то из каналов не найден — вернуть частичный результат + флаг отсутствия зеркала. + +### 3.2 Вычитать сообщения из каналов + +Для каждого найденного канала вызвать `GetChannelMessages`: + +```json +{ + "op": "GetChannelMessages", + "payload": { + "login": "<текущий-login-сессии>", + "channel": { + "ownerBlockchainName": "...", + "channelRootBlockNumber": 123, + "channelRootBlockHash": "..." + }, + "limit": 200, + "sort": "asc" + } +} +``` + +### 3.3 Склеить в единый диалог + +1. Объединить массивы сообщений из `A->B` и `B->A`. +2. Отсортировать по `createdAtMs`, при равенстве — по `messageRef.blockNumber`. +3. Вернуть структуру: + - `messages[]` + - `directChannelFound` / `reverseChannelFound` + - метаданные обоих каналов. + +## 4. Алгоритм дозаписи сообщения + +Цель: добавить сообщение **от имени `fromLogin`** в его канал `fromLogin -> toLogin`. + +### 4.1 Найти канал отправителя + +Через `ListSubscriptionsFeed(fromLogin)` найти owned-канал: +- `channelName == toLogin` +- `channelTypeCode == 100` + +Если канал не найден — вернуть ошибку `channel_not_found`. + +### 4.2 Отправить `AddBlock` с `TEXT_POST` + +Использовать клиентский/серверный helper формирования `TEXT_POST` body: +- `lineCode = channelRootBlockNumber`; +- `prevLineNumber/prevLineHash` берутся из последнего сообщения линии; +- подпись — blockchain private key пользователя `fromLogin`. + +Если у вас в MCP нет приватного ключа пользователя, дозапись невозможна. + +## 5. Контракт MCP (рекомендуемый) + +## `read_personal_public_dialog` + +Вход: +```json +{ + "fromLogin": "alice", + "toLogin": "bob", + "limitPerSide": 200 +} +``` + +Выход: +```json +{ + "ok": true, + "directChannelFound": true, + "reverseChannelFound": true, + "messages": [ + { + "authorLogin": "alice", + "text": "Привет", + "createdAtMs": 1760000000000, + "channelSide": "alice->bob", + "messageRef": { "blockNumber": 11, "blockHash": "..." } + } + ] +} +``` + +## `append_personal_public_message` + +Вход: +```json +{ + "fromLogin": "alice", + "toLogin": "bob", + "text": "Тест" +} +``` + +Выход: +```json +{ + "ok": true, + "serverLastGlobalNumber": 321, + "serverLastGlobalHash": "..." +} +``` + +## 6. Ограничения и безопасность + +1. Персональный канал типа `100` сейчас публичный по модели чтения (не E2E DM). +2. Нельзя дозаписать блок в чужой блокчейн без приватного ключа владельца (проверка подписи сервером). +3. Для прод-инструмента нужно: + - строгая авторизация MCP-вызовов; + - аудит, кто и от чьего имени запрашивал чтение/запись; + - лимиты/квоты на запись. + +## 7. Мини-чеклист для реализации MCP + +1. Реализовать helper поиска канала `findOwnedPersonalChannel(ownerLogin, peerLogin)`. +2. Реализовать чтение двух сторон и merge/sort. +3. Реализовать отправку `TEXT_POST` в найденный канал отправителя. +4. Добавить понятные ошибки: + - `user_not_found` + - `channel_not_found` + - `reverse_channel_not_found` + - `signature_required` + - `add_block_failed` diff --git a/Dev_Docs/Pending_Features/2026-05-14_1945_персональный-чат-формат-блока-и-обратный-канал.md b/Dev_Docs/Pending_Features/2026-05-14_1945_персональный-чат-формат-блока-и-обратный-канал.md new file mode 100644 index 0000000..d19c392 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-14_1945_персональный-чат-формат-блока-и-обратный-канал.md @@ -0,0 +1,35 @@ +# Персональный публичный чат: исправление формата блока и обратный канал + +- краткое описание фичи: + - Исправлен формат отправки `CreateChannel` из UI: для создания канала теперь используется версия body `1`, совместимая с серверным парсером. + - Убрана ошибка `AddBlock: Некорректный формат блока (BAD_BLOCK_FORMAT)` при создании персонального публичного чата (тип `100`) на актуальном сервере. + - В `channel-view` для персонального чата добавлена клиентская склейка диалога: + - основной канал `A -> B` (владелец `A`, имя канала `B`, тип `100`); + - зеркальный канал `B -> A` (владелец `B`, имя канала `A`, тип `100`); + - сообщения обоих каналов показываются в одном диалоге, отсортированном по времени. + - Если зеркальный канал не найден, показывается уведомление в шапке канала о том, что у собеседника пока не создан ответный чат. + - Исправлена ошибка `Идентификатор канала не готов` при добавлении сообщения в ряде сценариев (например, «мои сторис»): отправка теперь использует фактически загруженный селектор канала, а не только параметры маршрута. + - Улучшен резолв канала при открытии из поиска/прямой ссылки: + - сначала попытка по `ownerBlockchainName + channelName`; + - fallback по `ownerLogin + channelName`; + - дополнительный fallback через `GetUser(owner)` с сопоставлением `blockchainName`. + Это снижает число ложных `Канал не найден` при открытии сторис/каналов других пользователей. + +- что именно проверять: + - Создать персональный публичный чат через UI (`Каналы -> Чаты -> Новый персональный публичный чат`) и убедиться, что ошибка `BAD_BLOCK_FORMAT` больше не появляется. + - Открыть созданный персональный чат `A -> B`, написать сообщение. + - С аккаунта `B` создать зеркальный чат `B -> A`, отправить ответ. + - Снова открыть чат у `A` и проверить, что в одном экране видны и исходящие, и входящие сообщения из зеркального канала. + - Проверить, что при отсутствии зеркального канала в шапке отображается предупреждение. + - Вкладка «Мои сторис»: открыть канал и отправить сообщение кнопкой «Добавить сообщение» — ошибка про неготовый идентификатор не должна появляться. + - Вкладка «Найти канал»: открыть чужой сторис/канал по формату `user/channel` и убедиться, что канал открывается (если реально существует и доступен в выдаче). + +- ожидаемый результат: + - Персональный публичный чат создаётся без ошибки формата блока. + - При наличии зеркального канала переписка отображается единым диалогом. + - При отсутствии зеркального канала пользователь видит явное уведомление. + - В «мои сторис» сообщение добавляется без ошибки `Идентификатор канала не готов`. + - Открытие чужих каналов из поиска/ссылки работает стабильнее без ложного `Канал не найден`. + +- статус: + - pending diff --git a/VERSION.properties b/VERSION.properties index 4b7b44e..b137940 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.55 -server.version=1.2.49 +client.version=1.2.56 +server.version=1.2.50 diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 3259a54..3847521 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -19,6 +19,7 @@ import { import { openSpeechInputModal } from '../components/speech-input-modal.js'; export const pageMeta = { id: 'channel-view', title: 'Канал' }; +const CHANNEL_TYPE_PERSONAL = 100; const pendingReactionActions = new Set(); const pendingScrollByRoute = new Map(); @@ -408,18 +409,50 @@ function mapApiMessageToPost(message, selector, localNumber) { } async function loadFromApi(route, channelId) { + let cachedFeed = null; + const ensureFeed = async () => { + if (cachedFeed) return cachedFeed; + cachedFeed = await authService.listSubscriptionsFeed(state.session.login, 1000); + return cachedFeed; + }; + const getAllRows = async () => { + const feed = await ensureFeed(); + return [ + ...(Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []), + ...(Array.isArray(feed?.followedUsersChannels) ? feed.followedUsersChannels : []), + ...(Array.isArray(feed?.followedChannels) ? feed.followedChannels : []), + ]; + }; + let selector = buildSelectorFromRoute(route, channelId); if (selector?.ownerBlockchainName && selector?.channelName) { - const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000); - const allRows = [ - ...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []), - ...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []), - ...(Array.isArray(ownFeed?.followedChannels) ? ownFeed.followedChannels : []), - ]; - const channel = allRows.find((item) => ( - String(item?.channel?.ownerBlockchainName || '').trim() === selector.ownerBlockchainName + const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim(); + const routeOwnerNormalized = routeOwnerRaw.toLowerCase(); + const allRows = await getAllRows(); + let channel = allRows.find((item) => ( + String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() )); + if (!channel) { + channel = allRows.find((item) => ( + String(item?.channel?.ownerLogin || '').trim().toLowerCase() === routeOwnerNormalized + && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() + )); + } + if (!channel) { + try { + const ownerUser = await authService.getUser(routeOwnerRaw); + const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase(); + if (ownerBch) { + channel = allRows.find((item) => ( + String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch + && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() + )); + } + } catch { + // ignore fallback lookup failures + } + } if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) { throw new Error('Канал не найден.'); } @@ -437,8 +470,53 @@ async function loadFromApi(route, channelId) { const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login); const messages = Array.isArray(payload.messages) ? payload.messages : []; - const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1)); + let reverseChannelMissingWarning = ''; + let mergedMessages = [...messages]; + + const currentLogin = String(state.session.login || '').trim(); const ownerLogin = String(payload.channel?.ownerLogin || '').trim(); + const channelName = String(payload.channel?.channelName || '').trim(); + const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1); + const canResolveReverse = ( + channelTypeCode === CHANNEL_TYPE_PERSONAL + && !!currentLogin + && !!ownerLogin + && !!channelName + && ownerLogin.toLowerCase() === currentLogin.toLowerCase() + ); + + if (canResolveReverse) { + const allRows = await getAllRows(); + const reverseSummary = allRows.find((item) => ( + Number(item?.channel?.channelTypeCode ?? 1) === CHANNEL_TYPE_PERSONAL + && String(item?.channel?.ownerLogin || '').trim().toLowerCase() === channelName.toLowerCase() + && String(item?.channel?.channelName || '').trim().toLowerCase() === currentLogin.toLowerCase() + )); + + if (reverseSummary?.channel?.ownerBlockchainName && reverseSummary?.channel?.channelRoot?.blockNumber != null) { + const reverseSelector = { + ownerBlockchainName: String(reverseSummary.channel.ownerBlockchainName), + channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber), + channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash), + }; + const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', state.session.login); + const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : []; + mergedMessages = mergedMessages.concat(reverseMessages); + } else { + reverseChannelMissingWarning = `У собеседника ${channelName} пока не создан ответный персональный чат.`; + } + } + + const posts = mergedMessages + .map((message, index) => mapApiMessageToPost(message, selector, index + 1)) + .sort((a, b) => { + const byTime = Number(a?.timestampMs || 0) - Number(b?.timestampMs || 0); + if (byTime !== 0) return byTime; + const aNum = Number(a?.messageRef?.blockNumber || 0); + const bNum = Number(b?.messageRef?.blockNumber || 0); + return aNum - bNum; + }) + .map((post, index) => ({ ...post, localNumber: index + 1 })); const isOwnChannel = ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(); const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : []; const isSubscribed = followedRows.some((row) => ( @@ -455,6 +533,7 @@ async function loadFromApi(route, channelId) { ownerName: ownerLogin || 'неизвестно', }, posts, + reverseChannelMissingWarning, isOwnChannel, isSubscribed, selector, @@ -685,6 +764,12 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { head.append(title); head.append(owner, headActions); + if (channelData.reverseChannelMissingWarning) { + const reverseWarning = document.createElement('p'); + reverseWarning.className = 'channel-head-meta'; + reverseWarning.textContent = channelData.reverseChannelMissingWarning; + head.append(reverseWarning); + } const actionButton = document.createElement('button'); actionButton.className = channelData.isOwnChannel @@ -787,6 +872,7 @@ export function render({ navigate, route }) { const next = render({ navigate, route }); current.replaceWith(next); }; + let activeSelector = null; const requireSigningSession = () => { const login = state.session.login; @@ -860,14 +946,14 @@ export function render({ navigate, route }) { const onAddPost = async (bodyText) => { const { login, storagePwd } = requireSigningSession(); - if (!routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) { + if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) { throw new Error('Идентификатор канала не готов.'); } await authService.addBlockTextPost({ login, storagePwd, - channel: routeSelector, + channel: activeSelector, text: bodyText, }); @@ -892,6 +978,7 @@ export function render({ navigate, route }) { (async () => { try { const apiData = await loadFromApi(route, channelId); + activeSelector = apiData?.selector || null; skeleton.remove(); cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, { onToggleLike: async (messageRef, action) => { diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 9d0103c..5c95f3d 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -42,7 +42,7 @@ const MSG_SUBTYPE_REACTION_LIKE = 1; const MSG_SUBTYPE_REACTION_UNLIKE = 2; const MSG_SUBTYPE_CONNECTION_FOLLOW = 30; const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31; -const CREATE_CHANNEL_BODY_VERSION = 3; +const CREATE_CHANNEL_BODY_VERSION = 1; const CHANNEL_TYPE_STORIES = 0; const CHANNEL_TYPE_PUBLIC = 1; const CHANNEL_TYPE_PERSONAL = 100;