diff --git a/shine-UI/firebase-messaging-sw.js b/shine-UI/firebase-messaging-sw.js
index ccec2c2..cc6f322 100644
--- a/shine-UI/firebase-messaging-sw.js
+++ b/shine-UI/firebase-messaging-sw.js
@@ -12,26 +12,42 @@ async function broadcastToClients(payload) {
}
self.addEventListener('push', (event) => {
- let body = 'Новое сообщение SHiNE';
+ let body = '';
let rawText = '';
+ let kind = '';
+ let fromLogin = '';
try {
if (event.data) {
const text = event.data.text();
rawText = text || '';
- body = rawText || body;
+ try {
+ const json = JSON.parse(rawText || '{}');
+ kind = String(json.kind || '');
+ body = String(json.text || '');
+ fromLogin = String(json.fromLogin || '');
+ } catch {
+ body = rawText || '';
+ }
}
} catch {
// ignore
}
- event.waitUntil(Promise.all([
- self.registration.showNotification('SHiNE: входящее сообщение', {
- body,
+ const shouldNotify = kind === 'new_message' || (!kind && body);
+ const notifyPromise = shouldNotify
+ ? self.registration.showNotification('SHiNE: входящее сообщение', {
+ body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'),
tag: 'shine-direct-message',
renotify: true,
- }),
+ })
+ : Promise.resolve();
+
+ event.waitUntil(Promise.all([
+ notifyPromise,
broadcastToClients({
+ kind,
body,
+ fromLogin,
rawText,
receivedAt: Date.now(),
}),
diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js
index 26c379b..076846a 100644
--- a/shine-UI/js/app.js
+++ b/shine-UI/js/app.js
@@ -7,13 +7,15 @@ import {
authService,
addAppLogEntry,
authorizeSession,
+ hydrateMessagesFromStore,
isSessionInvalidError,
refreshSessions,
setSessionAuthorizedHandler,
setSessionResetHandler,
state,
terminateCurrentSession,
- addIncomingMessage,
+ addSignedMessageToChat,
+ markOutgoingReadByBaseKey,
setContacts,
} from './state.js';
@@ -338,48 +340,94 @@ async function init() {
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
});
- authService.onEvent('IncomingDirectMessage', async (evt) => {
+ authService.onEvent('SignedMessageArrived', async (evt) => {
const payload = evt?.payload || {};
- const fromLogin = payload.fromLogin || 'unknown';
- const messageId = payload.messageId || '';
- const eventId = payload.eventId || evt?.requestId || '';
- let text = payload.text || '';
- if (!text && payload.blobB64) {
- try {
- const bytes = Uint8Array.from(atob(payload.blobB64), (ch) => ch.charCodeAt(0));
- const msgLen = (bytes[bytes.length - 66] << 8) | bytes[bytes.length - 65];
- const msgStart = bytes.length - 64 - msgLen;
- const msgBytes = bytes.slice(msgStart, msgStart + msgLen);
- text = new TextDecoder().decode(msgBytes);
- } catch {
- text = '[binary message]';
- }
- }
- const added = addIncomingMessage(fromLogin, text, messageId);
- if (added) {
+ const messageKey = String(payload.messageKey || '').trim();
+ const blobB64 = String(payload.blobB64 || '').trim();
+ if (!messageKey || !blobB64) return;
+
+ let parsed;
+ try {
+ parsed = authService.parseSignedMessageBlob(blobB64);
+ } catch (error) {
addAppLogEntry({
- level: 'info',
- source: 'incoming-dm',
- message: `Входящее сообщение от ${fromLogin}`,
- details: { messageId, text },
+ level: 'warn',
+ source: 'signed-dm',
+ message: 'Не удалось распарсить входящий signed message',
+ details: { messageKey, error: error?.message || 'unknown' },
});
+ return;
}
- if (added && Notification.permission === 'granted') {
- try {
- new Notification(`Сообщение от ${fromLogin}`, { body: text || '' });
- } catch {}
- }
- if (eventId) {
- try {
- await authService.ackIncomingMessage(eventId, messageId);
- } catch (error) {
+
+ const myLogin = String(state.session.login || '').trim().toLowerCase();
+ const fromLogin = parsed.fromLogin || '';
+ const toLogin = parsed.toLogin || '';
+ const chatId = String(fromLogin || '').toLowerCase() === myLogin ? toLogin : fromLogin;
+ const messageType = Number(parsed.messageType || 0);
+ const text = (messageType === 1 || messageType === 2)
+ ? new TextDecoder().decode(parsed.payloadBytes || new Uint8Array(0))
+ : '';
+
+ if (messageType === 1 || messageType === 2) {
+ const isIncomingForCurrent = messageType === 1;
+ const added = addSignedMessageToChat({
+ chatId,
+ messageKey,
+ baseKey: parsed.baseKey,
+ from: isIncomingForCurrent ? 'in' : 'out',
+ text,
+ messageType,
+ unread: isIncomingForCurrent,
+ rawBlobB64: blobB64,
+ });
+ if (added) {
addAppLogEntry({
- level: 'warn',
- source: 'incoming-dm',
- message: 'Не удалось отправить ACK на входящее сообщение',
- details: { eventId, messageId, error: error?.message || 'unknown' },
+ level: 'info',
+ source: 'signed-dm',
+ message: isIncomingForCurrent
+ ? `Новое входящее сообщение от ${fromLogin}`
+ : `Синхронизирована исходящая копия в чате ${chatId}`,
+ details: { messageKey, baseKey: parsed.baseKey, messageType },
});
}
+ if (added && isIncomingForCurrent && Notification.permission === 'granted' && !payload.backlog) {
+ try {
+ new Notification(`Сообщение от ${fromLogin}`, { body: text || '' });
+ } catch {}
+ }
+ } else if (messageType === 3 || messageType === 4) {
+ const refBaseKey = String(payload.receiptRefBaseKey || '').trim();
+ if (refBaseKey) {
+ markOutgoingReadByBaseKey(refBaseKey);
+ } else {
+ try {
+ const ref = authService.parseReadReceiptPayload(parsed.payloadBytes);
+ const fallbackRefBase = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`;
+ markOutgoingReadByBaseKey(fallbackRefBase);
+ } catch {}
+ }
+ addAppLogEntry({
+ level: 'info',
+ source: 'signed-dm',
+ message: 'Получено подтверждение прочтения',
+ details: { messageKey, baseKey: parsed.baseKey, messageType },
+ });
+ }
+
+ try {
+ await authService.ackSessionDelivery(messageKey);
+ } catch (error) {
+ addAppLogEntry({
+ level: 'warn',
+ source: 'signed-dm',
+ message: 'Не удалось отправить ACK доставки по сессии',
+ details: { messageKey, error: error?.message || 'unknown' },
+ });
+ }
+
+ const pageId = getRoute().pageId || '';
+ if (pageId === 'chat-view' || pageId === 'messages-list') {
+ renderApp();
}
});
@@ -392,6 +440,7 @@ async function init() {
});
await tryAutoLogin();
+ await hydrateMessagesFromStore();
await ensureSessionRuntimeStarted();
if (!window.location.hash) {
diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js
index 8f7e1a3..38f227b 100644
--- a/shine-UI/js/pages/chat-view.js
+++ b/shine-UI/js/pages/chat-view.js
@@ -1,20 +1,59 @@
import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
-import { addAppLogEntry, addChatMessage, getChatMessages, authService, state } from '../state.js';
+import {
+ addAppLogEntry,
+ addChatMessage,
+ addOutgoingPendingMessage,
+ getChatMessages,
+ markChatRead,
+ markOutgoingSent,
+ authService,
+ state,
+} from '../state.js';
import { startOutgoingCall, hangupActiveCall } from '../services/call-service.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
+function parseBaseKey(baseKey) {
+ const raw = String(baseKey || '').trim();
+ const parts = raw.split('|');
+ if (parts.length < 4) return null;
+ const fromLogin = parts[0] || '';
+ const toLogin = parts[1] || '';
+ const timeMs = Number(parts[2] || 0);
+ const nonce = Number(parts[3] || 0);
+ if (!fromLogin || !toLogin || !Number.isFinite(timeMs) || !Number.isFinite(nonce)) return null;
+ return { fromLogin, toLogin, timeMs, nonce };
+}
+
function renderLog(list, chatId) {
list.innerHTML = '';
const messages = getChatMessages(chatId);
+ let unreadSeparatorInserted = false;
messages.forEach((msg) => {
+ if (!unreadSeparatorInserted && msg?.from === 'in' && msg?.unread) {
+ const sep = document.createElement('div');
+ sep.className = 'meta-muted';
+ sep.style.textAlign = 'center';
+ sep.style.margin = '8px 0';
+ sep.textContent = 'Новые сообщения';
+ list.append(sep);
+ unreadSeparatorInserted = true;
+ }
+
const bubble = document.createElement('div');
bubble.className = `bubble ${msg.from}`;
- bubble.textContent = msg.text;
+ let text = msg.text || '';
+ if (msg.from === 'out') {
+ if (msg.secondTick) text += ' ✓✓';
+ else if (msg.firstTick) text += ' ✓';
+ else text += ' …';
+ }
+ bubble.textContent = text;
list.append(bubble);
});
list.scrollTop = list.scrollHeight;
+ markChatRead(chatId);
}
export function render({ navigate, route }) {
@@ -27,6 +66,7 @@ export function render({ navigate, route }) {
const screen = document.createElement('section');
screen.className = 'stack';
+ const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
screen.append(
renderHeader({
@@ -60,6 +100,37 @@ export function render({ navigate, route }) {
})
);
+ if (!isKnownContact) {
+ const card = document.createElement('div');
+ card.className = 'card';
+ const btn = document.createElement('button');
+ btn.className = 'secondary-btn';
+ btn.type = 'button';
+ btn.textContent = 'Добавить в контакты';
+ btn.addEventListener('click', async () => {
+ try {
+ await authService.addCloseFriend(chatId);
+ state.contacts = [...new Set([...(state.contacts || []), chatId])];
+ addAppLogEntry({
+ level: 'info',
+ source: 'contacts',
+ message: `Пользователь ${chatId} добавлен в контакты`,
+ });
+ btn.disabled = true;
+ btn.textContent = 'Добавлено';
+ } catch (e) {
+ addAppLogEntry({
+ level: 'warn',
+ source: 'contacts',
+ message: 'Не удалось добавить пользователя в контакты',
+ details: { login: chatId, error: e?.message || 'unknown' },
+ });
+ }
+ });
+ card.append(btn);
+ screen.append(card);
+ }
+
const wrap = document.createElement('div');
wrap.className = 'chat-wrap';
@@ -79,7 +150,7 @@ export function render({ navigate, route }) {
const text = input.value.trim();
if (!text) return;
- addChatMessage(chatId, text);
+ const tempId = addOutgoingPendingMessage(chatId, text);
input.value = '';
renderLog(log, chatId);
@@ -90,16 +161,20 @@ export function render({ navigate, route }) {
text,
storagePwd: state.session.storagePwdInMemory,
});
+ markOutgoingSent(tempId, {
+ messageKey: result?.outgoingKey || '',
+ baseKey: result?.baseKey || result?.localBaseKey || '',
+ });
+ renderLog(log, chatId);
addAppLogEntry({
level: 'info',
source: 'outgoing-dm',
message: `Сообщение отправлено для ${chatId}`,
details: {
toLogin: chatId,
- messageId: result?.messageId || '',
+ messageId: result?.outgoingKey || '',
deliveredWsSessions: Number(result?.deliveredWsSessions || 0),
deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0),
- sessionNotFound: Boolean(result?.sessionNotFound),
},
});
} catch (e) {
@@ -118,7 +193,37 @@ export function render({ navigate, route }) {
});
renderLog(log, chatId);
+ void sendReadReceiptsForVisible();
wrap.append(log, form);
screen.append(wrap);
return screen;
}
+ async function sendReadReceiptsForVisible() {
+ const pending = getChatMessages(chatId)
+ .filter((row) => row?.from === 'in' && Number(row?.messageType) === 1 && !row?.readReceiptSent)
+ .slice(0, 50);
+ for (const row of pending) {
+ const ref = parseBaseKey(row.baseKey);
+ if (!ref) continue;
+ try {
+ await authService.sendReadReceipt({
+ login: state.session.login,
+ toLogin: ref.fromLogin,
+ storagePwd: state.session.storagePwdInMemory,
+ refToLogin: ref.toLogin,
+ refFromLogin: ref.fromLogin,
+ refTimeMs: ref.timeMs,
+ refNonce: ref.nonce,
+ refType: 1,
+ });
+ row.readReceiptSent = true;
+ } catch (e) {
+ addAppLogEntry({
+ level: 'warn',
+ source: 'read-receipt',
+ message: 'Не удалось отправить подтверждение прочтения',
+ details: { chatId, messageKey: row.messageKey || '', error: e?.message || 'unknown' },
+ });
+ }
+ }
+ }
diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js
index 72cd2df..58e3e65 100644
--- a/shine-UI/js/pages/messages-list.js
+++ b/shine-UI/js/pages/messages-list.js
@@ -1,6 +1,6 @@
import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
-import { getChatMessages } from '../state.js';
+import { getChatMessages, setContacts, state } from '../state.js';
import { loadCurrentRelations } from '../services/user-connections.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
@@ -30,6 +30,7 @@ export function render({ navigate }) {
${item.name}
+ ${item.notInContacts ? 'не в контактах' : ''}
${item.lastMessage}
@@ -46,32 +47,57 @@ export function render({ navigate }) {
try {
const relations = await loadCurrentRelations();
const contacts = relations.outContacts || [];
+ setContacts(contacts);
list.innerHTML = '';
-
- if (!contacts.length) {
- const empty = document.createElement('div');
- empty.className = 'card meta-muted';
- empty.textContent = 'Ваш список контактов пока пуст';
- list.append(empty);
- status.className = 'status-line is-available';
- status.textContent = 'Нет контактов.';
- return;
- }
-
- const rows = contacts.map((login) => {
+ const contactRows = contacts.map((login) => {
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
const chat = getChatMessages(login);
const lastChat = chat[chat.length - 1];
+ const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
return {
id: login,
initials: (login[0] || '?').toUpperCase(),
name: preview?.name || login,
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
time: preview?.time || '—',
- unread: Number(preview?.unread || 0),
+ unread,
+ notInContacts: false,
};
});
+ const allChatIds = Object.keys(state.chats || {})
+ .filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase())
+ .filter((id) => (getChatMessages(id) || []).length > 0);
+
+ const contactKeys = new Set(contacts.map((x) => String(x || '').toLowerCase()));
+ const extraRows = allChatIds
+ .filter((login) => !contactKeys.has(String(login || '').toLowerCase()))
+ .map((login) => {
+ const chat = getChatMessages(login);
+ const lastChat = chat[chat.length - 1];
+ const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
+ return {
+ id: login,
+ initials: (login[0] || '?').toUpperCase(),
+ name: login,
+ lastMessage: lastChat?.text || 'Диалог пока пуст.',
+ time: 'сейчас',
+ unread,
+ notInContacts: true,
+ };
+ });
+
+ const rows = [...contactRows, ...extraRows];
+ if (!rows.length) {
+ const empty = document.createElement('div');
+ empty.className = 'card meta-muted';
+ empty.textContent = 'Пока нет ни контактов, ни сообщений';
+ list.append(empty);
+ status.className = 'status-line is-available';
+ status.textContent = 'Нет диалогов.';
+ return;
+ }
+
rows.forEach((item) => list.append(renderRow(item)));
status.className = 'status-line is-available';
status.textContent = `Загружено диалогов: ${rows.length}`;
diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js
index bccd3c5..48d4930 100644
--- a/shine-UI/js/services/auth-service.js
+++ b/shine-UI/js/services/auth-service.js
@@ -200,6 +200,114 @@ function uint8Bytes(value) {
return new Uint8Array([Number(value) & 0xff]);
}
+const DM2_PREFIX = utf8Bytes('SHiNE_dm2');
+const DM2_TYPE_INCOMING = 1;
+const DM2_TYPE_OUTGOING_COPY = 2;
+const DM2_TYPE_READ_INCOMING = 3;
+const DM2_TYPE_READ_OUTGOING_COPY = 4;
+
+function ensureAsciiBytes(value, field, min = 1, max = 60) {
+ const text = String(value || '').trim();
+ const bytes = utf8Bytes(text);
+ if (bytes.length < min || bytes.length > max) {
+ throw new Error(`${field} должен быть ${min}..${max} ASCII-символов`);
+ }
+ for (let i = 0; i < bytes.length; i += 1) {
+ const code = bytes[i];
+ if (code < 0x20 || code > 0x7e) throw new Error(`${field} должен быть ASCII`);
+ }
+ return bytes;
+}
+
+function dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }) {
+ return `${fromLogin}|${toLogin}|${Number(timeMs)}|${Number(nonce)}`;
+}
+
+function dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) {
+ return `${dm2BaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`;
+}
+
+function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType }) {
+ const toBytes = ensureAsciiBytes(refToLogin, 'receipt.refToLogin');
+ const fromBytes = ensureAsciiBytes(refFromLogin, 'receipt.refFromLogin');
+ return concatBytes(
+ uint8Bytes(toBytes.length), toBytes,
+ uint8Bytes(fromBytes.length), fromBytes,
+ uint64Bytes(refTimeMs),
+ uint32Bytes(refNonce),
+ uint16Bytes(refType),
+ );
+}
+
+function parseSignedMessageBlockBytes(bytes) {
+ if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
+ let o = 0;
+ const read = (n) => {
+ if (o + n > bytes.length) throw new Error('BAD_LEN');
+ const out = bytes.slice(o, o + n);
+ o += n;
+ return out;
+ };
+ const readU8 = () => read(1)[0];
+ const readU16 = () => {
+ const part = read(2);
+ const view = new DataView(part.buffer, part.byteOffset, 2);
+ return view.getUint16(0, false);
+ };
+ const readU32 = () => {
+ const part = read(4);
+ const view = new DataView(part.buffer, part.byteOffset, 4);
+ return view.getUint32(0, false);
+ };
+ const readU64 = () => {
+ const part = read(8);
+ const view = new DataView(part.buffer, part.byteOffset, 8);
+ return Number(view.getBigUint64(0, false));
+ };
+ const readAscii = () => {
+ const len = readU8();
+ const part = read(len);
+ const text = new TextDecoder().decode(part);
+ for (let i = 0; i < part.length; i += 1) {
+ const c = part[i];
+ if (c < 0x20 || c > 0x7e) throw new Error('BAD_ASCII');
+ }
+ return text;
+ };
+
+ const prefix = read(DM2_PREFIX.length);
+ for (let i = 0; i < DM2_PREFIX.length; i += 1) {
+ if (prefix[i] !== DM2_PREFIX[i]) throw new Error('BAD_PREFIX');
+ }
+
+ const toLogin = readAscii();
+ const fromLogin = readAscii();
+ const timeMs = readU64();
+ const nonce = readU32();
+ const messageType = readU16();
+ const payloadLen = readU16();
+ const payloadBytes = read(payloadLen);
+ const signatureBytes = read(64);
+ if (o !== bytes.length) throw new Error('BAD_LEN');
+
+ const signedBody = bytes.slice(0, bytes.length - 64);
+ const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce });
+ const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
+ return {
+ toLogin,
+ fromLogin,
+ timeMs,
+ nonce,
+ messageType,
+ payloadBytes,
+ signatureBytes,
+ signedBody,
+ rawBytes: bytes,
+ baseKey,
+ messageKey,
+ };
+}
+
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
const keyBytes = utf8Bytes(String(key || ''));
const valueBytes = utf8Bytes(String(value || ''));
@@ -1118,11 +1226,18 @@ export class AuthService {
return response.payload || {};
}
- async sendDirectMessage({ login, toLogin, text, storagePwd, targetSessionId = null, messageType = 1 }) {
+ async buildSignedDm2Block({
+ login,
+ toLogin,
+ storagePwd,
+ timeMs,
+ nonce,
+ messageType,
+ payloadBytes,
+ }) {
const cleanFromLogin = String(login || '').trim();
const cleanToLogin = String(toLogin || '').trim();
- const cleanText = String(text || '');
- if (!cleanFromLogin || !cleanToLogin || !cleanText) throw new Error('Не передан login/toLogin/text');
+ if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
@@ -1130,46 +1245,147 @@ export class AuthService {
if (!devicePriv) throw new Error('Не найден приватный deviceKey');
const privateKey = await importPkcs8Ed25519(devicePriv);
- const prefix = utf8Bytes('SHiNE_msg');
- const version = uint8Bytes(1);
- const toBytes = utf8Bytes(cleanToLogin);
- const fromBytes = utf8Bytes(cleanFromLogin);
- if (toBytes.length < 1 || toBytes.length > 30) throw new Error('toLogin должен быть 1..30 ASCII-символов');
- if (fromBytes.length < 1 || fromBytes.length > 30) throw new Error('fromLogin должен быть 1..30 ASCII-символов');
- if (cleanText.length > 3000) throw new Error('Слишком длинное сообщение');
-
- const mode = targetSessionId ? 1 : 0;
- const targetBytes = targetSessionId ? utf8Bytes(String(targetSessionId)) : new Uint8Array(0);
- if (mode === 1 && (targetBytes.length < 1 || targetBytes.length > 255)) {
- throw new Error('targetSessionId должен быть 1..255 символов');
+ const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
+ const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
+ if (!(payloadBytes instanceof Uint8Array) || payloadBytes.length < 1 || payloadBytes.length > 4096) {
+ throw new Error('payload должен быть 1..4096 байт');
}
- const bodyBytes = utf8Bytes(cleanText);
const preimage = concatBytes(
- prefix,
- version,
+ DM2_PREFIX,
uint8Bytes(toBytes.length), toBytes,
uint8Bytes(fromBytes.length), fromBytes,
- uint64Bytes(Date.now()),
- uint32Bytes(Math.floor(Math.random() * 0x100000000)),
+ uint64Bytes(timeMs),
+ uint32Bytes(nonce),
uint16Bytes(messageType),
- uint8Bytes(mode),
- mode === 1 ? concatBytes(uint8Bytes(targetBytes.length), targetBytes) : new Uint8Array(0),
- uint16Bytes(bodyBytes.length),
- bodyBytes,
+ uint16Bytes(payloadBytes.length),
+ payloadBytes,
);
const signature = await signBytes(privateKey, preimage);
- const packet = concatBytes(preimage, signature);
- const blobB64 = bytesToBase64(packet);
+ return concatBytes(preimage, signature);
+ }
- const response = await this.ws.request('SendDirectMessage', { blobB64 });
- if (response.status !== 200) throw opError('SendDirectMessage', response);
+ parseSignedMessageBlob(blobB64) {
+ const bytes = base64ToBytes(String(blobB64 || '').trim());
+ return parseSignedMessageBlockBytes(bytes);
+ }
+
+ parseReadReceiptPayload(payloadBytes) {
+ if (!(payloadBytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
+ let o = 0;
+ const read = (n) => {
+ if (o + n > payloadBytes.length) throw new Error('BAD_RECEIPT_LEN');
+ const out = payloadBytes.slice(o, o + n);
+ o += n;
+ return out;
+ };
+ const readU8 = () => read(1)[0];
+ const readU16 = () => {
+ const part = read(2);
+ return new DataView(part.buffer, part.byteOffset, 2).getUint16(0, false);
+ };
+ const readU32 = () => {
+ const part = read(4);
+ return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false);
+ };
+ const readU64 = () => {
+ const part = read(8);
+ return Number(new DataView(part.buffer, part.byteOffset, 8).getBigUint64(0, false));
+ };
+ const readAscii = () => {
+ const len = readU8();
+ const part = read(len);
+ return new TextDecoder().decode(part);
+ };
+ const refToLogin = readAscii();
+ const refFromLogin = readAscii();
+ const refTimeMs = readU64();
+ const refNonce = readU32();
+ const refType = readU16();
+ if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_LEN');
+ return { refToLogin, refFromLogin, refTimeMs, refNonce, refType };
+ }
+
+ async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) {
+ const response = await this.ws.request('SendMessagePair', { incomingBlobB64, outgoingBlobB64 });
+ if (response.status !== 200) throw opError('SendMessagePair', response);
return response.payload || {};
}
- async ackIncomingMessage(eventId, messageId) {
- const response = await this.ws.request('AckIncomingMessage', { eventId, messageId });
- if (response.status !== 200) throw opError('AckIncomingMessage', response);
+ async sendDirectMessage({ login, toLogin, text, storagePwd }) {
+ const cleanFromLogin = String(login || '').trim();
+ const cleanToLogin = String(toLogin || '').trim();
+ const cleanText = String(text || '');
+ if (!cleanFromLogin || !cleanToLogin || !cleanText) throw new Error('Не передан login/toLogin/text');
+
+ const timeMs = Date.now();
+ const nonce = Math.floor(Math.random() * 0x100000000);
+ const incomingPayload = utf8Bytes(cleanText);
+ const outgoingPayload = utf8Bytes(cleanText);
+
+ const incomingBlock = await this.buildSignedDm2Block({
+ login: cleanFromLogin,
+ toLogin: cleanToLogin,
+ storagePwd,
+ timeMs,
+ nonce,
+ messageType: DM2_TYPE_INCOMING,
+ payloadBytes: incomingPayload,
+ });
+ const outgoingBlock = await this.buildSignedDm2Block({
+ login: cleanFromLogin,
+ toLogin: cleanToLogin,
+ storagePwd,
+ timeMs,
+ nonce,
+ messageType: DM2_TYPE_OUTGOING_COPY,
+ payloadBytes: outgoingPayload,
+ });
+
+ const payload = await this.sendMessagePair({
+ incomingBlobB64: bytesToBase64(incomingBlock),
+ outgoingBlobB64: bytesToBase64(outgoingBlock),
+ });
+ return {
+ ...payload,
+ localIncomingBlobB64: bytesToBase64(incomingBlock),
+ localOutgoingBlobB64: bytesToBase64(outgoingBlock),
+ localBaseKey: dm2BaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs, nonce }),
+ };
+ }
+
+ async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce, refType = DM2_TYPE_INCOMING }) {
+ const timeMs = Date.now();
+ const nonce = Math.floor(Math.random() * 0x100000000);
+ const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType });
+
+ const type3 = await this.buildSignedDm2Block({
+ login,
+ toLogin,
+ storagePwd,
+ timeMs,
+ nonce,
+ messageType: DM2_TYPE_READ_INCOMING,
+ payloadBytes: payload,
+ });
+ const type4 = await this.buildSignedDm2Block({
+ login,
+ toLogin,
+ storagePwd,
+ timeMs,
+ nonce,
+ messageType: DM2_TYPE_READ_OUTGOING_COPY,
+ payloadBytes: payload,
+ });
+ return this.sendMessagePair({
+ incomingBlobB64: bytesToBase64(type3),
+ outgoingBlobB64: bytesToBase64(type4),
+ });
+ }
+
+ async ackSessionDelivery(messageKey) {
+ const response = await this.ws.request('AckSessionDelivery', { messageKey });
+ if (response.status !== 200) throw opError('AckSessionDelivery', response);
return response.payload || {};
}
diff --git a/shine-UI/js/services/message-store.js b/shine-UI/js/services/message-store.js
new file mode 100644
index 0000000..70e42ff
--- /dev/null
+++ b/shine-UI/js/services/message-store.js
@@ -0,0 +1,50 @@
+const DB_NAME = 'shine-ui-messages-v1';
+const DB_VERSION = 1;
+const STORE_MESSAGES = 'messages';
+
+function openDb() {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(STORE_MESSAGES)) {
+ const store = db.createObjectStore(STORE_MESSAGES, { keyPath: 'messageKey' });
+ store.createIndex('by_chat', 'chatId', { unique: false });
+ store.createIndex('by_ts', 'ts', { unique: false });
+ }
+ };
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error || new Error('IndexedDB open failed'));
+ });
+}
+
+async function withStore(mode, callback) {
+ const db = await openDb();
+ try {
+ return await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_MESSAGES, mode);
+ const store = tx.objectStore(STORE_MESSAGES);
+ const result = callback(store, tx);
+ tx.oncomplete = () => resolve(result);
+ tx.onerror = () => reject(tx.error || new Error('IndexedDB transaction failed'));
+ tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted'));
+ });
+ } finally {
+ db.close();
+ }
+}
+
+export async function putStoredMessage(record) {
+ if (!record || !record.messageKey) return;
+ await withStore('readwrite', (store) => {
+ store.put(record);
+ });
+}
+
+export async function listStoredMessages() {
+ return withStore('readonly', (store) => new Promise((resolve, reject) => {
+ const req = store.getAll();
+ req.onsuccess = () => resolve(Array.isArray(req.result) ? req.result : []);
+ req.onerror = () => reject(req.error || new Error('IndexedDB getAll failed'));
+ }));
+}
diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js
index e26ac67..f7301b8 100644
--- a/shine-UI/js/state.js
+++ b/shine-UI/js/state.js
@@ -1,6 +1,7 @@
import { chatMessages, wallet } from './mock-data.js';
import { AuthService } from './services/auth-service.js';
import { clearClientAuthData } from './services/key-vault.js';
+import { listStoredMessages, putStoredMessage } from './services/message-store.js';
const clone = (value) => JSON.parse(JSON.stringify(value));
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
@@ -130,6 +131,8 @@ function createInitialState({ withStoredSession = true } = {}) {
contacts: [],
appLog: [],
incomingDedup: {},
+ knownMessageKeys: {},
+ outgoingTempSeq: 1,
notificationsTab: 'replies',
pageLabelCollapsed: false,
session: {
@@ -199,6 +202,55 @@ export const authService = new AuthService(state.entrySettings.shineServer);
let onSessionReset = null;
let onSessionAuthorized = null;
+function persistMessageRecord(chatId, row) {
+ if (!chatId || !row?.messageKey) return;
+ void putStoredMessage({
+ messageKey: row.messageKey,
+ chatId,
+ from: row.from || 'in',
+ text: String(row.text || ''),
+ baseKey: String(row.baseKey || ''),
+ messageType: Number(row.messageType || 0),
+ rawBlobB64: String(row.rawBlobB64 || ''),
+ unread: Boolean(row.unread),
+ firstTick: Boolean(row.firstTick),
+ secondTick: Boolean(row.secondTick),
+ readReceiptSent: Boolean(row.readReceiptSent),
+ refBaseKey: String(row.refBaseKey || ''),
+ ts: Date.now(),
+ }).catch(() => {});
+}
+
+export async function hydrateMessagesFromStore() {
+ try {
+ const rows = await listStoredMessages();
+ rows
+ .sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0))
+ .forEach((row) => {
+ const chatId = String(row?.chatId || '').trim();
+ const messageKey = String(row?.messageKey || '').trim();
+ if (!chatId || !messageKey) return;
+ if (state.knownMessageKeys[messageKey]) return;
+ state.knownMessageKeys[messageKey] = true;
+ getChatMessages(chatId).push({
+ from: row.from === 'out' ? 'out' : 'in',
+ text: String(row.text || ''),
+ messageKey,
+ baseKey: String(row.baseKey || ''),
+ messageType: Number(row.messageType || 0),
+ rawBlobB64: String(row.rawBlobB64 || ''),
+ unread: Boolean(row.unread),
+ firstTick: Boolean(row.firstTick),
+ secondTick: Boolean(row.secondTick),
+ readReceiptSent: Boolean(row.readReceiptSent),
+ refBaseKey: String(row.refBaseKey || ''),
+ });
+ });
+ } catch {
+ // ignore broken storage
+ }
+}
+
export function getChatMessages(chatId) {
if (!state.chats[chatId]) {
state.chats[chatId] = [];
@@ -209,7 +261,7 @@ export function getChatMessages(chatId) {
export function addChatMessage(chatId, text) {
const message = text.trim();
if (!message) return;
- getChatMessages(chatId).push({ from: 'out', text: message });
+ getChatMessages(chatId).push({ from: 'out', text: message, firstTick: false, secondTick: false, unread: false });
}
@@ -218,10 +270,100 @@ export function addIncomingMessage(chatId, text, messageId = '') {
if (!msg) return false;
if (messageId && state.incomingDedup[messageId]) return false;
if (messageId) state.incomingDedup[messageId] = true;
- getChatMessages(chatId).push({ from: 'in', text: msg, messageId });
+ getChatMessages(chatId).push({ from: 'in', text: msg, messageId, unread: true });
return true;
}
+export function addOutgoingPendingMessage(chatId, text) {
+ const msg = String(text || '').trim();
+ if (!msg) return null;
+ const tempId = `tmp-${Date.now()}-${state.outgoingTempSeq++}`;
+ getChatMessages(chatId).push({
+ from: 'out',
+ text: msg,
+ tempId,
+ firstTick: false,
+ secondTick: false,
+ unread: false,
+ });
+ return tempId;
+}
+
+export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {}) {
+ if (!tempId) return;
+ const keys = Object.keys(state.chats || {});
+ keys.forEach((chatId) => {
+ const list = getChatMessages(chatId);
+ const row = list.find((item) => item?.tempId === tempId);
+ if (!row) return;
+ row.firstTick = true;
+ row.messageKey = messageKey || row.messageKey || '';
+ row.baseKey = baseKey || row.baseKey || '';
+ if (messageKey) {
+ state.knownMessageKeys[messageKey] = true;
+ persistMessageRecord(chatId, row);
+ }
+ });
+}
+
+export function markOutgoingReadByBaseKey(baseKey) {
+ if (!baseKey) return;
+ const keys = Object.keys(state.chats || {});
+ keys.forEach((chatId) => {
+ const list = getChatMessages(chatId);
+ list.forEach((row) => {
+ if (row?.from !== 'out') return;
+ if (row.baseKey === baseKey) {
+ row.secondTick = true;
+ persistMessageRecord(chatId, row);
+ }
+ });
+ });
+}
+
+export function addSignedMessageToChat({
+ chatId,
+ messageKey,
+ baseKey = '',
+ from = 'in',
+ text = '',
+ messageType = 1,
+ unread = false,
+ rawBlobB64 = '',
+ refBaseKey = '',
+} = {}) {
+ const id = String(messageKey || '').trim();
+ if (!chatId || !id) return false;
+ if (state.knownMessageKeys[id]) return false;
+ state.knownMessageKeys[id] = true;
+
+ const row = {
+ from: from === 'out' ? 'out' : 'in',
+ text: String(text || ''),
+ messageKey: id,
+ baseKey: String(baseKey || ''),
+ messageType: Number(messageType || 0),
+ rawBlobB64: String(rawBlobB64 || ''),
+ unread: Boolean(unread),
+ refBaseKey: String(refBaseKey || ''),
+ firstTick: from === 'out',
+ secondTick: false,
+ };
+ getChatMessages(chatId).push(row);
+ persistMessageRecord(chatId, row);
+ return true;
+}
+
+export function markChatRead(chatId) {
+ const list = getChatMessages(chatId);
+ list.forEach((row) => {
+ if (row?.from === 'in') {
+ row.unread = false;
+ persistMessageRecord(chatId, row);
+ }
+ });
+}
+
export function setContacts(list) {
state.contacts = Array.isArray(list) ? [...list] : [];
}
diff --git a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
index 37c0f68..25888db 100644
--- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
+++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
@@ -496,6 +496,56 @@ public final class DatabaseInitializer {
ON signed_direct_messages_history (to_login, created_at_ms);
""");
+ // 13) signed_messages_v2 (универсальное хранилище блоков типов 1/2/3/4)
+ st.executeUpdate("""
+ CREATE TABLE IF NOT EXISTS signed_messages_v2 (
+ message_key TEXT NOT NULL PRIMARY KEY,
+ base_key TEXT NOT NULL,
+ target_login TEXT NOT NULL,
+ from_login TEXT NOT NULL,
+ to_login TEXT NOT NULL,
+ time_ms INTEGER NOT NULL,
+ nonce INTEGER NOT NULL,
+ message_type INTEGER NOT NULL,
+ raw_block BLOB NOT NULL,
+ created_at_ms INTEGER NOT NULL,
+ source_api TEXT NOT NULL,
+ origin_session_id TEXT,
+ receipt_ref_base_key TEXT,
+ receipt_ref_type INTEGER,
+ FOREIGN KEY (from_login) REFERENCES solana_users(login),
+ FOREIGN KEY (to_login) REFERENCES solana_users(login)
+ );
+ """);
+
+ st.executeUpdate("""
+ CREATE INDEX IF NOT EXISTS idx_signed_messages_v2_target
+ ON signed_messages_v2 (target_login, time_ms, created_at_ms);
+ """);
+
+ st.executeUpdate("""
+ CREATE INDEX IF NOT EXISTS idx_signed_messages_v2_base
+ ON signed_messages_v2 (base_key, message_type);
+ """);
+
+ // 14) signed_message_session_delivery (доставка по сессиям)
+ st.executeUpdate("""
+ CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
+ message_key TEXT NOT NULL,
+ session_id TEXT NOT NULL,
+ delivered INTEGER NOT NULL DEFAULT 0,
+ delivered_at_ms INTEGER,
+ created_at_ms INTEGER NOT NULL,
+ PRIMARY KEY (message_key, session_id),
+ FOREIGN KEY (message_key) REFERENCES signed_messages_v2(message_key)
+ );
+ """);
+
+ st.executeUpdate("""
+ CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
+ ON signed_message_session_delivery (session_id, delivered);
+ """);
+
DatabaseTriggersInstaller.createAllTriggers(st);
}
}
diff --git a/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java b/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java
new file mode 100644
index 0000000..c0205b7
--- /dev/null
+++ b/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java
@@ -0,0 +1,182 @@
+package shine.db.dao;
+
+import shine.db.SqliteDbController;
+import shine.db.entities.SignedMessageV2Entry;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.util.ArrayList;
+import java.util.List;
+
+public final class SignedMessagesV2DAO {
+ private static volatile SignedMessagesV2DAO instance;
+ private final SqliteDbController db = SqliteDbController.getInstance();
+
+ private SignedMessagesV2DAO() {}
+
+ public static SignedMessagesV2DAO getInstance() {
+ if (instance == null) {
+ synchronized (SignedMessagesV2DAO.class) {
+ if (instance == null) instance = new SignedMessagesV2DAO();
+ }
+ }
+ return instance;
+ }
+
+ public boolean insertIfAbsent(SignedMessageV2Entry e) throws Exception {
+ try (Connection c = db.getConnection()) {
+ String sql = """
+ INSERT OR IGNORE INTO signed_messages_v2 (
+ message_key, base_key, target_login, from_login, to_login,
+ time_ms, nonce, message_type, raw_block, created_at_ms,
+ source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """;
+ try (PreparedStatement ps = c.prepareStatement(sql)) {
+ ps.setString(1, e.getMessageKey());
+ ps.setString(2, e.getBaseKey());
+ ps.setString(3, e.getTargetLogin());
+ ps.setString(4, e.getFromLogin());
+ ps.setString(5, e.getToLogin());
+ ps.setLong(6, e.getTimeMs());
+ ps.setLong(7, e.getNonce());
+ ps.setInt(8, e.getMessageType());
+ ps.setBytes(9, e.getRawBlock());
+ ps.setLong(10, e.getCreatedAtMs());
+ ps.setString(11, e.getSourceApi());
+ ps.setString(12, e.getOriginSessionId());
+ ps.setString(13, e.getReceiptRefBaseKey());
+ if (e.getReceiptRefType() == null) ps.setObject(14, null);
+ else ps.setInt(14, e.getReceiptRefType());
+ return ps.executeUpdate() > 0;
+ }
+ }
+ }
+
+ public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
+ try (Connection c = db.getConnection()) {
+ String sql = """
+ SELECT
+ message_key, base_key, target_login, from_login, to_login,
+ time_ms, nonce, message_type, raw_block, created_at_ms,
+ source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
+ FROM signed_messages_v2
+ WHERE message_key = ?
+ """;
+ try (PreparedStatement ps = c.prepareStatement(sql)) {
+ ps.setString(1, messageKey);
+ try (ResultSet rs = ps.executeQuery()) {
+ if (!rs.next()) return null;
+ return mapRow(rs);
+ }
+ }
+ }
+ }
+
+ public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception {
+ try (Connection c = db.getConnection()) {
+ String sql = """
+ INSERT OR IGNORE INTO signed_message_session_delivery (
+ message_key, session_id, delivered, delivered_at_ms, created_at_ms
+ ) VALUES (?, ?, 0, NULL, ?)
+ """;
+ try (PreparedStatement ps = c.prepareStatement(sql)) {
+ ps.setString(1, messageKey);
+ ps.setString(2, sessionId);
+ ps.setLong(3, nowMs);
+ ps.executeUpdate();
+ }
+ }
+ }
+
+ public void markDelivered(String messageKey, String sessionId, long deliveredAtMs) throws Exception {
+ try (Connection c = db.getConnection()) {
+ String insertSql = """
+ INSERT OR IGNORE INTO signed_message_session_delivery (
+ message_key, session_id, delivered, delivered_at_ms, created_at_ms
+ ) VALUES (?, ?, 0, NULL, ?)
+ """;
+ try (PreparedStatement ps = c.prepareStatement(insertSql)) {
+ ps.setString(1, messageKey);
+ ps.setString(2, sessionId);
+ ps.setLong(3, deliveredAtMs);
+ ps.executeUpdate();
+ }
+
+ String updateSql = """
+ UPDATE signed_message_session_delivery
+ SET delivered = 1, delivered_at_ms = ?
+ WHERE message_key = ? AND session_id = ?
+ """;
+ try (PreparedStatement ps = c.prepareStatement(updateSql)) {
+ ps.setLong(1, deliveredAtMs);
+ ps.setString(2, messageKey);
+ ps.setString(3, sessionId);
+ ps.executeUpdate();
+ }
+ }
+ }
+
+ public List listPendingForSession(String login, String sessionId, int limit) throws Exception {
+ try (Connection c = db.getConnection()) {
+ String fillSql = """
+ INSERT OR IGNORE INTO signed_message_session_delivery (
+ message_key, session_id, delivered, delivered_at_ms, created_at_ms
+ )
+ SELECT m.message_key, ?, 0, NULL, ?
+ FROM signed_messages_v2 m
+ WHERE m.target_login = ? COLLATE NOCASE
+ """;
+ long now = System.currentTimeMillis();
+ try (PreparedStatement ps = c.prepareStatement(fillSql)) {
+ ps.setString(1, sessionId);
+ ps.setLong(2, now);
+ ps.setString(3, login);
+ ps.executeUpdate();
+ }
+
+ String sql = """
+ SELECT
+ m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
+ m.time_ms, m.nonce, m.message_type, m.raw_block, m.created_at_ms,
+ m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type
+ FROM signed_messages_v2 m
+ JOIN signed_message_session_delivery d
+ ON d.message_key = m.message_key
+ WHERE d.session_id = ? AND d.delivered = 0
+ ORDER BY m.time_ms ASC, m.created_at_ms ASC
+ LIMIT ?
+ """;
+ List out = new ArrayList<>();
+ try (PreparedStatement ps = c.prepareStatement(sql)) {
+ ps.setString(1, sessionId);
+ ps.setInt(2, Math.max(1, limit));
+ try (ResultSet rs = ps.executeQuery()) {
+ while (rs.next()) out.add(mapRow(rs));
+ }
+ }
+ return out;
+ }
+ }
+
+ private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception {
+ SignedMessageV2Entry e = new SignedMessageV2Entry();
+ e.setMessageKey(rs.getString("message_key"));
+ e.setBaseKey(rs.getString("base_key"));
+ e.setTargetLogin(rs.getString("target_login"));
+ e.setFromLogin(rs.getString("from_login"));
+ e.setToLogin(rs.getString("to_login"));
+ e.setTimeMs(rs.getLong("time_ms"));
+ e.setNonce(rs.getLong("nonce"));
+ e.setMessageType(rs.getInt("message_type"));
+ e.setRawBlock(rs.getBytes("raw_block"));
+ e.setCreatedAtMs(rs.getLong("created_at_ms"));
+ e.setSourceApi(rs.getString("source_api"));
+ e.setOriginSessionId(rs.getString("origin_session_id"));
+ e.setReceiptRefBaseKey(rs.getString("receipt_ref_base_key"));
+ int maybeRefType = rs.getInt("receipt_ref_type");
+ e.setReceiptRefType(rs.wasNull() ? null : maybeRefType);
+ return e;
+ }
+}
diff --git a/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java b/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java
new file mode 100644
index 0000000..5ded20b
--- /dev/null
+++ b/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java
@@ -0,0 +1,47 @@
+package shine.db.entities;
+
+public class SignedMessageV2Entry {
+ private String messageKey;
+ private String baseKey;
+ private String targetLogin;
+ private String fromLogin;
+ private String toLogin;
+ private long timeMs;
+ private long nonce;
+ private int messageType;
+ private byte[] rawBlock;
+ private long createdAtMs;
+ private String sourceApi;
+ private String originSessionId;
+ private String receiptRefBaseKey;
+ private Integer receiptRefType;
+
+ public String getMessageKey() { return messageKey; }
+ public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
+ public String getBaseKey() { return baseKey; }
+ public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
+ public String getTargetLogin() { return targetLogin; }
+ public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
+ public String getFromLogin() { return fromLogin; }
+ public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
+ public String getToLogin() { return toLogin; }
+ public void setToLogin(String toLogin) { this.toLogin = toLogin; }
+ public long getTimeMs() { return timeMs; }
+ public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
+ public long getNonce() { return nonce; }
+ public void setNonce(long nonce) { this.nonce = nonce; }
+ public int getMessageType() { return messageType; }
+ public void setMessageType(int messageType) { this.messageType = messageType; }
+ public byte[] getRawBlock() { return rawBlock; }
+ public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; }
+ public long getCreatedAtMs() { return createdAtMs; }
+ public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
+ public String getSourceApi() { return sourceApi; }
+ public void setSourceApi(String sourceApi) { this.sourceApi = sourceApi; }
+ public String getOriginSessionId() { return originSessionId; }
+ public void setOriginSessionId(String originSessionId) { this.originSessionId = originSessionId; }
+ public String getReceiptRefBaseKey() { return receiptRefBaseKey; }
+ public void setReceiptRefBaseKey(String receiptRefBaseKey) { this.receiptRefBaseKey = receiptRefBaseKey; }
+ public Integer getReceiptRefType() { return receiptRefType; }
+ public void setReceiptRefType(Integer receiptRefType) { this.receiptRefType = receiptRefType; }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
index c0e2e9c..3d8051e 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
@@ -59,14 +59,20 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserCo
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
import server.logic.ws_protocol.JSON.messages.Net_AckIncomingMessage_Handler;
+import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
+import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
+import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
+import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
+import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
+import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request;
// --- NEW: Ping ---
@@ -124,7 +130,10 @@ public final class JsonHandlerRegistry {
// --- direct messages / push ---
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
Map.entry("SendDirectMessage", new Net_SendDirectMessage_Handler()),
+ Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
+ Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
Map.entry("AckIncomingMessage", new Net_AckIncomingMessage_Handler()),
+ Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
@@ -172,7 +181,10 @@ public final class JsonHandlerRegistry {
// --- direct messages / push ---
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
Map.entry("SendDirectMessage", Net_SendDirectMessage_Request.class),
+ Map.entry("SendMessagePair", Net_SendMessagePair_Request.class),
+ Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
Map.entry("AckIncomingMessage", Net_AckIncomingMessage_Request.class),
+ Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java
index 947a6a2..fb6759c 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java
@@ -10,6 +10,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response;
+import server.logic.ws_protocol.JSON.messages.SignedMessagesRealtime;
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
@@ -378,6 +379,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
ActiveConnectionsRegistry.getInstance().register(ctx);
+ SignedMessagesRealtime.dispatchPendingForSession(ctx);
// --- формируем ответ ---
Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response();
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java
index f04cd18..8adac28 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java
@@ -10,6 +10,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response;
+import server.logic.ws_protocol.JSON.messages.SignedMessagesRealtime;
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
@@ -261,6 +262,7 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
ActiveConnectionsRegistry.getInstance().register(ctx);
+ SignedMessagesRealtime.dispatchPendingForSession(ctx);
// ответ
Net_SessionLogin_Response resp = new Net_SessionLogin_Response();
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java
new file mode 100644
index 0000000..4ada25e
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java
@@ -0,0 +1,34 @@
+package server.logic.ws_protocol.JSON.messages;
+
+import server.logic.ws_protocol.JSON.ConnectionContext;
+import server.logic.ws_protocol.JSON.entyties.Net_Request;
+import server.logic.ws_protocol.JSON.entyties.Net_Response;
+import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
+import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request;
+import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Response;
+import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
+import server.logic.ws_protocol.WireCodes;
+import shine.db.dao.SignedMessagesV2DAO;
+
+public class Net_AckSessionDelivery_Handler implements JsonMessageHandler {
+ @Override
+ public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
+ Net_AckSessionDelivery_Request req = (Net_AckSessionDelivery_Request) baseRequest;
+ if (ctx == null || !ctx.isAuthenticatedUser()) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
+ }
+ if (req.getMessageKey() == null || req.getMessageKey().isBlank()) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "messageKey обязателен");
+ }
+
+ String messageKey = req.getMessageKey().trim();
+ SignedMessagesV2DAO.getInstance().markDelivered(messageKey, ctx.getSessionId(), System.currentTimeMillis());
+
+ Net_AckSessionDelivery_Response resp = new Net_AckSessionDelivery_Response();
+ resp.setOp(req.getOp());
+ resp.setRequestId(req.getRequestId());
+ resp.setStatus(WireCodes.Status.OK);
+ resp.setMessageKey(messageKey);
+ return resp;
+ }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java
new file mode 100644
index 0000000..9ae3cbf
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java
@@ -0,0 +1,63 @@
+package server.logic.ws_protocol.JSON.messages;
+
+import server.logic.ws_protocol.JSON.ConnectionContext;
+import server.logic.ws_protocol.JSON.entyties.Net_Request;
+import server.logic.ws_protocol.JSON.entyties.Net_Response;
+import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
+import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
+import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response;
+import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
+import server.logic.ws_protocol.WireCodes;
+import shine.db.entities.SignedMessageV2Entry;
+
+public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
+ @Override
+ public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
+ Net_ReceiveIncomingMessage_Request req = (Net_ReceiveIncomingMessage_Request) baseRequest;
+ if (isBlank(req.getIncomingBlobB64())) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "incomingBlobB64 обязателен");
+ }
+
+ final SignedMessageBlock incoming;
+ try {
+ incoming = SignedMessagesCore.parseFromB64(req.getIncomingBlobB64());
+ } catch (IllegalArgumentException ex) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат входящего блока");
+ }
+ if (!incoming.isIncomingType()) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_MESSAGE_TYPE", "API принимает только входящие типы 1/3");
+ }
+
+ try {
+ SignedMessagesCore.verifyUsersAndSignature(incoming);
+ } catch (IllegalArgumentException ex) {
+ String code = ex.getMessage();
+ int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED;
+ return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку");
+ }
+
+ final SignedMessageV2Entry entry;
+ try {
+ entry = SignedMessagesCore.toEntry(incoming, "ReceiveIncomingMessage", null);
+ } catch (IllegalArgumentException ex) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
+ }
+
+ SignedMessagesCore.saveIfAbsent(entry);
+ SignedMessagesRealtime.DeliveryCounters counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null);
+
+ Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response();
+ resp.setOp(req.getOp());
+ resp.setRequestId(req.getRequestId());
+ resp.setStatus(WireCodes.Status.OK);
+ resp.setMessageKey(entry.getMessageKey());
+ resp.setBaseKey(entry.getBaseKey());
+ resp.setDeliveredWsSessions(counters.wsDelivered);
+ resp.setDeliveredWebPushSessions(counters.pushDelivered);
+ return resp;
+ }
+
+ private boolean isBlank(String s) {
+ return s == null || s.isBlank();
+ }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java
new file mode 100644
index 0000000..4ee26da
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java
@@ -0,0 +1,81 @@
+package server.logic.ws_protocol.JSON.messages;
+
+import server.logic.ws_protocol.JSON.ConnectionContext;
+import server.logic.ws_protocol.JSON.entyties.Net_Request;
+import server.logic.ws_protocol.JSON.entyties.Net_Response;
+import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
+import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
+import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
+import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
+import server.logic.ws_protocol.WireCodes;
+import shine.db.entities.SignedMessageV2Entry;
+
+public class Net_SendMessagePair_Handler implements JsonMessageHandler {
+ @Override
+ public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
+ Net_SendMessagePair_Request req = (Net_SendMessagePair_Request) baseRequest;
+ if (ctx == null || !ctx.isAuthenticatedUser()) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
+ }
+ if (isBlank(req.getIncomingBlobB64()) || isBlank(req.getOutgoingBlobB64())) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "incomingBlobB64/outgoingBlobB64 обязательны");
+ }
+
+ final SignedMessageBlock incoming;
+ final SignedMessageBlock outgoing;
+ try {
+ incoming = SignedMessagesCore.parseFromB64(req.getIncomingBlobB64());
+ outgoing = SignedMessagesCore.parseFromB64(req.getOutgoingBlobB64());
+ SignedMessagesCore.validatePair(incoming, outgoing);
+ } catch (IllegalArgumentException ex) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат пары сообщений");
+ }
+
+ if (!incoming.fromLogin.equalsIgnoreCase(ctx.getLogin())) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "SENDER_MISMATCH", "fromLogin должен совпадать с авторизованной сессией");
+ }
+
+ try {
+ SignedMessagesCore.verifyUsersAndSignature(incoming);
+ SignedMessagesCore.verifyUsersAndSignature(outgoing);
+ } catch (IllegalArgumentException ex) {
+ String code = ex.getMessage();
+ int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED;
+ return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку");
+ }
+
+ SignedMessageV2Entry incomingEntry;
+ SignedMessageV2Entry outgoingEntry;
+ try {
+ incomingEntry = SignedMessagesCore.toEntry(incoming, "SendMessagePair", ctx.getSessionId());
+ outgoingEntry = SignedMessagesCore.toEntry(outgoing, "SendMessagePair", ctx.getSessionId());
+ } catch (IllegalArgumentException ex) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
+ }
+
+ SignedMessagesCore.saveIfAbsent(incomingEntry);
+ SignedMessagesCore.saveIfAbsent(outgoingEntry);
+
+ SignedMessagesRealtime.DeliveryCounters inCounters =
+ SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, null);
+
+ String excludeSessionId = outgoingEntry.getTargetLogin().equalsIgnoreCase(ctx.getLogin()) ? ctx.getSessionId() : null;
+ SignedMessagesRealtime.DeliveryCounters outCounters =
+ SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, excludeSessionId);
+
+ Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
+ resp.setOp(req.getOp());
+ resp.setRequestId(req.getRequestId());
+ resp.setStatus(WireCodes.Status.OK);
+ resp.setBaseKey(incomingEntry.getBaseKey());
+ resp.setIncomingKey(incomingEntry.getMessageKey());
+ resp.setOutgoingKey(outgoingEntry.getMessageKey());
+ resp.setDeliveredWsSessions(inCounters.wsDelivered + outCounters.wsDelivered);
+ resp.setDeliveredWebPushSessions(inCounters.pushDelivered + outCounters.pushDelivered);
+ return resp;
+ }
+
+ private boolean isBlank(String s) {
+ return s == null || s.isBlank();
+ }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java
new file mode 100644
index 0000000..9808453
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java
@@ -0,0 +1,57 @@
+package server.logic.ws_protocol.JSON.messages;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+
+final class ReadReceiptPayload {
+ final String refToLogin;
+ final String refFromLogin;
+ final long refTimeMs;
+ final long refNonce;
+ final int refType;
+
+ private ReadReceiptPayload(String refToLogin, String refFromLogin, long refTimeMs, long refNonce, int refType) {
+ this.refToLogin = refToLogin;
+ this.refFromLogin = refFromLogin;
+ this.refTimeMs = refTimeMs;
+ this.refNonce = refNonce;
+ this.refType = refType;
+ }
+
+ static ReadReceiptPayload parse(byte[] payload) {
+ if (payload == null || payload.length < 1 + 1 + 8 + 4 + 2) {
+ throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
+ }
+ ByteBuffer bb = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN);
+ String refTo = readAscii(bb, 1, 60, "BAD_RECEIPT_TO_LOGIN");
+ String refFrom = readAscii(bb, 1, 60, "BAD_RECEIPT_FROM_LOGIN");
+ long refTimeMs = bb.getLong();
+ if (refTimeMs < 0) throw new IllegalArgumentException("BAD_RECEIPT_TIME");
+ long refNonce = Integer.toUnsignedLong(bb.getInt());
+ int refType = Short.toUnsignedInt(bb.getShort());
+ if (refType < SignedMessageBlock.TYPE_INCOMING_TEXT || refType > SignedMessageBlock.TYPE_READ_OUTGOING_COPY) {
+ throw new IllegalArgumentException("BAD_RECEIPT_REF_TYPE");
+ }
+ if (bb.hasRemaining()) {
+ throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
+ }
+ return new ReadReceiptPayload(refTo, refFrom, refTimeMs, refNonce, refType);
+ }
+
+ String refBaseKey() {
+ return SignedMessageKeys.baseKey(refToLogin, refFromLogin, refTimeMs, refNonce);
+ }
+
+ private static String readAscii(ByteBuffer bb, int minLen, int maxLen, String code) {
+ if (!bb.hasRemaining()) throw new IllegalArgumentException(code);
+ int len = Byte.toUnsignedInt(bb.get());
+ if (len < minLen || len > maxLen || bb.remaining() < len) throw new IllegalArgumentException(code);
+ byte[] bytes = new byte[len];
+ bb.get(bytes);
+ for (byte b : bytes) {
+ if (b < 0x20 || b > 0x7E) throw new IllegalArgumentException(code);
+ }
+ return new String(bytes, StandardCharsets.US_ASCII);
+ }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java
new file mode 100644
index 0000000..87967a4
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java
@@ -0,0 +1,118 @@
+package server.logic.ws_protocol.JSON.messages;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+final class SignedMessageBlock {
+ static final byte[] PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
+ static final int TYPE_INCOMING_TEXT = 1;
+ static final int TYPE_OUTGOING_COPY = 2;
+ static final int TYPE_READ_INCOMING = 3;
+ static final int TYPE_READ_OUTGOING_COPY = 4;
+
+ final String toLogin;
+ final String fromLogin;
+ final long timeMs;
+ final long nonce;
+ final int messageType;
+ final byte[] payloadBytes;
+ final byte[] signedBody;
+ final byte[] signature64;
+ final byte[] rawPacket;
+
+ private SignedMessageBlock(
+ String toLogin,
+ String fromLogin,
+ long timeMs,
+ long nonce,
+ int messageType,
+ byte[] payloadBytes,
+ byte[] signedBody,
+ byte[] signature64,
+ byte[] rawPacket
+ ) {
+ this.toLogin = toLogin;
+ this.fromLogin = fromLogin;
+ this.timeMs = timeMs;
+ this.nonce = nonce;
+ this.messageType = messageType;
+ this.payloadBytes = payloadBytes;
+ this.signedBody = signedBody;
+ this.signature64 = signature64;
+ this.rawPacket = rawPacket;
+ }
+
+ static SignedMessageBlock parse(byte[] raw, int maxPayloadBytes) {
+ if (raw == null || raw.length < PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) {
+ throw new IllegalArgumentException("BAD_LEN");
+ }
+ if (raw.length > 8192) {
+ throw new IllegalArgumentException("PAYLOAD_TOO_LARGE");
+ }
+
+ ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
+ byte[] prefix = new byte[PREFIX.length];
+ bb.get(prefix);
+ if (!Arrays.equals(prefix, PREFIX)) {
+ throw new IllegalArgumentException("BAD_PREFIX");
+ }
+
+ String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN");
+ String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN");
+
+ long timeMs = bb.getLong();
+ if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
+
+ long nonce = Integer.toUnsignedLong(bb.getInt());
+ int messageType = Short.toUnsignedInt(bb.getShort());
+ if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) {
+ throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
+ }
+
+ int payloadLen = Short.toUnsignedInt(bb.getShort());
+ if (payloadLen < 1 || payloadLen > maxPayloadBytes) {
+ throw new IllegalArgumentException("BAD_MESSAGE_LEN");
+ }
+ if (bb.remaining() != payloadLen + 64) {
+ throw new IllegalArgumentException("BAD_LEN");
+ }
+
+ byte[] payload = new byte[payloadLen];
+ bb.get(payload);
+ byte[] signature64 = new byte[64];
+ bb.get(signature64);
+ byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
+
+ return new SignedMessageBlock(
+ toLogin, fromLogin, timeMs, nonce, messageType, payload, signedBody, signature64, raw
+ );
+ }
+
+ boolean isIncomingType() {
+ return messageType == TYPE_INCOMING_TEXT || messageType == TYPE_READ_INCOMING;
+ }
+
+ boolean isOutgoingCopyType() {
+ return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY;
+ }
+
+ String targetLogin() {
+ return isIncomingType() ? toLogin : fromLogin;
+ }
+
+ private static String readAscii(ByteBuffer bb, int minLen, int maxLen, String code) {
+ if (!bb.hasRemaining()) throw new IllegalArgumentException(code);
+ int len = Byte.toUnsignedInt(bb.get());
+ if (len < minLen || len > maxLen || bb.remaining() < len) {
+ throw new IllegalArgumentException(code);
+ }
+ byte[] bytes = new byte[len];
+ bb.get(bytes);
+ for (byte b : bytes) {
+ if (b < 0x20 || b > 0x7E) throw new IllegalArgumentException(code);
+ }
+ return new String(bytes, StandardCharsets.US_ASCII);
+ }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageKeys.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageKeys.java
new file mode 100644
index 0000000..4814549
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageKeys.java
@@ -0,0 +1,13 @@
+package server.logic.ws_protocol.JSON.messages;
+
+final class SignedMessageKeys {
+ private SignedMessageKeys() {}
+
+ static String baseKey(String toLogin, String fromLogin, long timeMs, long nonce) {
+ return fromLogin + "|" + toLogin + "|" + timeMs + "|" + nonce;
+ }
+
+ static String messageKey(String toLogin, String fromLogin, long timeMs, long nonce, int messageType) {
+ return baseKey(toLogin, fromLogin, timeMs, nonce) + "|" + messageType;
+ }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java
new file mode 100644
index 0000000..e666781
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java
@@ -0,0 +1,89 @@
+package server.logic.ws_protocol.JSON.messages;
+
+import shine.db.dao.SignedMessagesV2DAO;
+import shine.db.dao.SolanaUsersDAO;
+import shine.db.entities.SignedMessageV2Entry;
+import shine.db.entities.SolanaUserEntry;
+import utils.crypto.Ed25519Util;
+
+import java.util.Base64;
+
+final class SignedMessagesCore {
+ private static final int MAX_PAYLOAD_BYTES = 4096;
+
+ private SignedMessagesCore() {}
+
+ static SignedMessageBlock parseFromB64(String blobB64) {
+ try {
+ byte[] raw = Base64.getDecoder().decode(blobB64.trim());
+ return SignedMessageBlock.parse(raw, MAX_PAYLOAD_BYTES);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
+ }
+ }
+
+ static void verifyUsersAndSignature(SignedMessageBlock block) throws Exception {
+ SolanaUserEntry from = SolanaUsersDAO.getInstance().getByLogin(block.fromLogin);
+ SolanaUserEntry to = SolanaUsersDAO.getInstance().getByLogin(block.toLogin);
+ if (from == null || to == null) {
+ throw new IllegalArgumentException("USER_NOT_FOUND");
+ }
+ byte[] pubKey32 = Ed25519Util.keyFromBase64(from.getDeviceKey());
+ if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) {
+ throw new IllegalArgumentException("BAD_SIGNATURE");
+ }
+ }
+
+ static void validatePair(SignedMessageBlock incoming, SignedMessageBlock outgoing) {
+ if (incoming.messageType % 2 == 0) throw new IllegalArgumentException("BAD_PAIR_TYPES");
+ if (outgoing.messageType != incoming.messageType + 1) throw new IllegalArgumentException("BAD_PAIR_TYPES");
+ if (!incoming.toLogin.equalsIgnoreCase(outgoing.toLogin)) throw new IllegalArgumentException("BAD_PAIR_KEYS");
+ if (!incoming.fromLogin.equalsIgnoreCase(outgoing.fromLogin)) throw new IllegalArgumentException("BAD_PAIR_KEYS");
+ if (incoming.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS");
+ if (incoming.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS");
+
+ if (incoming.messageType == SignedMessageBlock.TYPE_READ_INCOMING) {
+ ReadReceiptPayload inRef = ReadReceiptPayload.parse(incoming.payloadBytes);
+ ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes);
+ if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin)
+ || !inRef.refFromLogin.equalsIgnoreCase(outRef.refFromLogin)
+ || inRef.refTimeMs != outRef.refTimeMs
+ || inRef.refNonce != outRef.refNonce
+ || inRef.refType != outRef.refType) {
+ throw new IllegalArgumentException("BAD_RECEIPT_REF");
+ }
+ }
+ }
+
+ static SignedMessageV2Entry toEntry(SignedMessageBlock block, String sourceApi, String originSessionId) {
+ String baseKey = SignedMessageKeys.baseKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce);
+ String messageKey = SignedMessageKeys.messageKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce, block.messageType);
+
+ SignedMessageV2Entry entry = new SignedMessageV2Entry();
+ entry.setMessageKey(messageKey);
+ entry.setBaseKey(baseKey);
+ entry.setTargetLogin(block.targetLogin());
+ entry.setFromLogin(block.fromLogin);
+ entry.setToLogin(block.toLogin);
+ entry.setTimeMs(block.timeMs);
+ entry.setNonce(block.nonce);
+ entry.setMessageType(block.messageType);
+ entry.setRawBlock(block.rawPacket);
+ entry.setCreatedAtMs(System.currentTimeMillis());
+ entry.setSourceApi(sourceApi);
+ entry.setOriginSessionId(originSessionId);
+
+ if (block.messageType == SignedMessageBlock.TYPE_READ_INCOMING
+ || block.messageType == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) {
+ ReadReceiptPayload ref = ReadReceiptPayload.parse(block.payloadBytes);
+ entry.setReceiptRefBaseKey(ref.refBaseKey());
+ entry.setReceiptRefType(ref.refType);
+ }
+
+ return entry;
+ }
+
+ static void saveIfAbsent(SignedMessageV2Entry entry) throws Exception {
+ SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
+ }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java
new file mode 100644
index 0000000..97d482d
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java
@@ -0,0 +1,136 @@
+package server.logic.ws_protocol.JSON.messages;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
+import server.logic.ws_protocol.JSON.ConnectionContext;
+import server.logic.ws_protocol.JSON.push.WebPushSender;
+import server.logic.ws_protocol.JSON.push.WsEventSender;
+import shine.db.dao.ActiveSessionsDAO;
+import shine.db.dao.SignedMessagesV2DAO;
+import shine.db.entities.ActiveSessionEntry;
+import shine.db.entities.SignedMessageV2Entry;
+
+import java.util.Base64;
+import java.util.List;
+
+public final class SignedMessagesRealtime {
+ private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class);
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final int LOGIN_BACKLOG_LIMIT = 500;
+
+ private SignedMessagesRealtime() {}
+
+ static DeliveryCounters deliverToTargetSessions(
+ SignedMessageV2Entry message,
+ String excludeSessionId
+ ) throws Exception {
+ DeliveryCounters counters = new DeliveryCounters();
+ List sessions = ActiveSessionsDAO.getInstance().getByLogin(message.getTargetLogin());
+ long now = System.currentTimeMillis();
+ for (ActiveSessionEntry s : sessions) {
+ String sessionId = s.getSessionId();
+ if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
+ continue;
+ }
+ SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now);
+ boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, message, false);
+ if (deliveredOnline) {
+ counters.wsDelivered++;
+ continue;
+ }
+ if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT) {
+ boolean pushed = pushNewMessageNotification(s, message);
+ if (pushed) counters.pushDelivered++;
+ }
+ }
+ return counters;
+ }
+
+ public static void dispatchPendingForSession(ConnectionContext ctx) {
+ if (ctx == null || !ctx.isAuthenticatedUser()) return;
+ String login = ctx.getLogin();
+ String sessionId = ctx.getSessionId();
+ if (isBlank(login) || isBlank(sessionId)) return;
+
+ try {
+ List pending = SignedMessagesV2DAO.getInstance()
+ .listPendingForSession(login, sessionId, LOGIN_BACKLOG_LIMIT);
+ for (SignedMessageV2Entry e : pending) {
+ sendEventToSessionIfOnline(sessionId, e, true);
+ }
+ } catch (Exception e) {
+ log.warn("Failed to dispatch pending messages for sessionId={}", sessionId, e);
+ }
+ }
+
+ private static boolean sendEventToSessionIfOnline(String sessionId, SignedMessageV2Entry message, boolean backlog) {
+ ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId);
+ if (targetCtx == null) return false;
+
+ String blobB64 = Base64.getEncoder().encodeToString(message.getRawBlock());
+ ObjectNode payload = MAPPER.createObjectNode();
+ payload.put("messageKey", message.getMessageKey());
+ payload.put("baseKey", message.getBaseKey());
+ payload.put("fromLogin", message.getFromLogin());
+ payload.put("toLogin", message.getToLogin());
+ payload.put("targetLogin", message.getTargetLogin());
+ payload.put("messageType", message.getMessageType());
+ payload.put("timeMs", message.getTimeMs());
+ payload.put("nonce", message.getNonce());
+ payload.put("blobB64", blobB64);
+ payload.put("backlog", backlog);
+ if (message.getReceiptRefBaseKey() != null) {
+ payload.put("receiptRefBaseKey", message.getReceiptRefBaseKey());
+ }
+ if (message.getReceiptRefType() != null) {
+ payload.put("receiptRefType", message.getReceiptRefType());
+ }
+ return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
+ }
+
+ private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) {
+ try {
+ if (session == null) return false;
+ if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
+ return false;
+ }
+ String text = "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения.";
+ String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
+ return WebPushSender.sendBase64Payload(
+ session.getPushEndpoint(),
+ session.getPushP256dhKey(),
+ session.getPushAuthKey(),
+ payload
+ );
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private static String jsonEscape(String s) {
+ if (s == null) return "";
+ StringBuilder out = new StringBuilder();
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (c == '\\') out.append("\\\\");
+ else if (c == '"') out.append("\\\"");
+ else if (c == '\n') out.append("\\n");
+ else if (c == '\r') out.append("\\r");
+ else if (c == '\t') out.append("\\t");
+ else out.append(c);
+ }
+ return out.toString();
+ }
+
+ private static boolean isBlank(String s) {
+ return s == null || s.isBlank();
+ }
+
+ static final class DeliveryCounters {
+ int wsDelivered;
+ int pushDelivered;
+ }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckSessionDelivery_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckSessionDelivery_Request.java
new file mode 100644
index 0000000..8d07214
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckSessionDelivery_Request.java
@@ -0,0 +1,10 @@
+package server.logic.ws_protocol.JSON.messages.entyties;
+
+import server.logic.ws_protocol.JSON.entyties.Net_Request;
+
+public class Net_AckSessionDelivery_Request extends Net_Request {
+ private String messageKey;
+
+ public String getMessageKey() { return messageKey; }
+ public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckSessionDelivery_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckSessionDelivery_Response.java
new file mode 100644
index 0000000..743d435
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_AckSessionDelivery_Response.java
@@ -0,0 +1,10 @@
+package server.logic.ws_protocol.JSON.messages.entyties;
+
+import server.logic.ws_protocol.JSON.entyties.Net_Response;
+
+public class Net_AckSessionDelivery_Response extends Net_Response {
+ private String messageKey;
+
+ public String getMessageKey() { return messageKey; }
+ public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java
new file mode 100644
index 0000000..5b67429
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java
@@ -0,0 +1,10 @@
+package server.logic.ws_protocol.JSON.messages.entyties;
+
+import server.logic.ws_protocol.JSON.entyties.Net_Request;
+
+public class Net_ReceiveIncomingMessage_Request extends Net_Request {
+ private String incomingBlobB64;
+
+ public String getIncomingBlobB64() { return incomingBlobB64; }
+ public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Response.java
new file mode 100644
index 0000000..3e4f7f5
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Response.java
@@ -0,0 +1,19 @@
+package server.logic.ws_protocol.JSON.messages.entyties;
+
+import server.logic.ws_protocol.JSON.entyties.Net_Response;
+
+public class Net_ReceiveIncomingMessage_Response extends Net_Response {
+ private String messageKey;
+ private String baseKey;
+ private int deliveredWsSessions;
+ private int deliveredWebPushSessions;
+
+ public String getMessageKey() { return messageKey; }
+ public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
+ public String getBaseKey() { return baseKey; }
+ public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
+ public int getDeliveredWsSessions() { return deliveredWsSessions; }
+ public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
+ public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
+ public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java
new file mode 100644
index 0000000..d291722
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java
@@ -0,0 +1,13 @@
+package server.logic.ws_protocol.JSON.messages.entyties;
+
+import server.logic.ws_protocol.JSON.entyties.Net_Request;
+
+public class Net_SendMessagePair_Request extends Net_Request {
+ private String incomingBlobB64;
+ private String outgoingBlobB64;
+
+ public String getIncomingBlobB64() { return incomingBlobB64; }
+ public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
+ public String getOutgoingBlobB64() { return outgoingBlobB64; }
+ public void setOutgoingBlobB64(String outgoingBlobB64) { this.outgoingBlobB64 = outgoingBlobB64; }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Response.java
new file mode 100644
index 0000000..298e4d3
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Response.java
@@ -0,0 +1,22 @@
+package server.logic.ws_protocol.JSON.messages.entyties;
+
+import server.logic.ws_protocol.JSON.entyties.Net_Response;
+
+public class Net_SendMessagePair_Response extends Net_Response {
+ private String baseKey;
+ private String incomingKey;
+ private String outgoingKey;
+ private int deliveredWsSessions;
+ private int deliveredWebPushSessions;
+
+ public String getBaseKey() { return baseKey; }
+ public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
+ public String getIncomingKey() { return incomingKey; }
+ public void setIncomingKey(String incomingKey) { this.incomingKey = incomingKey; }
+ public String getOutgoingKey() { return outgoingKey; }
+ public void setOutgoingKey(String outgoingKey) { this.outgoingKey = outgoingKey; }
+ public int getDeliveredWsSessions() { return deliveredWsSessions; }
+ public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
+ public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
+ public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
+}