UI: исправить каналы и добавить MCP-док по чтению/дозаписи

This commit is contained in:
AidarKC 2026-05-14 17:35:54 +03:00
parent 0fdb5b245c
commit 01b38952e5
5 changed files with 298 additions and 14 deletions

View File

@ -0,0 +1,162 @@
# MCP: чтение и дозапись персонального публичного чата (type=100)
Документ для реализации MCP-инструмента, который:
- читает переписку между двумя логинами (`from`, `to`);
- добавляет новое сообщение от отправителя через серверный `AddBlock`.
Важно: речь про **персональные публичные** каналы (`channelTypeCode=100`), а не приватные DM.
## 1. Базовые предпосылки
1. У каждого пользователя свой блокчейн (`<login>-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`

View File

@ -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

View File

@ -1,2 +1,2 @@
client.version=1.2.55 client.version=1.2.56
server.version=1.2.49 server.version=1.2.50

View File

@ -19,6 +19,7 @@ import {
import { openSpeechInputModal } from '../components/speech-input-modal.js'; import { openSpeechInputModal } from '../components/speech-input-modal.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' }; export const pageMeta = { id: 'channel-view', title: 'Канал' };
const CHANNEL_TYPE_PERSONAL = 100;
const pendingReactionActions = new Set(); const pendingReactionActions = new Set();
const pendingScrollByRoute = new Map(); const pendingScrollByRoute = new Map();
@ -408,18 +409,50 @@ function mapApiMessageToPost(message, selector, localNumber) {
} }
async function loadFromApi(route, channelId) { 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); let selector = buildSelectorFromRoute(route, channelId);
if (selector?.ownerBlockchainName && selector?.channelName) { if (selector?.ownerBlockchainName && selector?.channelName) {
const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000); const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
const allRows = [ const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []), const allRows = await getAllRows();
...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []), let channel = allRows.find((item) => (
...(Array.isArray(ownFeed?.followedChannels) ? ownFeed.followedChannels : []), String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
];
const channel = allRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim() === selector.ownerBlockchainName
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() && 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) { if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
throw new Error('Канал не найден.'); throw new Error('Канал не найден.');
} }
@ -437,8 +470,53 @@ async function loadFromApi(route, channelId) {
const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login); const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login);
const messages = Array.isArray(payload.messages) ? payload.messages : []; 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 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 isOwnChannel = ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase();
const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : []; const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : [];
const isSubscribed = followedRows.some((row) => ( const isSubscribed = followedRows.some((row) => (
@ -455,6 +533,7 @@ async function loadFromApi(route, channelId) {
ownerName: ownerLogin || 'неизвестно', ownerName: ownerLogin || 'неизвестно',
}, },
posts, posts,
reverseChannelMissingWarning,
isOwnChannel, isOwnChannel,
isSubscribed, isSubscribed,
selector, selector,
@ -685,6 +764,12 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
head.append(title); head.append(title);
head.append(owner, headActions); 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'); const actionButton = document.createElement('button');
actionButton.className = channelData.isOwnChannel actionButton.className = channelData.isOwnChannel
@ -787,6 +872,7 @@ export function render({ navigate, route }) {
const next = render({ navigate, route }); const next = render({ navigate, route });
current.replaceWith(next); current.replaceWith(next);
}; };
let activeSelector = null;
const requireSigningSession = () => { const requireSigningSession = () => {
const login = state.session.login; const login = state.session.login;
@ -860,14 +946,14 @@ export function render({ navigate, route }) {
const onAddPost = async (bodyText) => { const onAddPost = async (bodyText) => {
const { login, storagePwd } = requireSigningSession(); const { login, storagePwd } = requireSigningSession();
if (!routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) { if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
throw new Error('Идентификатор канала не готов.'); throw new Error('Идентификатор канала не готов.');
} }
await authService.addBlockTextPost({ await authService.addBlockTextPost({
login, login,
storagePwd, storagePwd,
channel: routeSelector, channel: activeSelector,
text: bodyText, text: bodyText,
}); });
@ -892,6 +978,7 @@ export function render({ navigate, route }) {
(async () => { (async () => {
try { try {
const apiData = await loadFromApi(route, channelId); const apiData = await loadFromApi(route, channelId);
activeSelector = apiData?.selector || null;
skeleton.remove(); skeleton.remove();
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, { cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
onToggleLike: async (messageRef, action) => { onToggleLike: async (messageRef, action) => {

View File

@ -42,7 +42,7 @@ const MSG_SUBTYPE_REACTION_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2; const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30; const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31; 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_STORIES = 0;
const CHANNEL_TYPE_PUBLIC = 1; const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_TYPE_PERSONAL = 100; const CHANNEL_TYPE_PERSONAL = 100;