SHA256
3263 lines
128 KiB
JavaScript
3263 lines
128 KiB
JavaScript
import { WsJsonClient } from './ws-client.js';
|
|
import {
|
|
base64ToBytes,
|
|
bytesToBase64,
|
|
deriveEd25519FromMasterSecret,
|
|
deriveMasterSecretFromPassword,
|
|
ed25519PublicToX25519Public,
|
|
ed25519SeedToX25519Private,
|
|
exportEd25519PublicKeyB64,
|
|
exportPkcs8B64,
|
|
generateEd25519Pair,
|
|
extractEd25519SeedFromPkcs8B64,
|
|
hkdfSha256,
|
|
importPkcs8Ed25519,
|
|
publicKeyB64FromPkcs8Ed25519,
|
|
randomBase64,
|
|
randomBytes,
|
|
sha256Bytes,
|
|
signBytes,
|
|
signBase64,
|
|
utf8Bytes,
|
|
x25519PublicFromPrivate,
|
|
x25519RandomPrivateKey,
|
|
x25519SharedSecret,
|
|
encryptBytesAesGcm,
|
|
decryptBytesAesGcm,
|
|
} from './crypto-utils.js';
|
|
import {
|
|
channelNameErrorText,
|
|
normalizeChannelDisplayName,
|
|
toCanonicalChannelSlug,
|
|
validateChannelDisplayName,
|
|
} from './channel-name-rules.js';
|
|
import {
|
|
loadEncryptedUserSecrets,
|
|
loadSessionMaterial,
|
|
saveEncryptedUserSecrets,
|
|
saveSessionMaterial,
|
|
} from './key-vault.js';
|
|
import { createAns104DataItem } from './ans104-data-item.js';
|
|
import { defaultServerWs } from '../deploy-config.js';
|
|
|
|
const BCH_SUFFIX = '001';
|
|
const ZERO64 = '0'.repeat(64);
|
|
const ZERO_HASH_HEX = ZERO64;
|
|
|
|
const MSG_TYPE_TECH = 0;
|
|
const MSG_TYPE_TEXT = 1;
|
|
const MSG_TYPE_REACTION = 2;
|
|
const MSG_TYPE_CONNECTION = 3;
|
|
const MSG_TYPE_STATUS_ACTION = 5;
|
|
|
|
const MSG_SUBTYPE_TECH_HEADER = 0;
|
|
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
|
|
const MSG_SUBTYPE_TEXT_POST = 10;
|
|
const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
|
|
const MSG_SUBTYPE_TEXT_REPLY = 20;
|
|
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
|
|
const MSG_SUBTYPE_TEXT_RATING = 30;
|
|
const MSG_SUBTYPE_TEXT_REPOST = 50;
|
|
const MSG_SUBTYPE_TEXT_CHANNEL_META = 90;
|
|
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
|
|
const MSG_SUBTYPE_TEXT_EXERCISE = 110;
|
|
const MSG_SUBTYPE_TEXT_SERVICE = 120;
|
|
const MSG_SUBTYPE_TEXT_COURSE = 130;
|
|
const MSG_SUBTYPE_STATUS_DONE_ONCE = 10;
|
|
const MSG_SUBTYPE_STATUS_LEARNED = 20;
|
|
const MSG_SUBTYPE_STATUS_SERVICE_PASSED = 30;
|
|
const MSG_SUBTYPE_STATUS_CONFIRMED = 100;
|
|
const MSG_SUBTYPE_STATUS_INTERESTED = 110;
|
|
const MSG_SUBTYPE_STATUS_STARTED = 120;
|
|
const MSG_SUBTYPE_STATUS_IN_STUDY = 130;
|
|
const MSG_SUBTYPE_STATUS_ABANDONED = 140;
|
|
const MSG_SUBTYPE_STATUS_COMPLETED = 150;
|
|
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
|
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
|
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
|
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
|
|
const CREATE_CHANNEL_BODY_VERSION = 1;
|
|
const CHANNEL_TYPE_STORIES = 0;
|
|
const CHANNEL_TYPE_PUBLIC = 1;
|
|
const CHANNEL_TYPE_PERSONAL = 100;
|
|
const CHANNEL_TYPE_GROUP = 200;
|
|
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
|
|
const CREATE_CHANNEL_DESCRIPTION_MAX_BYTES = 2048;
|
|
const SESSION_TYPE_CLIENT = 1;
|
|
const SESSION_TYPE_WALLET = 50;
|
|
const SESSION_TYPE_HOMESERVER = 100;
|
|
const SIGNAL_TARGET_SINGLE = 'single_session';
|
|
const SIGNAL_TARGET_ALL = 'all_sessions';
|
|
const SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST = 'remote_addblock_request';
|
|
const SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT = 'remote_addblock_result';
|
|
|
|
const CONNECTION_SUBTYPES = Object.freeze({
|
|
close_friend: { on: 10, off: 11 },
|
|
friend: { on: 14, off: 15 },
|
|
contact: { on: 20, off: 21 },
|
|
// Reserved: пока не используется UI.
|
|
follow: { on: 30, off: 31 },
|
|
// Reserved: семейные связи пока скрыты из UI.
|
|
spouse: { on: 40, off: 41 },
|
|
parent: { on: 50, off: 51 },
|
|
child: { on: 52, off: 53 },
|
|
sibling: { on: 54, off: 55 },
|
|
// Legacy 60/61 intentionally absent: старые блоки сервер принимает, новый UI их не создаёт.
|
|
shine_confirmed: { on: 70, off: 71 },
|
|
// Reserved: пока не используется UI.
|
|
shine_seen: { on: 74, off: 75 },
|
|
official_account: { on: 80, off: 81 },
|
|
});
|
|
|
|
function normalizeServerUrl(url) {
|
|
const value = (url || '').trim();
|
|
if (!value) return defaultServerWs;
|
|
if (value.startsWith('ws://') || value.startsWith('wss://')) {
|
|
try {
|
|
const parsed = new URL(value);
|
|
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
|
|
return parsed.toString();
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
if (value.startsWith('https://') || value.startsWith('http://')) {
|
|
try {
|
|
const parsed = new URL(value);
|
|
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
|
|
return parsed.toString();
|
|
} catch {
|
|
return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`;
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function opError(op, response) {
|
|
const payload = response?.payload || {};
|
|
const message = payload?.message || response?.message || payload?.error || response?.error || 'Unknown server error';
|
|
const code = String(payload?.code || response?.code || payload?.error || response?.error || 'UNKNOWN').toUpperCase();
|
|
const error = new Error(`${op}: ${message} (${code})`);
|
|
error.op = op;
|
|
error.code = code;
|
|
error.status = response?.status || 0;
|
|
return error;
|
|
}
|
|
|
|
function createSignalRequestId(prefix = 'signal') {
|
|
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
}
|
|
|
|
function makeClientInfo() {
|
|
const ua = navigator.userAgent || 'unknown';
|
|
return ua.slice(0, 50);
|
|
}
|
|
|
|
function makeClientPlatform() {
|
|
return 'Web';
|
|
}
|
|
|
|
function escapeUserSettingPart(value = '') {
|
|
return String(value ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
}
|
|
|
|
function hexToBytes(hex) {
|
|
const clean = String(hex || '').trim().toLowerCase();
|
|
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
|
|
const out = new Uint8Array(clean.length / 2);
|
|
for (let i = 0; i < out.length; i += 1) {
|
|
const byte = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
if (Number.isNaN(byte)) throw new Error('Некорректный hex');
|
|
out[i] = byte;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function normalizeHex32(value, fallback = ZERO64) {
|
|
const raw = String(value || '').trim().toLowerCase();
|
|
if (!raw) return fallback;
|
|
if (/^0+$/.test(raw)) return ZERO64;
|
|
if (!/^[0-9a-f]{64}$/.test(raw)) throw new Error('Bad hash32 format');
|
|
return raw;
|
|
}
|
|
|
|
async function sha256Base64FromText(text) {
|
|
const digest = await sha256Bytes(utf8Bytes(String(text || '')));
|
|
return bytesToBase64(digest).replace(/=+$/g, '');
|
|
}
|
|
|
|
function concatBytes(...chunks) {
|
|
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
const out = new Uint8Array(total);
|
|
let offset = 0;
|
|
chunks.forEach((chunk) => {
|
|
out.set(chunk, offset);
|
|
offset += chunk.length;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function int32Bytes(value) {
|
|
const bytes = new Uint8Array(4);
|
|
const view = new DataView(bytes.buffer);
|
|
view.setInt32(0, Number(value), false);
|
|
return bytes;
|
|
}
|
|
|
|
function int16Bytes(value) {
|
|
const bytes = new Uint8Array(2);
|
|
const view = new DataView(bytes.buffer);
|
|
view.setUint16(0, Number(value), false);
|
|
return bytes;
|
|
}
|
|
|
|
function int8Byte(value) {
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error('Bad uint8 value');
|
|
return new Uint8Array([n & 0xff]);
|
|
}
|
|
|
|
function int64Bytes(value) {
|
|
const bytes = new Uint8Array(8);
|
|
const view = new DataView(bytes.buffer);
|
|
view.setBigInt64(0, BigInt(value), false);
|
|
return bytes;
|
|
}
|
|
|
|
function uint16Bytes(value) {
|
|
const bytes = new Uint8Array(2);
|
|
const view = new DataView(bytes.buffer);
|
|
view.setUint16(0, Number(value), false);
|
|
return bytes;
|
|
}
|
|
|
|
function uint32Bytes(value) {
|
|
const bytes = new Uint8Array(4);
|
|
const view = new DataView(bytes.buffer);
|
|
view.setUint32(0, Number(value), false);
|
|
return bytes;
|
|
}
|
|
|
|
function uint64Bytes(value) {
|
|
const bytes = new Uint8Array(8);
|
|
const view = new DataView(bytes.buffer);
|
|
view.setBigUint64(0, BigInt(value), false);
|
|
return bytes;
|
|
}
|
|
|
|
function uint8Bytes(value) {
|
|
return new Uint8Array([Number(value) & 0xff]);
|
|
}
|
|
|
|
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
|
|
const NTF_PREFIX_V1 = utf8Bytes('SHiNE_NTF');
|
|
const NTF_FORMAT_VERSION_MAJOR = 1;
|
|
const NTF_FORMAT_VERSION_MINOR = 0;
|
|
const NTF_STATE_SEEN_WATERMARK = 1;
|
|
const NTF_CATEGORY = { replies: 1, connections: 2, events: 3 };
|
|
const DM_TYPE_INCOMING = 1;
|
|
const DM_TYPE_OUTGOING_COPY = 2;
|
|
const DM_TYPE_READ_INCOMING = 3;
|
|
const DM_TYPE_READ_OUTGOING_COPY = 4;
|
|
const DM_TYPE_MESSAGE_DELETED_BY_SENDER = 5;
|
|
const DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT = 6;
|
|
const DM_TYPE_CONVERSATION_DELETED_BY_SENDER = 7;
|
|
const DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT = 8;
|
|
const DM_FORMAT_VERSION_MAJOR = 1;
|
|
const DM_FORMAT_VERSION_MINOR = 0;
|
|
const DM_MAX_ENCRYPTED_BODY_BYTES = 16384;
|
|
const DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM = 1;
|
|
const DM_CRYPTO_VERSION_1_0 = 0;
|
|
const DM_HKDF_INFO = utf8Bytes('SHiNE_DM|1|0|X25519+HKDF-SHA256+AES-256-GCM');
|
|
const DM_DECRYPT_ERROR_UNSUPPORTED_FORMAT = 'unsupported_format';
|
|
const DM_DECRYPT_ERROR_DECRYPT_FAILED = 'decrypt_failed';
|
|
const DM_DECRYPT_ERROR_CORRUPTED = 'corrupted';
|
|
|
|
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 dmBaseKey({ toLogin, fromLogin, timeMs, nonce }) {
|
|
return `${fromLogin}|${toLogin}|${Number(timeMs)}|${Number(nonce)}`;
|
|
}
|
|
|
|
function dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) {
|
|
return `${dmBaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`;
|
|
}
|
|
|
|
function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce }) {
|
|
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),
|
|
);
|
|
}
|
|
|
|
function buildDmEncryptedBodyBytes({ plainBytes, recipientClientKeyB64 }) {
|
|
return buildDmEncryptedBodyBytesAsync({ plainBytes, recipientClientKeyB64 });
|
|
}
|
|
|
|
async function buildDmEncryptedBodyBytesAsync({ plainBytes, recipientClientKeyB64 }) {
|
|
const recipientEd25519Pub = base64ToBytes(String(recipientClientKeyB64 || '').trim());
|
|
if (recipientEd25519Pub.length !== 32) {
|
|
throw new Error('Некорректный clientKey получателя');
|
|
}
|
|
const recipientX25519Pub = ed25519PublicToX25519Public(recipientEd25519Pub);
|
|
const ephemeralPriv = x25519RandomPrivateKey();
|
|
const ephemeralPub = x25519PublicFromPrivate(ephemeralPriv);
|
|
const sharedSecret = x25519SharedSecret(ephemeralPriv, recipientX25519Pub);
|
|
const salt = concatBytes(ephemeralPub, recipientX25519Pub);
|
|
const aesKeyBytes = await hkdfSha256(sharedSecret, salt, DM_HKDF_INFO, 32);
|
|
const iv = randomBytes(12);
|
|
const cipherText = await encryptBytesAesGcm(plainBytes, aesKeyBytes, iv);
|
|
return concatBytes(
|
|
uint8Bytes(DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM),
|
|
uint8Bytes(DM_CRYPTO_VERSION_1_0),
|
|
uint8Bytes(ephemeralPub.length),
|
|
ephemeralPub,
|
|
uint8Bytes(iv.length),
|
|
iv,
|
|
uint32Bytes(cipherText.length),
|
|
cipherText,
|
|
);
|
|
}
|
|
|
|
function parseEncryptedDmBodyBytes(payloadBytes) {
|
|
if (!(payloadBytes instanceof Uint8Array)) throw new Error('BAD_ENCRYPTED_BODY');
|
|
let o = 0;
|
|
const read = (n) => {
|
|
if (o + n > payloadBytes.length) throw new Error('BAD_ENCRYPTED_BODY');
|
|
const out = payloadBytes.slice(o, o + n);
|
|
o += n;
|
|
return out;
|
|
};
|
|
const readU8 = () => read(1)[0];
|
|
const cryptoMethod = readU8();
|
|
const cryptoVersion = readU8();
|
|
const ephemeralPubKeyLen = readU8();
|
|
const ephemeralPubKey = read(ephemeralPubKeyLen);
|
|
const ivLen = readU8();
|
|
const iv = read(ivLen);
|
|
const cipherTextLenBytes = read(4);
|
|
const cipherTextLen = new DataView(cipherTextLenBytes.buffer, cipherTextLenBytes.byteOffset, 4).getUint32(0, false);
|
|
const cipherText = read(cipherTextLen);
|
|
if (o !== payloadBytes.length) throw new Error('BAD_ENCRYPTED_BODY');
|
|
return {
|
|
cryptoMethod,
|
|
cryptoVersion,
|
|
ephemeralPubKey,
|
|
iv,
|
|
cipherText,
|
|
};
|
|
}
|
|
|
|
function classifyDmDecryptFailure(error) {
|
|
const code = String(error?.message || '').trim();
|
|
if (code === 'BAD_ENCRYPTED_BODY') return DM_DECRYPT_ERROR_CORRUPTED;
|
|
if (code === 'Неподдерживаемый метод шифрования DM') return DM_DECRYPT_ERROR_UNSUPPORTED_FORMAT;
|
|
if (code === 'OperationError') return DM_DECRYPT_ERROR_DECRYPT_FAILED;
|
|
return DM_DECRYPT_ERROR_DECRYPT_FAILED;
|
|
}
|
|
|
|
function parseReadReceiptBodyBytes(payloadBytes) {
|
|
if (!(payloadBytes instanceof Uint8Array)) throw new Error('BAD_RECEIPT_PAYLOAD_LEN');
|
|
let o = 0;
|
|
const read = (n) => {
|
|
if (o + n > payloadBytes.length) throw new Error('BAD_RECEIPT_PAYLOAD_LEN');
|
|
const out = payloadBytes.slice(o, o + n);
|
|
o += n;
|
|
return out;
|
|
};
|
|
const readU8 = () => read(1)[0];
|
|
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 = (code) => {
|
|
const len = readU8();
|
|
const part = read(len);
|
|
for (let i = 0; i < part.length; i += 1) {
|
|
const c = part[i];
|
|
if (c < 0x20 || c > 0x7e) throw new Error(code);
|
|
}
|
|
return new TextDecoder().decode(part);
|
|
};
|
|
|
|
const refToLogin = readAscii('BAD_RECEIPT_TO_LOGIN');
|
|
const refFromLogin = readAscii('BAD_RECEIPT_FROM_LOGIN');
|
|
const refTimeMs = readU64();
|
|
const refNonce = readU32();
|
|
if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_PAYLOAD_LEN');
|
|
return { refToLogin, refFromLogin, refTimeMs, refNonce };
|
|
}
|
|
|
|
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 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 startsWith = (prefix) => {
|
|
if (bytes.length < prefix.length) return false;
|
|
for (let i = 0; i < prefix.length; i += 1) {
|
|
if (bytes[i] !== prefix[i]) return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
if (!startsWith(DM_PREFIX_V1)) throw new Error('BAD_PREFIX');
|
|
|
|
read(DM_PREFIX_V1.length);
|
|
const formatVersionMajor = readU8();
|
|
const formatVersionMinor = readU8();
|
|
if (formatVersionMajor !== DM_FORMAT_VERSION_MAJOR || formatVersionMinor !== DM_FORMAT_VERSION_MINOR) {
|
|
throw new Error('BAD_FORMAT_VERSION');
|
|
}
|
|
const toLogin = readAscii();
|
|
const fromLogin = readAscii();
|
|
const timeMs = readU64();
|
|
const nonce = readU32();
|
|
const messageType = readU8();
|
|
if (messageType < DM_TYPE_INCOMING || messageType > DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT) {
|
|
throw new Error('BAD_MESSAGE_TYPE');
|
|
}
|
|
const revisionTimeMs = readU64();
|
|
const reencryptedAtMs = readU64();
|
|
const bodyLen = readU32();
|
|
const bodyBytes = read(bodyLen);
|
|
const signatureBytes = read(64);
|
|
if (o !== bytes.length) throw new Error('BAD_LEN');
|
|
|
|
const signedBody = bytes.slice(0, bytes.length - 64);
|
|
const baseKey = dmBaseKey({ toLogin, fromLogin, timeMs, nonce });
|
|
const messageKey = dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
|
|
let encryptedBodyPayload = null;
|
|
let readReceiptPayload = null;
|
|
if (messageType === DM_TYPE_INCOMING || messageType === DM_TYPE_OUTGOING_COPY) {
|
|
if (bodyBytes.length === 0) throw new Error('BAD_MESSAGE_LEN');
|
|
} else if (messageType === DM_TYPE_READ_INCOMING || messageType === DM_TYPE_READ_OUTGOING_COPY) {
|
|
if (bodyBytes.length === 0) throw new Error('BAD_RECEIPT_PAYLOAD_LEN');
|
|
if (revisionTimeMs !== 0 || reencryptedAtMs !== 0) throw new Error('BAD_RECEIPT_REVISION');
|
|
readReceiptPayload = parseReadReceiptBodyBytes(bodyBytes);
|
|
} else if (messageType === DM_TYPE_MESSAGE_DELETED_BY_SENDER || messageType === DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT) {
|
|
if (bodyBytes.length !== 0) throw new Error('BAD_DELETE_BODY');
|
|
if (revisionTimeMs <= 0) throw new Error('BAD_REVISION_TIME');
|
|
if (reencryptedAtMs !== 0) throw new Error('BAD_REENCRYPTED_TIME');
|
|
} else if (messageType === DM_TYPE_CONVERSATION_DELETED_BY_SENDER || messageType === DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT) {
|
|
if (bodyBytes.length !== 0) throw new Error('BAD_DELETE_BODY');
|
|
if (revisionTimeMs !== 0) throw new Error('BAD_REVISION_TIME');
|
|
if (reencryptedAtMs !== 0) throw new Error('BAD_REENCRYPTED_TIME');
|
|
}
|
|
|
|
return {
|
|
toLogin,
|
|
fromLogin,
|
|
timeMs,
|
|
nonce,
|
|
messageType,
|
|
revisionTimeMs,
|
|
reencryptedAtMs,
|
|
formatVersionMajor,
|
|
formatVersionMinor,
|
|
encryptedBodyBytes: bodyBytes,
|
|
encryptedBodyPayload,
|
|
text: '',
|
|
bodyAttachments: [],
|
|
payloadBytes: bodyBytes,
|
|
signatureBytes,
|
|
signedBody,
|
|
rawBytes: bytes,
|
|
baseKey,
|
|
messageKey,
|
|
legacyFormat: false,
|
|
deleted: messageType >= DM_TYPE_MESSAGE_DELETED_BY_SENDER,
|
|
isMessageDelete: messageType === DM_TYPE_MESSAGE_DELETED_BY_SENDER || messageType === DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT,
|
|
isConversationDelete: messageType === DM_TYPE_CONVERSATION_DELETED_BY_SENDER || messageType === DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT,
|
|
readReceiptPayload,
|
|
};
|
|
}
|
|
|
|
function extractContactsFromDialogs(dialogs) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
(Array.isArray(dialogs) ? dialogs : []).forEach((dialog) => {
|
|
const relationFlag = String(dialog?.relationFlag || '').trim().toLowerCase();
|
|
if (relationFlag !== 'contact' && relationFlag !== 'close_friend') return;
|
|
const login = String(dialog?.peerLogin || '').trim();
|
|
if (!login) return;
|
|
const key = login.toLowerCase();
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
out.push(login);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function makeHeaderBodyBytes({ login, initialBlockchainKey32 }) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const loginBytes = utf8Bytes(cleanLogin);
|
|
const keyBytes = initialBlockchainKey32 instanceof Uint8Array
|
|
? initialBlockchainKey32
|
|
: new Uint8Array(initialBlockchainKey32 || []);
|
|
if (loginBytes.length < 1 || loginBytes.length > 255) throw new Error('HEADER login must be 1..255 UTF-8 bytes');
|
|
if (keyBytes.length !== 32 || keyBytes.every((value) => value === 0)) {
|
|
throw new Error('HEADER initialBlockchainKey32 must be a non-zero 32-byte key');
|
|
}
|
|
return concatBytes(
|
|
utf8Bytes('SHiNE'),
|
|
int8Byte(loginBytes.length),
|
|
loginBytes,
|
|
keyBytes,
|
|
);
|
|
}
|
|
|
|
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
|
|
const keyBytes = utf8Bytes(String(key || ''));
|
|
const valueBytes = utf8Bytes(String(value || ''));
|
|
const prevHashBytes = hexToBytes(prevLineHashHex);
|
|
if (!keyBytes.length || !valueBytes.length) throw new Error('Пустые key/value для блока параметра');
|
|
if (prevHashBytes.length !== 32) throw new Error('prevLineHash должен быть 32 байта');
|
|
|
|
return concatBytes(
|
|
int32Bytes(lineCode),
|
|
int32Bytes(prevLineNumber),
|
|
prevHashBytes,
|
|
int32Bytes(thisLineNumber),
|
|
int16Bytes(keyBytes.length),
|
|
keyBytes,
|
|
int16Bytes(valueBytes.length),
|
|
valueBytes,
|
|
);
|
|
}
|
|
|
|
function makeReactionLikeBodyBytes({ toLogin, toForkNumber, toBlockNumber, toBlockHashHex }) {
|
|
const cleanLogin = String(toLogin || '').trim();
|
|
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'like');
|
|
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'like');
|
|
const loginBytes = targetLoginBytes(cleanLogin);
|
|
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'like');
|
|
return concatBytes(int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes);
|
|
}
|
|
|
|
function makeTextReplyBodyBytes({ toLogin, toForkNumber, toBlockNumber, toBlockHashHex, text }) {
|
|
const loginBytes = targetLoginBytes(toLogin);
|
|
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'reply');
|
|
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'reply');
|
|
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'reply');
|
|
const textBytes = utf8Bytes(String(text || '').trim());
|
|
if (textBytes.length < 1 || textBytes.length > 65535) throw new Error('Reply text must be 1..65535 UTF-8 bytes');
|
|
return concatBytes(int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes, int16Bytes(textBytes.length), textBytes);
|
|
}
|
|
|
|
function makeTextRatingBodyBytes({ toLogin, toForkNumber, toBlockNumber, toBlockHashHex, text }) {
|
|
const loginBytes = targetLoginBytes(toLogin);
|
|
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'rating');
|
|
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'rating');
|
|
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'rating');
|
|
const textBytes = utf8Bytes(String(text || '').trim());
|
|
if (textBytes.length < 1 || textBytes.length > 65535) throw new Error('Rating text must be 1..65535 UTF-8 bytes');
|
|
return concatBytes(int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes, int16Bytes(textBytes.length), textBytes);
|
|
}
|
|
|
|
function makeStatusActionBodyBytes({ toLogin, toForkNumber, toBlockNumber, toBlockHashHex, text = '' }) {
|
|
const loginBytes = targetLoginBytes(toLogin);
|
|
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'status action');
|
|
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'status action');
|
|
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'status action');
|
|
const textBytes = utf8Bytes(String(text || '').trim());
|
|
if (textBytes.length > 65535) throw new Error('Status action text must be 0..65535 UTF-8 bytes');
|
|
return concatBytes(int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes, int16Bytes(textBytes.length), textBytes);
|
|
}
|
|
|
|
function makeTextRepostBodyBytes({
|
|
lineCode, prevLineNumber, prevLineHashHex, thisLineNumber,
|
|
toLogin, toForkNumber, toBlockNumber, toBlockHashHex, text,
|
|
}) {
|
|
const message = String(text || '').trim();
|
|
if (!message) throw new Error('Комментарий к репосту обязателен');
|
|
const loginBytes = targetLoginBytes(toLogin);
|
|
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'repost');
|
|
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'repost');
|
|
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'repost');
|
|
const textBytes = utf8Bytes(message);
|
|
if (textBytes.length > 65535) throw new Error('Repost comment too long');
|
|
return concatBytes(
|
|
int32Bytes(lineCode), int32Bytes(prevLineNumber), hexToBytes(normalizeHex32(prevLineHashHex)), int32Bytes(thisLineNumber),
|
|
int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes,
|
|
int16Bytes(textBytes.length), textBytes,
|
|
);
|
|
}
|
|
|
|
function makeTextEditPostBodyBytes({
|
|
lineCode, prevLineNumber, prevLineHashHex, thisLineNumber,
|
|
toBlockNumber, toBlockHashHex, text,
|
|
}) {
|
|
const targetBlockNumber = normalizeTargetBlockNumber(toBlockNumber, 'edit post');
|
|
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'edit post');
|
|
const textBytes = utf8Bytes(String(text || '').trim());
|
|
if (textBytes.length > 65535) throw new Error('Message text must be 0..65535 UTF-8 bytes');
|
|
return concatBytes(
|
|
int32Bytes(lineCode), int32Bytes(prevLineNumber), hexToBytes(normalizeHex32(prevLineHashHex)), int32Bytes(thisLineNumber),
|
|
int32Bytes(targetBlockNumber), hashBytes, int16Bytes(textBytes.length), textBytes,
|
|
);
|
|
}
|
|
|
|
function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) {
|
|
const targetBlockNumber = normalizeTargetBlockNumber(toBlockNumber, 'edit reply');
|
|
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'edit reply');
|
|
const textBytes = utf8Bytes(String(text || '').trim());
|
|
if (textBytes.length > 65535) throw new Error('Message text must be 0..65535 UTF-8 bytes');
|
|
return concatBytes(int32Bytes(targetBlockNumber), hashBytes, int16Bytes(textBytes.length), textBytes);
|
|
}
|
|
|
|
function makeConnectionBodyBytes({
|
|
lineCode = 0, prevLineNumber = -1, prevLineHashHex = ZERO64, thisLineNumber = -1,
|
|
toLogin, toForkNumber, toBlockNumber, toBlockHashHex,
|
|
}) {
|
|
const loginBytes = targetLoginBytes(toLogin);
|
|
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'connection');
|
|
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'connection');
|
|
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'connection');
|
|
return concatBytes(
|
|
int32Bytes(lineCode), int32Bytes(prevLineNumber), hexToBytes(normalizeHex32(prevLineHashHex)), int32Bytes(thisLineNumber),
|
|
int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes,
|
|
);
|
|
}
|
|
|
|
function targetLoginBytes(value) {
|
|
const bytes = utf8Bytes(String(value || '').trim());
|
|
if (bytes.length < 1 || bytes.length > 255) throw new Error('toLogin must be 1..255 UTF-8 bytes');
|
|
return bytes;
|
|
}
|
|
|
|
function normalizeTargetForkNumber(value, actionName = 'target') {
|
|
const n = Number(value);
|
|
if (!Number.isInteger(n) || n < 1 || n > 999) throw new Error(`Invalid toForkNumber for ${actionName}`);
|
|
return n;
|
|
}
|
|
|
|
function normalizeTargetBlockNumber(value, actionName = 'target') {
|
|
const n = Number(value);
|
|
if (!Number.isInteger(n) || n < 0) throw new Error(`Invalid toBlockNumber for ${actionName}`);
|
|
return n;
|
|
}
|
|
|
|
function nonZeroHash32Bytes(value, actionName = 'target') {
|
|
const hex = normalizeHex32(value, ZERO64);
|
|
if (hex === ZERO64) throw new Error(`Zero target hash is forbidden for ${actionName}`);
|
|
return hexToBytes(hex);
|
|
}
|
|
|
|
function normalizeChannelDescription(value) {
|
|
const text = String(value == null ? '' : value).trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
const bytes = utf8Bytes(text);
|
|
if (bytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
|
|
throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function validateChannelMetaTitle(value) {
|
|
const text = String(value || '').trim();
|
|
if (Array.from(text).length > 50) throw new Error('Название канала слишком длинное: максимум 50 символов.');
|
|
if (/[<>;\t\n\r\u0000]/u.test(text)) {
|
|
throw new Error('В названии канала нельзя использовать < > ; табуляцию и перевод строки.');
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function normalizeChannelMetaDescription(value) {
|
|
const text = String(value == null ? '' : value).trim();
|
|
if (Array.from(text).length > 250) throw new Error('Описание канала слишком длинное: максимум 250 символов.');
|
|
return text;
|
|
}
|
|
|
|
function composeChannelMetaText({ title = '', description = '', avatar = null } = {}) {
|
|
const cleanTitle = validateChannelMetaTitle(title);
|
|
const cleanDescription = normalizeChannelMetaDescription(description);
|
|
const rows = [];
|
|
if (cleanTitle) rows.push(`<S:title;v=1;${cleanTitle}>`);
|
|
if (avatar?.ar) {
|
|
const size = Number(avatar.size || 0);
|
|
const sha256 = String(avatar.sha256 || '').trim().toLowerCase();
|
|
const ar = String(avatar.ar || '').trim();
|
|
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер аватара.');
|
|
if (!/^[0-9a-f]{64}$/u.test(sha256)) throw new Error('Некорректный SHA-256 аватара.');
|
|
if (!/^[A-Za-z0-9_-]{43}$/u.test(ar)) throw new Error('Некорректный Arweave txId аватара.');
|
|
rows.push(`<S:ava;v=1;sz=${size};sha256=${sha256};ar=${ar}>`);
|
|
}
|
|
if (cleanDescription) rows.push(cleanDescription);
|
|
return rows.join('\n');
|
|
}
|
|
|
|
function validatePersonalChannelName(value) {
|
|
const normalized = normalizeChannelDisplayName(value);
|
|
if (!normalized) return { ok: false, error: 'Введите логин пользователя.' };
|
|
const length = Array.from(normalized).length;
|
|
if (length < 1 || length > 20) {
|
|
return { ok: false, error: 'Логин: 1-20 символов.' };
|
|
}
|
|
if (!/^[A-Za-z0-9_]+$/.test(normalized)) {
|
|
return { ok: false, error: 'Логин: разрешены только латиница, цифры и _.' };
|
|
}
|
|
return { ok: true, normalized };
|
|
}
|
|
|
|
function makeCreateChannelBodyBytes({
|
|
lineCode,
|
|
prevLineNumber,
|
|
prevLineHashHex,
|
|
thisLineNumber,
|
|
channelName,
|
|
channelDescription = '',
|
|
channelType = CHANNEL_TYPE_PUBLIC,
|
|
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
|
|
}) {
|
|
const typeCode = Number(channelType);
|
|
const nameCheck = typeCode === CHANNEL_TYPE_PERSONAL
|
|
? validatePersonalChannelName(channelName)
|
|
: validateChannelDisplayName(channelName);
|
|
if (!nameCheck.ok) {
|
|
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
|
|
}
|
|
const cleanName = nameCheck.normalized;
|
|
const cleanDescription = normalizeChannelDescription(channelDescription);
|
|
|
|
const nameBytes = utf8Bytes(cleanName);
|
|
if (nameBytes.length < 1 || nameBytes.length > 255) {
|
|
throw new Error('Channel name must be 1..255 bytes');
|
|
}
|
|
|
|
const descriptionBytes = utf8Bytes(cleanDescription);
|
|
if (descriptionBytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
|
|
throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
|
|
}
|
|
const typeVer = Number(channelTypeVersion);
|
|
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
|
|
throw new Error('Некорректный тип канала.');
|
|
}
|
|
if (!Number.isFinite(typeVer) || typeVer < 0 || typeVer > 65535) {
|
|
throw new Error('Некорректная версия типа канала.');
|
|
}
|
|
|
|
return concatBytes(
|
|
int32Bytes(lineCode),
|
|
int32Bytes(prevLineNumber),
|
|
hexToBytes(normalizeHex32(prevLineHashHex)),
|
|
int32Bytes(thisLineNumber),
|
|
int8Byte(nameBytes.length),
|
|
nameBytes,
|
|
int16Bytes(descriptionBytes.length),
|
|
descriptionBytes,
|
|
uint16Bytes(typeCode),
|
|
uint16Bytes(typeVer),
|
|
);
|
|
}
|
|
|
|
function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
|
|
const message = String(text || '').trim();
|
|
if (!message) throw new Error('Message text is required');
|
|
|
|
const textBytes = utf8Bytes(message);
|
|
if (textBytes.length < 1 || textBytes.length > 65535) {
|
|
throw new Error('Message text must be 1..65535 UTF-8 bytes');
|
|
}
|
|
|
|
return concatBytes(
|
|
int32Bytes(lineCode),
|
|
int32Bytes(prevLineNumber),
|
|
hexToBytes(normalizeHex32(prevLineHashHex)),
|
|
int32Bytes(thisLineNumber),
|
|
int16Bytes(textBytes.length),
|
|
textBytes
|
|
);
|
|
}
|
|
|
|
function makeTextLineBodyBytesAllowEmpty({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
|
|
const message = String(text || '').trim();
|
|
const textBytes = utf8Bytes(message);
|
|
if (textBytes.length > 65535) {
|
|
throw new Error('Message text must be 0..65535 UTF-8 bytes');
|
|
}
|
|
|
|
return concatBytes(
|
|
int32Bytes(lineCode),
|
|
int32Bytes(prevLineNumber),
|
|
hexToBytes(normalizeHex32(prevLineHashHex)),
|
|
int32Bytes(thisLineNumber),
|
|
int16Bytes(textBytes.length),
|
|
textBytes
|
|
);
|
|
}
|
|
|
|
function loginFromBlockchainNameValue(value) {
|
|
const name = String(value || '').trim();
|
|
const match = name.match(/^(.*)-(\d{3})$/u);
|
|
return match?.[1] ? match[1] : '';
|
|
}
|
|
|
|
function forkNumberFromBlockchainNameValue(value) {
|
|
const name = String(value || '').trim();
|
|
const match = name.match(/^(.*)-(\d{3})$/u);
|
|
if (!match) return 0;
|
|
const forkNumber = Number(match[2]);
|
|
return Number.isInteger(forkNumber) && forkNumber >= 1 && forkNumber <= 999 ? forkNumber : 0;
|
|
}
|
|
|
|
function normalizeMessageRefTarget(target, actionName = 'action') {
|
|
const cleanBch = String(target?.blockchainName || target?.authorBlockchainName || '').trim();
|
|
const cleanLogin = String(target?.login || target?.authorLogin || target?.ownerLogin || loginFromBlockchainNameValue(cleanBch)).trim();
|
|
const cleanBlockNumber = Number(target?.blockNumber ?? target?.messageRef?.blockNumber);
|
|
const cleanBlockHash = String(target?.blockHash ?? target?.messageRef?.blockHash ?? '').trim().toLowerCase();
|
|
|
|
if (!cleanLogin) {
|
|
throw new Error(`Missing message target login for ${actionName}`);
|
|
}
|
|
if (!Number.isFinite(cleanBlockNumber) || cleanBlockNumber < 0) {
|
|
throw new Error(`Invalid message target block number for ${actionName}`);
|
|
}
|
|
if (!/^[0-9a-f]{64}$/.test(cleanBlockHash) || /^0+$/.test(cleanBlockHash)) {
|
|
throw new Error(`Invalid message target hash for ${actionName}`);
|
|
}
|
|
|
|
const forkNumber = Number(target?.forkNumber ?? target?.toForkNumber ?? forkNumberFromBlockchainNameValue(cleanBch));
|
|
if (!Number.isInteger(forkNumber) || forkNumber < 1 || forkNumber > 999) {
|
|
throw new Error(`Missing/invalid message target fork for ${actionName}`);
|
|
}
|
|
|
|
return {
|
|
login: cleanLogin,
|
|
blockchainName: cleanBch,
|
|
forkNumber,
|
|
blockNumber: cleanBlockNumber,
|
|
blockHash: cleanBlockHash,
|
|
};
|
|
}
|
|
|
|
function resolveLatestLineStep(message) {
|
|
const lineStep = Number(message?.lineStep);
|
|
if (Number.isFinite(lineStep) && lineStep >= 0) return lineStep;
|
|
const fallbackByVersions = Number(message?.versionsTotal);
|
|
if (Number.isFinite(fallbackByVersions) && fallbackByVersions > 0) return Math.max(0, fallbackByVersions - 1);
|
|
return null;
|
|
}
|
|
|
|
function buildBlockPreimage({ prevBlockHashHex, blockNumber, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
|
const prevHashBytes = hexToBytes(normalizeHex32(prevBlockHashHex));
|
|
const body = bodyBytes || new Uint8Array(0);
|
|
const blockSize = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + body.length;
|
|
|
|
return concatBytes(
|
|
int16Bytes(1),
|
|
prevHashBytes,
|
|
int32Bytes(blockSize),
|
|
int32Bytes(blockNumber),
|
|
int64Bytes(Math.floor(Date.now() / 1000)),
|
|
int16Bytes(msgType),
|
|
int16Bytes(msgSubType),
|
|
int16Bytes(msgVersion),
|
|
body
|
|
);
|
|
}
|
|
|
|
function buildRemoteBlockBodyBytes({ msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
|
const body = bodyBytes || new Uint8Array(0);
|
|
return concatBytes(
|
|
int16Bytes(msgType),
|
|
int16Bytes(msgSubType),
|
|
int16Bytes(msgVersion),
|
|
body,
|
|
);
|
|
}
|
|
|
|
export class AuthService {
|
|
constructor(serverUrl) {
|
|
this.serverUrl = normalizeServerUrl(serverUrl);
|
|
this.ws = new WsJsonClient(this.serverUrl);
|
|
this.eventListeners = new Map();
|
|
this.wsEventUnsubscribers = new Map();
|
|
this.headerHashCache = new Map();
|
|
this.writeLocks = new Map();
|
|
this.passwordKeyBundleCache = new Map();
|
|
this.passwordKeyBundleInFlight = new Map();
|
|
this.currentLogin = '';
|
|
this.currentSessionId = '';
|
|
this.remoteAddBlockSessionId = '';
|
|
}
|
|
|
|
bindRegisteredEventsToCurrentWs() {
|
|
this.wsEventUnsubscribers.forEach((unsubscribe) => {
|
|
try { unsubscribe?.(); } catch {}
|
|
});
|
|
this.wsEventUnsubscribers.clear();
|
|
|
|
this.eventListeners.forEach((_handlers, op) => {
|
|
const unsubscribe = this.ws.onEvent(op, (data) => {
|
|
const handlers = this.eventListeners.get(op);
|
|
if (!handlers) return;
|
|
handlers.forEach((handler) => {
|
|
try { handler(data); } catch {}
|
|
});
|
|
});
|
|
this.wsEventUnsubscribers.set(op, unsubscribe);
|
|
});
|
|
}
|
|
|
|
resetConnection(serverUrl = this.serverUrl, { clearSessionContext = true } = {}) {
|
|
const normalized = normalizeServerUrl(serverUrl);
|
|
try { this.ws?.close(); } catch {}
|
|
this.serverUrl = normalized;
|
|
this.ws = new WsJsonClient(this.serverUrl);
|
|
this.headerHashCache = new Map();
|
|
this.writeLocks.clear();
|
|
this.bindRegisteredEventsToCurrentWs();
|
|
if (clearSessionContext) this.clearActiveSessionContext();
|
|
}
|
|
|
|
async reconnect(serverUrl) {
|
|
const normalized = normalizeServerUrl(serverUrl);
|
|
if (normalized === this.serverUrl) return;
|
|
this.resetConnection(normalized, { clearSessionContext: false });
|
|
}
|
|
|
|
setActiveSessionContext({ login = '', sessionId = '' } = {}) {
|
|
this.currentLogin = String(login || '').trim();
|
|
this.currentSessionId = String(sessionId || '').trim();
|
|
}
|
|
|
|
clearActiveSessionContext() {
|
|
this.currentLogin = '';
|
|
this.currentSessionId = '';
|
|
}
|
|
|
|
setRemoteAddBlockSessionId(sessionId = '') {
|
|
this.remoteAddBlockSessionId = String(sessionId || '').trim();
|
|
}
|
|
|
|
runWriteLocked(lockKey, runAction) {
|
|
const key = String(lockKey || '').trim() || 'write';
|
|
if (this.writeLocks.has(key)) return this.writeLocks.get(key);
|
|
|
|
const task = (async () => runAction())().finally(() => {
|
|
this.writeLocks.delete(key);
|
|
});
|
|
|
|
this.writeLocks.set(key, task);
|
|
return task;
|
|
}
|
|
|
|
async getUser(login) {
|
|
const response = await this.ws.request('GetUser', { login });
|
|
if (response.status !== 200) throw opError('GetUser', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async resolveLoginForAuth(login) {
|
|
const cleanLogin = String(login || '').trim();
|
|
if (!cleanLogin) throw new Error('Введите логин');
|
|
const response = await this.ws.request('ResolveLoginForAuth', { login: cleanLogin });
|
|
if (response.status !== 200) throw opError('ResolveLoginForAuth', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async resolveCanonicalDisplayLogin(login) {
|
|
const cleanLogin = String(login || '').trim();
|
|
if (!cleanLogin) return '';
|
|
try {
|
|
const user = await this.getUser(cleanLogin);
|
|
const canonicalLogin = String(user?.login || '').trim();
|
|
return canonicalLogin || cleanLogin;
|
|
} catch {
|
|
return cleanLogin;
|
|
}
|
|
}
|
|
|
|
async ensureLoginFree(login) {
|
|
const payload = await this.getUser(login);
|
|
return payload.exists !== true;
|
|
}
|
|
|
|
async derivePasswordKeyBundle(login, password, options = {}) {
|
|
const normalizedLogin = String(login ?? '');
|
|
const normalizedPassword = String(password ?? '');
|
|
const cacheKey = `${normalizedLogin}\n${normalizedPassword}`;
|
|
const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : null;
|
|
const isCancelled = typeof options?.isCancelled === 'function' ? options.isCancelled : null;
|
|
|
|
if (this.passwordKeyBundleCache.has(cacheKey)) {
|
|
if (onProgress) onProgress({ percent: 100, stage: 'cached', message: 'Ключи уже сгенерированы в памяти.' });
|
|
return this.passwordKeyBundleCache.get(cacheKey);
|
|
}
|
|
if (this.passwordKeyBundleInFlight.has(cacheKey)) {
|
|
return this.passwordKeyBundleInFlight.get(cacheKey);
|
|
}
|
|
|
|
const task = (async () => {
|
|
if (onProgress) onProgress({ percent: 3, stage: 'prepare', message: 'Подготовка параметров генерации...' });
|
|
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
|
|
|
|
const masterSecret = await deriveMasterSecretFromPassword(normalizedPassword, {
|
|
login: normalizedLogin,
|
|
onProgress: (value01) => {
|
|
if (!onProgress) return;
|
|
const v = Math.max(0, Math.min(1, Number(value01) || 0));
|
|
const percent = Math.round(5 + (v * 88));
|
|
onProgress({ percent, stage: 'secret', message: 'Генерация секрета из пароля...' });
|
|
},
|
|
});
|
|
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
|
|
|
|
if (onProgress) onProgress({ percent: 94, stage: 'derive', message: 'Вычисление root key...' });
|
|
const rootPair = await deriveEd25519FromMasterSecret(masterSecret, 'root.key');
|
|
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
|
|
|
|
if (onProgress) onProgress({ percent: 97, stage: 'derive', message: 'Вычисление blockchain key...' });
|
|
const blockchainPair = await deriveEd25519FromMasterSecret(masterSecret, 'blockchain.key');
|
|
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
|
|
|
|
if (onProgress) onProgress({ percent: 99, stage: 'derive', message: 'Вычисление client key...' });
|
|
const clientPair = await deriveEd25519FromMasterSecret(masterSecret, 'client.key');
|
|
const result = {
|
|
masterSecretB64: bytesToBase64(masterSecret),
|
|
rootPair,
|
|
blockchainPair,
|
|
clientPair,
|
|
};
|
|
this.passwordKeyBundleCache.set(cacheKey, result);
|
|
if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' });
|
|
return result;
|
|
})().finally(() => {
|
|
this.passwordKeyBundleInFlight.delete(cacheKey);
|
|
});
|
|
|
|
this.passwordKeyBundleInFlight.set(cacheKey, task);
|
|
return task;
|
|
}
|
|
|
|
async createAuthSession(login, keyBundle) {
|
|
const cleanLogin = (login || '').trim();
|
|
if (!cleanLogin) throw new Error('Введите логин');
|
|
const clientPair = keyBundle?.clientPair;
|
|
if (!clientPair) throw new Error('createAuthSession: не передан clientPair');
|
|
|
|
const sessionPair = await generateEd25519Pair();
|
|
const sessionKeyPub = await exportEd25519PublicKeyB64(sessionPair.publicKey);
|
|
const sessionKey = `ed25519/${sessionKeyPub}`;
|
|
const storagePwd = randomBase64(32);
|
|
|
|
const challengeResp = await this.ws.request('AuthChallenge', { login: cleanLogin });
|
|
if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp);
|
|
|
|
const authNonce = challengeResp?.payload?.authNonce;
|
|
if (!authNonce) throw new Error('AuthChallenge: сервер не вернул authNonce');
|
|
|
|
const timeMs = Date.now();
|
|
const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${sessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
|
|
const signatureB64 = await signBase64(clientPair.privateKey, preimage);
|
|
|
|
const createResp = await this.ws.request('CreateAuthSession', {
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
sessionKey,
|
|
timeMs,
|
|
authNonce,
|
|
clientKey: clientPair.publicKeyB64,
|
|
signatureB64,
|
|
sessionType: SESSION_TYPE_CLIENT,
|
|
clientPlatform: makeClientPlatform(),
|
|
clientInfo: makeClientInfo(),
|
|
});
|
|
if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);
|
|
|
|
const sessionId = createResp?.payload?.sessionId;
|
|
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
|
const connectionScope = String(createResp?.payload?.connectionScope || '').trim().toUpperCase();
|
|
|
|
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
|
|
|
|
return {
|
|
login: canonicalLogin,
|
|
sessionId,
|
|
storagePwd,
|
|
connectionScope,
|
|
sessionMaterial: {
|
|
sessionId,
|
|
sessionKey,
|
|
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
|
|
},
|
|
};
|
|
}
|
|
|
|
async createSessionForExistingUser(login, password) {
|
|
const cleanLogin = (login || '').trim();
|
|
if (!cleanLogin) throw new Error('Введите логин');
|
|
|
|
const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password);
|
|
const session = await this.createAuthSession(cleanLogin, keyBundle);
|
|
return { ...session, keyBundle };
|
|
}
|
|
|
|
async createSessionFromImportedSecrets(login, secrets) {
|
|
const cleanLogin = (login || '').trim();
|
|
if (!cleanLogin) throw new Error('В QR-коде нет логина');
|
|
const clientKey = String(secrets?.clientKey || '').trim();
|
|
const blockchainKey = String(secrets?.blockchainKey || '').trim();
|
|
if (!clientKey) throw new Error('В QR-коде нет ключа доступа для входа');
|
|
if (!blockchainKey) throw new Error('В QR-коде нет ключа блокчейна');
|
|
|
|
const privateKey = await importPkcs8Ed25519(clientKey);
|
|
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(clientKey);
|
|
const session = await this.createAuthSession(cleanLogin, {
|
|
clientPair: {
|
|
privateKey,
|
|
publicKeyB64,
|
|
},
|
|
});
|
|
return session;
|
|
}
|
|
|
|
async createDelegatedSessionWithClientKey({
|
|
login,
|
|
clientPrivPkcs8,
|
|
sessionKey,
|
|
sessionType = SESSION_TYPE_WALLET,
|
|
clientPlatform = 'Delegated session',
|
|
clientInfo = 'Delegated session via pairing',
|
|
}) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const cleanSessionKey = String(sessionKey || '').trim();
|
|
const cleanClientPriv = String(clientPrivPkcs8 || '').trim();
|
|
if (!cleanLogin) throw new Error('createDelegatedSessionWithClientKey: пустой login');
|
|
if (!cleanSessionKey) throw new Error('createDelegatedSessionWithClientKey: пустой sessionKey');
|
|
if (!cleanClientPriv) throw new Error('createDelegatedSessionWithClientKey: пустой client private key');
|
|
|
|
const clientPrivateKey = await importPkcs8Ed25519(cleanClientPriv);
|
|
const clientPublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(cleanClientPriv);
|
|
const storagePwd = randomBase64(32);
|
|
const tempAuth = new AuthService(this.serverUrl);
|
|
|
|
try {
|
|
const challengeResp = await tempAuth.ws.request('AuthChallenge', { login: cleanLogin });
|
|
if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp);
|
|
|
|
const authNonce = challengeResp?.payload?.authNonce;
|
|
if (!authNonce) throw new Error('AuthChallenge: сервер не вернул authNonce');
|
|
|
|
const timeMs = Date.now();
|
|
const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${cleanSessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
|
|
const signatureB64 = await signBase64(clientPrivateKey, preimage);
|
|
|
|
const createResp = await tempAuth.ws.request('CreateAuthSession', {
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
sessionKey: cleanSessionKey,
|
|
timeMs,
|
|
authNonce,
|
|
clientKey: clientPublicKeyB64,
|
|
signatureB64,
|
|
sessionType: Number(sessionType) || SESSION_TYPE_WALLET,
|
|
clientPlatform: String(clientPlatform || '').trim() || 'Delegated session',
|
|
clientInfo: String(clientInfo || '').trim() || 'Delegated session via pairing',
|
|
});
|
|
if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);
|
|
|
|
const sessionId = createResp?.payload?.sessionId;
|
|
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
|
|
|
const canonicalLogin = await tempAuth.resolveCanonicalDisplayLogin(cleanLogin);
|
|
|
|
return {
|
|
login: canonicalLogin,
|
|
sessionId,
|
|
storagePwd,
|
|
sessionKey: cleanSessionKey,
|
|
sessionType: Number(sessionType) || SESSION_TYPE_WALLET,
|
|
clientPlatform: String(clientPlatform || '').trim() || 'Delegated session',
|
|
};
|
|
} finally {
|
|
tempAuth.ws.close();
|
|
}
|
|
}
|
|
|
|
async persistSelectedKeys(login, storagePwd, keyBundle) {
|
|
let currentSecrets = {};
|
|
try {
|
|
const loaded = await loadEncryptedUserSecrets(login, storagePwd);
|
|
if (loaded && typeof loaded === 'object') {
|
|
currentSecrets = loaded;
|
|
}
|
|
} catch {
|
|
// Если контейнера ещё нет или пароль новый для этого логина — создадим новый ниже.
|
|
}
|
|
|
|
const clientKey = String(keyBundle?.clientPair?.privatePkcs8B64 || '').trim();
|
|
const blockchainKey = String(keyBundle?.blockchainPair?.privatePkcs8B64 || '').trim();
|
|
if (!clientKey || !blockchainKey) {
|
|
throw new Error('Для входа нужно сохранить client key и blockchain key');
|
|
}
|
|
|
|
const secrets = {
|
|
...currentSecrets,
|
|
clientKey,
|
|
blockchainKey,
|
|
};
|
|
// SAWD-v1 wallet зависит от blockchain key. При смене ключа старый кэш
|
|
// нельзя переносить в новую identity: он будет лениво выведен заново.
|
|
if (String(currentSecrets?.blockchainKey || '').trim() !== blockchainKey) {
|
|
delete secrets.arweaveWallet;
|
|
}
|
|
delete secrets.rootKey;
|
|
await saveEncryptedUserSecrets(login, storagePwd, secrets);
|
|
}
|
|
|
|
async persistSessionMaterial(login, sessionMaterial) {
|
|
await saveSessionMaterial(login, sessionMaterial);
|
|
}
|
|
|
|
|
|
async resumeSession(login, preferredSessionId = '') {
|
|
const cleanLogin = (login || '').trim();
|
|
if (!cleanLogin) throw new Error('Нет login для авто-входа');
|
|
|
|
const sessionMaterial = await loadSessionMaterial(cleanLogin);
|
|
if (!sessionMaterial?.sessionId || !sessionMaterial?.sessionKey || !sessionMaterial?.sessionPrivPkcs8) {
|
|
throw new Error('На устройстве нет сохраненного ключа сессии');
|
|
}
|
|
|
|
const targetSessionId = preferredSessionId || sessionMaterial.sessionId;
|
|
const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
|
|
|
|
const challengeResp = await this.ws.request('SessionChallenge', { sessionId: targetSessionId });
|
|
if (challengeResp.status !== 200) throw opError('SessionChallenge', challengeResp);
|
|
|
|
const nonce = challengeResp?.payload?.nonce;
|
|
if (!nonce) throw new Error('SessionChallenge: не вернулся nonce');
|
|
|
|
const timeMs = Date.now();
|
|
const preimage = `SESSION_LOGIN:${targetSessionId}:${timeMs}:${nonce}`;
|
|
const signatureB64 = await signBase64(privateKey, preimage);
|
|
|
|
const loginResp = await this.ws.request('SessionLogin', {
|
|
sessionId: targetSessionId,
|
|
sessionKey: sessionMaterial.sessionKey,
|
|
timeMs,
|
|
signatureB64,
|
|
sessionType: SESSION_TYPE_CLIENT,
|
|
clientPlatform: makeClientPlatform(),
|
|
clientInfo: makeClientInfo(),
|
|
});
|
|
if (loginResp.status !== 200) throw opError('SessionLogin', loginResp);
|
|
|
|
const storagePwd = loginResp?.payload?.storagePwd;
|
|
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
|
|
const connectionScope = String(loginResp?.payload?.connectionScope || '').trim().toUpperCase();
|
|
|
|
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
|
|
|
|
return {
|
|
login: canonicalLogin,
|
|
sessionId: targetSessionId,
|
|
storagePwd,
|
|
connectionScope,
|
|
};
|
|
}
|
|
|
|
async listSessions() {
|
|
const response = await this.ws.request('ListSessions', {});
|
|
if (response.status !== 200) throw opError('ListSessions', response);
|
|
return response?.payload?.sessions || [];
|
|
}
|
|
|
|
async waitForSignal({ signalType, signalRequestId, timeoutMs = 15000 }) {
|
|
const cleanSignalType = String(signalType || '').trim();
|
|
const cleanSignalRequestId = String(signalRequestId || '').trim();
|
|
if (!cleanSignalType || !cleanSignalRequestId) {
|
|
throw new Error('waitForSignal: не переданы signalType/signalRequestId');
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let unsubscribe = () => {};
|
|
const timer = window.setTimeout(() => {
|
|
unsubscribe();
|
|
reject(new Error(`Таймаут ожидания сигнала ${cleanSignalType}`));
|
|
}, timeoutMs);
|
|
|
|
unsubscribe = this.onEvent('IncomingSignal', (evt) => {
|
|
const payload = evt?.payload || {};
|
|
if (String(payload?.signalType || '').trim() !== cleanSignalType) return;
|
|
if (String(payload?.signalRequestId || '').trim() !== cleanSignalRequestId) return;
|
|
window.clearTimeout(timer);
|
|
unsubscribe();
|
|
resolve(payload);
|
|
});
|
|
});
|
|
}
|
|
|
|
async sendSignal({
|
|
toLogin,
|
|
targetMode = SIGNAL_TARGET_SINGLE,
|
|
targetSessionId = '',
|
|
signalType,
|
|
signalRequestId,
|
|
data = '',
|
|
storagePwd = '',
|
|
includeClientSignature = true,
|
|
timeMs = Date.now(),
|
|
} = {}) {
|
|
const cleanToLogin = String(toLogin || '').trim();
|
|
const cleanTargetMode = String(targetMode || '').trim();
|
|
const cleanTargetSessionId = String(targetSessionId || '').trim();
|
|
const cleanSignalType = String(signalType || '').trim();
|
|
const cleanSignalRequestId = String(signalRequestId || '').trim();
|
|
const cleanLogin = String(this.currentLogin || '').trim();
|
|
const cleanSessionId = String(this.currentSessionId || '').trim();
|
|
if (!cleanLogin || !cleanSessionId) throw new Error('SendSignal: нет активного login/sessionId');
|
|
if (!cleanToLogin || !cleanSignalType || !cleanSignalRequestId) {
|
|
throw new Error('SendSignal: не переданы toLogin/signalType/signalRequestId');
|
|
}
|
|
if (cleanTargetMode !== SIGNAL_TARGET_SINGLE && cleanTargetMode !== SIGNAL_TARGET_ALL) {
|
|
throw new Error('SendSignal: bad targetMode');
|
|
}
|
|
if (cleanTargetMode === SIGNAL_TARGET_SINGLE && !cleanTargetSessionId) {
|
|
throw new Error('SendSignal: targetSessionId обязателен для single_session');
|
|
}
|
|
|
|
const sessionMaterial = await loadSessionMaterial(cleanLogin);
|
|
if (!sessionMaterial?.sessionPrivPkcs8) {
|
|
throw new Error('На устройстве нет сохранённого session key для SendSignal');
|
|
}
|
|
const sessionPrivateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
|
|
|
|
const dataText = typeof data === 'string' ? data : JSON.stringify(data || {});
|
|
const dataSha256B64 = await sha256Base64FromText(dataText);
|
|
|
|
const sessionPreimage = `SEND_SIGNAL_SESSION:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`;
|
|
const sessionSignatureB64 = await signBase64(sessionPrivateKey, sessionPreimage);
|
|
|
|
let clientSignatureB64 = '';
|
|
if (includeClientSignature) {
|
|
if (!storagePwd) throw new Error('SendSignal: нужен storagePwd для подписи client key');
|
|
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
|
const clientPrivatePkcs8 = String(secrets?.clientKey || '').trim();
|
|
if (!clientPrivatePkcs8) {
|
|
throw new Error('На устройстве нет сохранённого client key для SendSignal');
|
|
}
|
|
const clientPrivateKey = await importPkcs8Ed25519(clientPrivatePkcs8);
|
|
const clientPreimage = `SEND_SIGNAL_CLIENT:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`;
|
|
clientSignatureB64 = await signBase64(clientPrivateKey, clientPreimage);
|
|
}
|
|
|
|
const response = await this.ws.request('SendSignal', {
|
|
toLogin: cleanToLogin,
|
|
targetMode: cleanTargetMode,
|
|
targetSessionId: cleanTargetMode === SIGNAL_TARGET_SINGLE ? cleanTargetSessionId : '',
|
|
signalType: cleanSignalType,
|
|
signalRequestId: cleanSignalRequestId,
|
|
data: dataText,
|
|
timeMs: Number(timeMs),
|
|
sessionSignatureB64,
|
|
clientSignatureB64,
|
|
});
|
|
if (response.status !== 200) throw opError('SendSignal', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async closeSession(sessionId) {
|
|
const response = await this.ws.request('CloseActiveSession', { sessionId });
|
|
if (response.status !== 200) throw opError('CloseActiveSession', response);
|
|
}
|
|
|
|
async getTrustedDeviceLoginSettings() {
|
|
const response = await this.ws.request('GetTrustedDeviceLoginSettings', {});
|
|
if (response.status !== 200) throw opError('GetTrustedDeviceLoginSettings', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async upsertTrustedDeviceLoginSettings({ enabled, passwordHash = '' }) {
|
|
const response = await this.ws.request('UpsertTrustedDeviceLoginSettings', {
|
|
enabled: !!enabled,
|
|
passwordHash: String(passwordHash || '').trim(),
|
|
});
|
|
if (response.status !== 200) throw opError('UpsertTrustedDeviceLoginSettings', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async startTrustedDeviceLogin({
|
|
login,
|
|
passwordHash,
|
|
requesterSessionKey,
|
|
requesterSessionType = SESSION_TYPE_CLIENT,
|
|
requesterClientPlatform = makeClientPlatform(),
|
|
payloadType = 3,
|
|
}) {
|
|
const response = await this.ws.request('StartTrustedDeviceLogin', {
|
|
login: String(login || '').trim(),
|
|
passwordHash: String(passwordHash || '').trim(),
|
|
requesterSessionKey: String(requesterSessionKey || '').trim(),
|
|
requesterSessionType: Number(requesterSessionType) || SESSION_TYPE_CLIENT,
|
|
requesterClientPlatform: String(requesterClientPlatform || '').trim() || makeClientPlatform(),
|
|
payloadType: Number(payloadType) || 3,
|
|
});
|
|
if (response.status !== 200) throw opError('StartTrustedDeviceLogin', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async listTrustedDeviceLoginRequests() {
|
|
const response = await this.ws.request('ListTrustedDeviceLoginRequests', {});
|
|
if (response.status !== 200) throw opError('ListTrustedDeviceLoginRequests', response);
|
|
return Array.isArray(response?.payload?.requests) ? response.payload.requests : [];
|
|
}
|
|
|
|
async approveTrustedDeviceLogin(pairingId, encryptedPayload) {
|
|
const response = await this.ws.request('ApproveTrustedDeviceLogin', {
|
|
pairingId: String(pairingId || '').trim(),
|
|
encryptedPayload: String(encryptedPayload || '').trim(),
|
|
});
|
|
if (response.status !== 200) throw opError('ApproveTrustedDeviceLogin', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async rejectTrustedDeviceLogin(pairingId, reason = '') {
|
|
const response = await this.ws.request('RejectTrustedDeviceLogin', {
|
|
pairingId: String(pairingId || '').trim(),
|
|
reason: String(reason || '').trim(),
|
|
});
|
|
if (response.status !== 200) throw opError('RejectTrustedDeviceLogin', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async cancelTrustedDeviceLogin(pairingId, requesterSessionKey) {
|
|
const response = await this.ws.request('CancelTrustedDeviceLogin', {
|
|
pairingId: String(pairingId || '').trim(),
|
|
requesterSessionKey: String(requesterSessionKey || '').trim(),
|
|
});
|
|
if (response.status !== 200) throw opError('CancelTrustedDeviceLogin', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async getTrustedDeviceLoginStatus(pairingId) {
|
|
const response = await this.ws.request('GetTrustedDeviceLoginStatus', {
|
|
pairingId: String(pairingId || '').trim(),
|
|
});
|
|
if (response.status !== 200) throw opError('GetTrustedDeviceLoginStatus', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async upsertEspPairingSettings(args) { return this.upsertTrustedDeviceLoginSettings(args); }
|
|
async startEspPairing(args) { return this.startTrustedDeviceLogin(args); }
|
|
async listEspPairingRequests() { return this.listTrustedDeviceLoginRequests(); }
|
|
async approveEspPairing(pairingId, encryptedPayload) { return this.approveTrustedDeviceLogin(pairingId, encryptedPayload); }
|
|
async rejectEspPairing(pairingId, reason = '') { return this.rejectTrustedDeviceLogin(pairingId, reason); }
|
|
async cancelEspPairing(pairingId, requesterSessionKey) { return this.cancelTrustedDeviceLogin(pairingId, requesterSessionKey); }
|
|
async getEspPairingStatus(pairingId) { return this.getTrustedDeviceLoginStatus(pairingId); }
|
|
|
|
async listSubscriptionsFeed(login, limit = 200) {
|
|
const response = await this.ws.request('ListSubscriptionsFeed', { login, limit });
|
|
if (response.status !== 200) throw opError('ListSubscriptionsFeed', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async getChannelMessages(channel, limit = null, sort = 'asc', login = '') {
|
|
const normalizedChannel = {
|
|
ownerBlockchainName: String(channel?.ownerBlockchainName || '').trim(),
|
|
channelRootBlockNumber: Number(channel?.channelRootBlockNumber),
|
|
channelRootBlockHash: String(channel?.channelRootBlockHash || '').trim(),
|
|
};
|
|
const payload = { channel: normalizedChannel, sort };
|
|
const cleanLimit = Number(limit);
|
|
if (Number.isFinite(cleanLimit) && cleanLimit > 0) payload.limit = Math.trunc(cleanLimit);
|
|
const cleanLogin = String(login || '').trim();
|
|
if (cleanLogin) payload.login = cleanLogin;
|
|
const response = await this.ws.request('GetChannelMessages', payload);
|
|
if (response.status !== 200) throw opError('GetChannelMessages', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async getPersonalDiary(login, limit = 200, sort = 'asc') {
|
|
const payload = { login: String(login || '').trim(), limit, sort };
|
|
const response = await this.ws.request('GetPersonalDiary', payload);
|
|
if (response.status !== 200) throw opError('GetPersonalDiary', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async getMessageLikes(message) {
|
|
const normalizedMessage = {
|
|
blockchainName: String(message?.blockchainName || '').trim(),
|
|
blockNumber: Number(message?.blockNumber),
|
|
blockHash: String(message?.blockHash || '').trim(),
|
|
};
|
|
const response = await this.ws.request('GetMessageLikes', { message: normalizedMessage });
|
|
if (response.status !== 200) throw opError('GetMessageLikes', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
|
|
const normalizedMessage = {
|
|
blockchainName: String(message?.blockchainName || '').trim(),
|
|
blockNumber: Number(message?.blockNumber),
|
|
blockHash: String(message?.blockHash || '').trim(),
|
|
};
|
|
const payload = { message: normalizedMessage, depthUp, depthDown, limitChildrenPerNode };
|
|
const cleanLogin = String(login || '').trim();
|
|
if (cleanLogin) payload.login = cleanLogin;
|
|
const response = await this.ws.request('GetMessageThread', payload);
|
|
if (response.status !== 200) throw opError('GetMessageThread', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async resolveFreshBlockchainCursor(login) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const user = await this.getUser(cleanLogin);
|
|
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
|
const freshNum = Number(user?.serverLastGlobalNumber);
|
|
const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
|
|
return {
|
|
blockchainName,
|
|
cursor: {
|
|
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
|
serverLastGlobalHash: freshHash,
|
|
},
|
|
};
|
|
}
|
|
|
|
async submitPreparedAddBlock({ login, storagePwd, blockchainName, blockNumber, prevBlockHash, preimage, ansTags }) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const cleanBlockchainName = String(blockchainName || '').trim();
|
|
const cleanPrevBlockHash = normalizeHex32(prevBlockHash, ZERO_HASH_HEX);
|
|
if (!cleanLogin || !cleanBlockchainName) throw new Error('submitPreparedAddBlock: missing login/blockchainName');
|
|
if (!(preimage instanceof Uint8Array) || preimage.length === 0) {
|
|
throw new Error('submitPreparedAddBlock: bad preimage');
|
|
}
|
|
|
|
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
|
const blockchainPrivatePkcs8 = String(savedKeys?.blockchainKey || '').trim();
|
|
if (blockchainPrivatePkcs8) {
|
|
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
|
const owner32 = base64ToBytes(await publicKeyB64FromPkcs8Ed25519(blockchainPrivatePkcs8));
|
|
const dataItemBytes = await createAns104DataItem({
|
|
owner32,
|
|
privateKey,
|
|
data: preimage,
|
|
tags: Array.isArray(ansTags) && ansTags.length ? ansTags : [{ name: 'App', value: 'test5590' }],
|
|
});
|
|
return this.ws.request('AddBlock', {
|
|
blockchainName: cleanBlockchainName,
|
|
blockNumber: Number(blockNumber),
|
|
prevBlockHash: cleanPrevBlockHash,
|
|
blockBytesB64: bytesToBase64(dataItemBytes),
|
|
});
|
|
}
|
|
|
|
|
|
const remoteSessionId = String(this.remoteAddBlockSessionId || '').trim();
|
|
if (!remoteSessionId) {
|
|
throw new Error('На устройстве нет blockchain key и не выбрана homeserver-сессия для remote AddBlock');
|
|
}
|
|
|
|
const signalRequestId = createSignalRequestId('remote-addblock');
|
|
const responseWait = this.waitForSignal({
|
|
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT,
|
|
signalRequestId,
|
|
timeoutMs: 20000,
|
|
});
|
|
|
|
const signalData = {
|
|
operation: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
|
|
signalRequestId,
|
|
login: cleanLogin,
|
|
blockchainName: cleanBlockchainName,
|
|
blockNumber: Number(blockNumber),
|
|
prevBlockHash: cleanPrevBlockHash,
|
|
blockPreimageB64: bytesToBase64(preimage),
|
|
};
|
|
|
|
await this.sendSignal({
|
|
toLogin: cleanLogin,
|
|
targetMode: SIGNAL_TARGET_SINGLE,
|
|
targetSessionId: remoteSessionId,
|
|
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
|
|
signalRequestId,
|
|
data: JSON.stringify(signalData),
|
|
storagePwd,
|
|
includeClientSignature: true,
|
|
});
|
|
|
|
const signalPayload = await responseWait;
|
|
let result = {};
|
|
try {
|
|
result = JSON.parse(String(signalPayload?.data || '{}'));
|
|
} catch {
|
|
throw new Error('Некорректный ответ remote AddBlock от homeserver');
|
|
}
|
|
if (!result?.ok) {
|
|
throw new Error(String(result?.errorMessage || result?.error || 'remote_addblock_failed'));
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
payload: {
|
|
serverLastGlobalNumber: Number(result?.serverLastGlobalNumber ?? blockNumber),
|
|
serverLastGlobalHash: String(result?.serverLastGlobalHash || ZERO_HASH_HEX),
|
|
remote: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
async submitRemoteAddBlockBody({ login, storagePwd, blockchainName, blockBodyBytes }) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const cleanBlockchainName = String(blockchainName || '').trim();
|
|
if (!cleanLogin || !cleanBlockchainName) throw new Error('submitRemoteAddBlockBody: missing login/blockchainName');
|
|
if (!(blockBodyBytes instanceof Uint8Array) || blockBodyBytes.length < 6) {
|
|
throw new Error('submitRemoteAddBlockBody: bad blockBodyBytes');
|
|
}
|
|
|
|
const remoteSessionId = String(this.remoteAddBlockSessionId || '').trim();
|
|
if (!remoteSessionId) {
|
|
throw new Error('На устройстве нет blockchain key и не выбрана homeserver-сессия для remote AddBlock');
|
|
}
|
|
|
|
const signalRequestId = createSignalRequestId('remote-addblock');
|
|
const responseWait = this.waitForSignal({
|
|
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT,
|
|
signalRequestId,
|
|
timeoutMs: 20000,
|
|
});
|
|
|
|
const signalData = {
|
|
operation: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
|
|
signalRequestId,
|
|
blockchainName: cleanBlockchainName,
|
|
blockBodyB64: bytesToBase64(blockBodyBytes),
|
|
};
|
|
|
|
await this.sendSignal({
|
|
toLogin: cleanLogin,
|
|
targetMode: SIGNAL_TARGET_SINGLE,
|
|
targetSessionId: remoteSessionId,
|
|
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
|
|
signalRequestId,
|
|
data: JSON.stringify(signalData),
|
|
storagePwd,
|
|
includeClientSignature: true,
|
|
});
|
|
|
|
const signalPayload = await responseWait;
|
|
let result = {};
|
|
try {
|
|
result = JSON.parse(String(signalPayload?.data || '{}'));
|
|
} catch {
|
|
throw new Error('Некорректный ответ remote AddBlock от homeserver');
|
|
}
|
|
if (!result?.ok) {
|
|
throw new Error(String(result?.errorMessage || result?.error || 'remote_addblock_failed'));
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
payload: {
|
|
serverLastGlobalNumber: Number(result?.serverLastGlobalNumber ?? -1),
|
|
serverLastGlobalHash: String(result?.serverLastGlobalHash || ZERO_HASH_HEX),
|
|
remote: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
async runAddBlockWithRetry({ login, storagePwd, resolveFreshState, buildPreimage, ansTags }) {
|
|
let freshState = await resolveFreshState();
|
|
let blockchainName = String(freshState?.blockchainName || '').trim();
|
|
if (!blockchainName) throw new Error('runAddBlockWithRetry: blockchainName is empty');
|
|
|
|
const tryAdd = async (cursor) => {
|
|
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
|
const prevBlockHash = normalizeHex32(cursor?.serverLastGlobalHash, ZERO64);
|
|
const preimage = await buildPreimage({ blockNumber, prevBlockHash, blockchainName });
|
|
return this.submitPreparedAddBlock({
|
|
login,
|
|
storagePwd,
|
|
blockchainName,
|
|
blockNumber,
|
|
prevBlockHash,
|
|
preimage,
|
|
ansTags,
|
|
});
|
|
};
|
|
|
|
let cursor = freshState.cursor;
|
|
let response = await tryAdd(cursor);
|
|
if (response.status !== 200) {
|
|
const knownNum = Number(response?.payload?.serverLastGlobalNumber);
|
|
const knownHash = String(response?.payload?.serverLastGlobalHash || '');
|
|
if (Number.isFinite(knownNum) && /^[0-9a-fA-F]{64}$/.test(knownHash)) {
|
|
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash.toLowerCase() };
|
|
response = await tryAdd(cursor);
|
|
} else {
|
|
freshState = await resolveFreshState();
|
|
blockchainName = String(freshState?.blockchainName || blockchainName).trim() || blockchainName;
|
|
cursor = freshState.cursor;
|
|
response = await tryAdd(cursor);
|
|
}
|
|
}
|
|
|
|
if (response.status !== 200) throw opError('AddBlock', response);
|
|
return {
|
|
response,
|
|
blockchainName,
|
|
};
|
|
}
|
|
|
|
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes, channelSlug = '', _skipHeaderEnsure = false }) {
|
|
const cleanLogin = (login || '').trim();
|
|
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
|
if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing');
|
|
|
|
const isHeader = Number(msgType) === MSG_TYPE_TECH && Number(msgSubType) === MSG_SUBTYPE_TECH_HEADER;
|
|
if (!_skipHeaderEnsure && !isHeader) {
|
|
await this.ensureBlockchainHeader(cleanLogin, storagePwd);
|
|
}
|
|
|
|
const keyBundle = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
|
const blockchainPrivatePkcs8 = String(keyBundle?.blockchainKey || '').trim();
|
|
if (!blockchainPrivatePkcs8) {
|
|
const user = await this.getUser(cleanLogin);
|
|
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
|
if (!blockchainName) throw new Error('Не удалось определить blockchainName для remote AddBlock');
|
|
const response = await this.submitRemoteAddBlockBody({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
blockchainName,
|
|
blockBodyBytes: buildRemoteBlockBodyBytes({ msgType, msgSubType, msgVersion, bodyBytes }),
|
|
});
|
|
return response.payload || {};
|
|
}
|
|
|
|
const ansTags = [{ name: 'App', value: 'test5590' }];
|
|
const cleanChannelSlug = String(channelSlug || '').trim();
|
|
if (cleanChannelSlug) ansTags.push({ name: 'c_test5590', value: cleanChannelSlug });
|
|
|
|
const { response, blockchainName } = await this.runAddBlockWithRetry({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin),
|
|
ansTags,
|
|
buildPreimage: async ({ blockNumber, prevBlockHash }) => buildBlockPreimage({
|
|
prevBlockHashHex: prevBlockHash,
|
|
blockNumber,
|
|
msgType,
|
|
msgSubType,
|
|
msgVersion,
|
|
bodyBytes,
|
|
}),
|
|
});
|
|
|
|
const payload = response.payload || {};
|
|
const acceptedNum = Number(payload?.serverLastGlobalNumber);
|
|
const acceptedHash = normalizeHex32(payload?.serverLastGlobalHash, ZERO64);
|
|
if (Number.isFinite(acceptedNum) && acceptedNum === 0 && acceptedHash !== ZERO64) {
|
|
this.headerHashCache.set(blockchainName, acceptedHash);
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
async ensureBlockchainHeader(login, storagePwd) {
|
|
const cleanLogin = String(login || '').trim();
|
|
if (!cleanLogin) throw new Error('Missing login for HEADER bootstrap');
|
|
|
|
const current = await this.getUser(cleanLogin);
|
|
const lastNum = Number(current?.serverLastGlobalNumber);
|
|
if (Number.isFinite(lastNum) && lastNum >= 0) return current;
|
|
if (!(Number.isFinite(lastNum) && lastNum === -1)) return current;
|
|
|
|
const keyBundle = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
|
const privatePkcs8 = String(keyBundle?.blockchainKey || '').trim();
|
|
let publicKeyB64 = '';
|
|
if (privatePkcs8) {
|
|
publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(privatePkcs8);
|
|
} else {
|
|
publicKeyB64 = String(current?.blockchainKey || '').trim();
|
|
}
|
|
const initialBlockchainKey32 = base64ToBytes(publicKeyB64);
|
|
if (initialBlockchainKey32.length !== 32 || initialBlockchainKey32.every((value) => value === 0)) {
|
|
throw new Error('Не удалось определить initial blockchain key для HEADER');
|
|
}
|
|
|
|
await this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: MSG_TYPE_TECH,
|
|
msgSubType: MSG_SUBTYPE_TECH_HEADER,
|
|
msgVersion: 1,
|
|
bodyBytes: makeHeaderBodyBytes({ login: cleanLogin, initialBlockchainKey32 }),
|
|
_skipHeaderEnsure: true,
|
|
});
|
|
|
|
return this.getUser(cleanLogin);
|
|
}
|
|
|
|
async ensureChainInitializedForLineOps(login, storagePwd) {
|
|
return this.ensureBlockchainHeader(login, storagePwd);
|
|
}
|
|
|
|
async addBlockLike({ login, message, storagePwd }) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const target = normalizeMessageRefTarget(message, 'like');
|
|
const key = `like:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`;
|
|
|
|
return this.runWriteLocked(key, async () => {
|
|
const bodyBytes = makeReactionLikeBodyBytes({
|
|
toLogin: target.login,
|
|
toForkNumber: target.forkNumber,
|
|
toBlockNumber: target.blockNumber,
|
|
toBlockHashHex: target.blockHash,
|
|
});
|
|
|
|
return this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: MSG_TYPE_REACTION,
|
|
msgSubType: MSG_SUBTYPE_REACTION_LIKE,
|
|
msgVersion: 1,
|
|
bodyBytes,
|
|
});
|
|
});
|
|
}
|
|
|
|
async addBlockUnlike({ login, message, storagePwd }) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const target = normalizeMessageRefTarget(message, 'unlike');
|
|
const key = `unlike:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`;
|
|
|
|
return this.runWriteLocked(key, async () => {
|
|
const bodyBytes = makeReactionLikeBodyBytes({
|
|
toLogin: target.login,
|
|
toForkNumber: target.forkNumber,
|
|
toBlockNumber: target.blockNumber,
|
|
toBlockHashHex: target.blockHash,
|
|
});
|
|
|
|
return this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: MSG_TYPE_REACTION,
|
|
msgSubType: MSG_SUBTYPE_REACTION_UNLIKE,
|
|
msgVersion: 1,
|
|
bodyBytes,
|
|
});
|
|
});
|
|
}
|
|
|
|
async addBlockReply({ login, message, text, storagePwd }) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const cleanText = String(text || '').trim();
|
|
const target = normalizeMessageRefTarget(message, 'reply');
|
|
const key = `reply:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
|
|
|
|
return this.runWriteLocked(key, async () => {
|
|
const bodyBytes = makeTextReplyBodyBytes({
|
|
toLogin: target.login,
|
|
toForkNumber: target.forkNumber,
|
|
toBlockNumber: target.blockNumber,
|
|
toBlockHashHex: target.blockHash,
|
|
text: cleanText,
|
|
});
|
|
|
|
return this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: MSG_TYPE_TEXT,
|
|
msgSubType: MSG_SUBTYPE_TEXT_REPLY,
|
|
msgVersion: 1,
|
|
bodyBytes,
|
|
});
|
|
});
|
|
}
|
|
|
|
async addBlockRating({ login, message, text, storagePwd }) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const cleanText = String(text || '').trim();
|
|
const target = normalizeMessageRefTarget(message, 'rating');
|
|
const key = `rating:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
|
|
|
|
return this.runWriteLocked(key, async () => {
|
|
const bodyBytes = makeTextRatingBodyBytes({
|
|
toLogin: target.login,
|
|
toForkNumber: target.forkNumber,
|
|
toBlockNumber: target.blockNumber,
|
|
toBlockHashHex: target.blockHash,
|
|
text: cleanText,
|
|
});
|
|
|
|
return this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: MSG_TYPE_TEXT,
|
|
msgSubType: MSG_SUBTYPE_TEXT_RATING,
|
|
msgVersion: 1,
|
|
bodyBytes,
|
|
});
|
|
});
|
|
}
|
|
|
|
async addBlockStatusAction({ login, message, text = '', statusSubType, storagePwd }) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const cleanText = String(text || '').trim();
|
|
const cleanSubType = Number(statusSubType);
|
|
const target = normalizeMessageRefTarget(message, 'status action');
|
|
const allowedStatusSubTypes = new Set([
|
|
MSG_SUBTYPE_STATUS_DONE_ONCE,
|
|
MSG_SUBTYPE_STATUS_LEARNED,
|
|
MSG_SUBTYPE_STATUS_SERVICE_PASSED,
|
|
MSG_SUBTYPE_STATUS_CONFIRMED,
|
|
MSG_SUBTYPE_STATUS_INTERESTED,
|
|
MSG_SUBTYPE_STATUS_STARTED,
|
|
MSG_SUBTYPE_STATUS_IN_STUDY,
|
|
MSG_SUBTYPE_STATUS_ABANDONED,
|
|
MSG_SUBTYPE_STATUS_COMPLETED,
|
|
]);
|
|
if (!allowedStatusSubTypes.has(cleanSubType)) {
|
|
throw new Error('Unsupported status action subtype');
|
|
}
|
|
const key = `status-action:${cleanLogin}:${cleanSubType}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
|
|
|
|
return this.runWriteLocked(key, async () => {
|
|
const bodyBytes = makeStatusActionBodyBytes({
|
|
toLogin: target.login,
|
|
toForkNumber: target.forkNumber,
|
|
toBlockNumber: target.blockNumber,
|
|
toBlockHashHex: target.blockHash,
|
|
text: cleanText,
|
|
});
|
|
|
|
return this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: MSG_TYPE_STATUS_ACTION,
|
|
msgSubType: cleanSubType,
|
|
msgVersion: 1,
|
|
bodyBytes,
|
|
});
|
|
});
|
|
}
|
|
|
|
async addBlockRepost({ login, channel, message, text, storagePwd }) {
|
|
const cleanLogin = String(login || '').trim();
|
|
if (!cleanLogin) throw new Error('Missing login');
|
|
const cleanText = String(text || '').trim();
|
|
if (!cleanText) throw new Error('Комментарий к репосту обязателен');
|
|
const target = normalizeMessageRefTarget(message, 'repost');
|
|
const selector = channel || {};
|
|
const owner = String(selector?.ownerBlockchainName || '').trim();
|
|
const root = Number(selector?.channelRootBlockNumber);
|
|
const key = `repost:${cleanLogin}:${owner}:${root}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
|
|
|
|
return this.runWriteLocked(key, async () => {
|
|
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
|
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
|
if (!owner || !Number.isFinite(root) || root < 0) throw new Error('Invalid channel selector');
|
|
if (owner !== blockchainName) throw new Error('Repost is allowed only to your own channels');
|
|
|
|
let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || ''));
|
|
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
|
if (rootHashHex === ZERO64 || !channelSlug) {
|
|
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
|
|
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === root);
|
|
if (!rootChannel) throw new Error('Channel root not found');
|
|
if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
|
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
|
|
}
|
|
|
|
let prevLineNumber = root;
|
|
let prevLineHashHex = rootHashHex;
|
|
let thisLineNumber = 0;
|
|
try {
|
|
const latestPayload = await this.getChannelMessages({
|
|
ownerBlockchainName: owner,
|
|
channelRootBlockNumber: root,
|
|
channelRootBlockHash: rootHashHex,
|
|
}, 1, 'desc', cleanLogin);
|
|
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
|
const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
|
|
const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, '');
|
|
const latestLineStep = resolveLatestLineStep(latestMessage);
|
|
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
|
prevLineNumber = latestBlockNumber;
|
|
prevLineHashHex = latestBlockHash;
|
|
thisLineNumber = Number.isFinite(latestLineStep)
|
|
? Math.max(0, latestLineStep + 1)
|
|
: 1;
|
|
}
|
|
} catch {
|
|
// fallback to root anchor
|
|
}
|
|
|
|
const bodyBytes = makeTextRepostBodyBytes({
|
|
lineCode: root,
|
|
prevLineNumber,
|
|
prevLineHashHex,
|
|
thisLineNumber,
|
|
toLogin: target.login,
|
|
toForkNumber: target.forkNumber,
|
|
toBlockNumber: target.blockNumber,
|
|
toBlockHashHex: target.blockHash,
|
|
text: cleanText,
|
|
});
|
|
|
|
return this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: MSG_TYPE_TEXT,
|
|
msgSubType: MSG_SUBTYPE_TEXT_REPOST,
|
|
msgVersion: 1,
|
|
bodyBytes,
|
|
channelSlug,
|
|
});
|
|
});
|
|
}
|
|
|
|
async addBlockEditMessage({
|
|
login,
|
|
message,
|
|
text,
|
|
storagePwd,
|
|
isChannelPost = false,
|
|
channel = null,
|
|
}) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const cleanText = String(text || '').trim();
|
|
const target = normalizeMessageRefTarget(message, 'edit');
|
|
const lockKey = `edit:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}:${isChannelPost ? 'post' : 'reply'}`;
|
|
|
|
return this.runWriteLocked(lockKey, async () => {
|
|
if (isChannelPost) {
|
|
const selector = channel || {};
|
|
const ownerBlockchainName = String(selector?.ownerBlockchainName || target.blockchainName || '').trim();
|
|
const lineCode = Number(selector?.channelRootBlockNumber);
|
|
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
|
throw new Error('Invalid channel selector for edit');
|
|
}
|
|
|
|
let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || ''));
|
|
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
|
if (rootHashHex === ZERO64 || !channelSlug) {
|
|
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, ownerBlockchainName);
|
|
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
|
if (rootChannel) {
|
|
if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
|
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
|
|
}
|
|
}
|
|
|
|
let prevLineNumber = lineCode;
|
|
let prevLineHashHex = rootHashHex;
|
|
let thisLineNumber = 1;
|
|
try {
|
|
const latestPayload = await this.getChannelMessages({
|
|
ownerBlockchainName,
|
|
channelRootBlockNumber: lineCode,
|
|
channelRootBlockHash: rootHashHex,
|
|
}, 1, 'desc', cleanLogin);
|
|
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
|
const latestVersions = Array.isArray(latestMessage?.versions) ? latestMessage.versions : [];
|
|
const latestVersion = latestVersions[latestVersions.length - 1] || null;
|
|
const latestBlockNumber = Number(latestVersion?.blockNumber ?? latestMessage?.messageRef?.blockNumber);
|
|
const latestBlockHash = normalizeHex32(latestVersion?.blockHash ?? latestMessage?.messageRef?.blockHash, '');
|
|
const latestLineStep = resolveLatestLineStep(latestMessage);
|
|
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
|
prevLineNumber = latestBlockNumber;
|
|
prevLineHashHex = latestBlockHash;
|
|
thisLineNumber = Number.isFinite(latestLineStep)
|
|
? Math.max(0, latestLineStep)
|
|
: 1;
|
|
}
|
|
} catch {
|
|
// fallback to root anchor
|
|
}
|
|
|
|
const bodyBytes = makeTextEditPostBodyBytes({
|
|
lineCode,
|
|
prevLineNumber,
|
|
prevLineHashHex,
|
|
thisLineNumber,
|
|
toLogin: target.login,
|
|
toBlockNumber: target.blockNumber,
|
|
toBlockHashHex: target.blockHash,
|
|
text: cleanText,
|
|
});
|
|
|
|
return this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: MSG_TYPE_TEXT,
|
|
msgSubType: MSG_SUBTYPE_TEXT_EDIT_POST,
|
|
msgVersion: 1,
|
|
bodyBytes,
|
|
channelSlug,
|
|
});
|
|
}
|
|
|
|
const bodyBytes = makeTextEditReplyBodyBytes({
|
|
toBlockNumber: target.blockNumber,
|
|
toBlockHashHex: target.blockHash,
|
|
text: cleanText,
|
|
});
|
|
|
|
return this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: MSG_TYPE_TEXT,
|
|
msgSubType: MSG_SUBTYPE_TEXT_EDIT_REPLY,
|
|
msgVersion: 1,
|
|
bodyBytes,
|
|
});
|
|
});
|
|
}
|
|
|
|
async addBlockFollowUser({ login, targetLogin, storagePwd, unfollow = false }) {
|
|
const cleanTargetLogin = String(targetLogin || '').trim().replace(/^@+/, '');
|
|
if (!cleanTargetLogin) throw new Error('Target login is required');
|
|
const cleanLogin = String(login || '').trim();
|
|
const key = `${unfollow ? 'unfollow-user' : 'follow-user'}:${cleanLogin}:${cleanTargetLogin.toLowerCase()}`;
|
|
|
|
return this.runWriteLocked(key, async () => {
|
|
const targetUser = await this.getUser(cleanTargetLogin);
|
|
if (!targetUser?.exists) throw new Error('Target user not found');
|
|
const targetHeaderHash = await this.resolveHeaderHashForBlockchain(targetUser.blockchainName);
|
|
|
|
return this.addBlockFollowChannel({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
targetLogin: cleanTargetLogin,
|
|
targetBlockchainName: targetUser.blockchainName,
|
|
targetBlockNumber: 0,
|
|
targetBlockHashHex: targetHeaderHash,
|
|
unfollow,
|
|
});
|
|
});
|
|
}
|
|
|
|
async addBlockFollowChannel({
|
|
login,
|
|
storagePwd,
|
|
targetLogin = '',
|
|
targetBlockchainName,
|
|
targetBlockNumber,
|
|
targetBlockHashHex,
|
|
unfollow = false,
|
|
}) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const cleanTargetBch = String(targetBlockchainName || '').trim();
|
|
const cleanTargetLogin = String(targetLogin || loginFromBlockchainNameValue(cleanTargetBch)).trim();
|
|
const cleanTargetBlockNumber = Number(targetBlockNumber);
|
|
if (!cleanTargetBch) throw new Error('Target blockchain is required');
|
|
if (!cleanTargetLogin) throw new Error('Target login is required');
|
|
if (!Number.isFinite(cleanTargetBlockNumber) || cleanTargetBlockNumber < 0) {
|
|
throw new Error('Invalid target block number');
|
|
}
|
|
const seedHash = normalizeHex32(targetBlockHashHex, ZERO64);
|
|
const key = `${unfollow ? 'unfollow-channel' : 'follow-channel'}:${cleanLogin}:${cleanTargetBch}:${cleanTargetBlockNumber}:${seedHash}`;
|
|
|
|
return this.runWriteLocked(key, async () => {
|
|
let targetHashHex = seedHash;
|
|
if (targetHashHex === ZERO64) {
|
|
targetHashHex = cleanTargetBlockNumber === 0
|
|
? await this.resolveHeaderHashForBlockchain(cleanTargetBch)
|
|
: await this.getBlockHashByNumber(cleanTargetBch, cleanTargetBlockNumber);
|
|
}
|
|
|
|
const bodyBytes = makeConnectionBodyBytes({
|
|
lineCode: 0,
|
|
prevLineNumber: -1,
|
|
prevLineHashHex: ZERO64,
|
|
thisLineNumber: -1,
|
|
toLogin: cleanTargetLogin,
|
|
toForkNumber: forkNumberFromBlockchainNameValue(cleanTargetBch),
|
|
toBlockNumber: cleanTargetBlockNumber,
|
|
toBlockHashHex: targetHashHex,
|
|
});
|
|
|
|
return this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: MSG_TYPE_CONNECTION,
|
|
msgSubType: unfollow ? MSG_SUBTYPE_CONNECTION_UNFOLLOW : MSG_SUBTYPE_CONNECTION_FOLLOW,
|
|
msgVersion: 1,
|
|
bodyBytes,
|
|
});
|
|
});
|
|
}
|
|
|
|
async getBlockHashByNumber(blockchainName, blockNumber) {
|
|
const cleanBlockNumber = Number(blockNumber);
|
|
try {
|
|
const payload = await this.getMessageThread(
|
|
{
|
|
blockchainName: String(blockchainName || '').trim(),
|
|
blockNumber: cleanBlockNumber,
|
|
blockHash: ZERO64,
|
|
},
|
|
0,
|
|
0,
|
|
1
|
|
);
|
|
const hash = payload?.focus?.messageRef?.blockHash;
|
|
return normalizeHex32(hash, ZERO64);
|
|
} catch (error) {
|
|
if (cleanBlockNumber === 0 && Number(error?.status) === 404) {
|
|
return ZERO64;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async resolveHeaderHashForBlockchain(blockchainName) {
|
|
const cleanBch = String(blockchainName || '').trim();
|
|
if (!cleanBch) throw new Error('Missing blockchainName');
|
|
|
|
if (this.headerHashCache.has(cleanBch)) {
|
|
const cached = normalizeHex32(this.headerHashCache.get(cleanBch), ZERO64);
|
|
if (cached !== ZERO64) return cached;
|
|
this.headerHashCache.delete(cleanBch);
|
|
}
|
|
|
|
const headerHash = await this.getBlockHashByNumber(cleanBch, 0);
|
|
if (headerHash !== ZERO64) {
|
|
this.headerHashCache.set(cleanBch, headerHash);
|
|
} else {
|
|
this.headerHashCache.delete(cleanBch);
|
|
}
|
|
return headerHash;
|
|
}
|
|
|
|
async listOwnChannelsForBlockchain(login, blockchainName) {
|
|
const feed = await this.listSubscriptionsFeed(login, 500);
|
|
const own = feed?.ownedChannels || [];
|
|
return own
|
|
.filter((item) => String(item?.channel?.ownerBlockchainName || '') === blockchainName)
|
|
.map((item) => ({
|
|
rootBlockNumber: Number(item?.channel?.channelRoot?.blockNumber),
|
|
rootBlockHash: normalizeHex32(item?.channel?.channelRoot?.blockHash, ZERO64),
|
|
channelName: String(item?.channel?.channelName || ''),
|
|
channelTypeCode: Number(item?.channel?.channelTypeCode ?? CHANNEL_TYPE_PUBLIC),
|
|
}))
|
|
.filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
|
|
}
|
|
|
|
async addBlockCreateChannel({
|
|
login,
|
|
channelName,
|
|
channelDescription = '',
|
|
channelProfileTitle = '',
|
|
channelAvatar = null,
|
|
channelType = CHANNEL_TYPE_PUBLIC,
|
|
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
|
|
storagePwd,
|
|
}) {
|
|
const cleanLogin = (login || '').trim();
|
|
if (!cleanLogin) throw new Error('Missing login');
|
|
|
|
const typeCode = Number(channelType);
|
|
const nameCheck = typeCode === CHANNEL_TYPE_PERSONAL
|
|
? validatePersonalChannelName(channelName)
|
|
: validateChannelDisplayName(channelName);
|
|
if (!nameCheck.ok) {
|
|
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
|
|
}
|
|
const cleanChannelName = normalizeChannelDisplayName(nameCheck.normalized);
|
|
const cleanChannelDescription = typeCode === CHANNEL_TYPE_PUBLIC
|
|
? composeChannelMetaText({
|
|
title: channelProfileTitle,
|
|
description: channelDescription,
|
|
avatar: channelAvatar,
|
|
})
|
|
: normalizeChannelDescription(channelDescription);
|
|
const channelSlug = toCanonicalChannelSlug(cleanChannelName);
|
|
const typeVersion = Number(channelTypeVersion);
|
|
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
|
|
return this.runWriteLocked(key, async () => {
|
|
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
|
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
|
const userLastGlobalNumber = Number(user?.serverLastGlobalNumber);
|
|
const userLastGlobalHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
|
|
|
|
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
|
|
const createdChannels = ownChannels
|
|
.filter((item) => item.rootBlockNumber > 0)
|
|
.sort((a, b) => a.rootBlockNumber - b.rootBlockNumber);
|
|
|
|
let prevLineNumber = 0;
|
|
let prevLineHashHex = (
|
|
Number.isFinite(userLastGlobalNumber) &&
|
|
userLastGlobalNumber === 0 &&
|
|
userLastGlobalHash !== ZERO64
|
|
)
|
|
? userLastGlobalHash
|
|
: await this.resolveHeaderHashForBlockchain(blockchainName);
|
|
let thisLineNumber = 1;
|
|
|
|
if (createdChannels.length > 0) {
|
|
const last = createdChannels[createdChannels.length - 1];
|
|
prevLineNumber = last.rootBlockNumber;
|
|
prevLineHashHex = normalizeHex32(last.rootBlockHash, ZERO64);
|
|
thisLineNumber = createdChannels.length + 1;
|
|
}
|
|
|
|
const payload = await this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: MSG_TYPE_TECH,
|
|
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
|
msgVersion: CREATE_CHANNEL_BODY_VERSION,
|
|
channelSlug,
|
|
bodyBytes: makeCreateChannelBodyBytes({
|
|
lineCode: 0,
|
|
prevLineNumber,
|
|
prevLineHashHex,
|
|
thisLineNumber,
|
|
channelName: cleanChannelName,
|
|
channelDescription: cleanChannelDescription,
|
|
channelType: typeCode,
|
|
channelTypeVersion: typeVersion,
|
|
}),
|
|
});
|
|
|
|
const selector = {
|
|
ownerBlockchainName: blockchainName,
|
|
channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
|
|
channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64),
|
|
};
|
|
|
|
return {
|
|
...payload,
|
|
channel: {
|
|
...selector,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
async resolveChannelLineTail({ login, blockchainName, channel }) {
|
|
const selector = channel || {};
|
|
const ownerBlockchainName = String(selector?.ownerBlockchainName || '').trim();
|
|
const lineCode = Number(selector?.channelRootBlockNumber);
|
|
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
|
throw new Error('Invalid channel selector');
|
|
}
|
|
if (ownerBlockchainName !== blockchainName) {
|
|
throw new Error('Posting is allowed only to your own channels');
|
|
}
|
|
|
|
let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || ''));
|
|
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
|
if (rootHashHex === ZERO64 || !channelSlug) {
|
|
const ownChannels = await this.listOwnChannelsForBlockchain(login, blockchainName);
|
|
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
|
if (!rootChannel) throw new Error('Channel root not found');
|
|
if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
|
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
|
|
}
|
|
if (!channelSlug) throw new Error('Cannot resolve canonical channel slug');
|
|
|
|
let prevLineNumber = lineCode;
|
|
let prevLineHashHex = rootHashHex;
|
|
let thisLineNumber = 0;
|
|
try {
|
|
const latestPayload = await this.getChannelMessages({
|
|
ownerBlockchainName,
|
|
channelRootBlockNumber: lineCode,
|
|
channelRootBlockHash: rootHashHex,
|
|
}, 1, 'desc', login);
|
|
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
|
const latestMeta = (Array.isArray(latestPayload?.metaEvents) ? latestPayload.metaEvents : [])
|
|
.slice()
|
|
.sort((a, b) => Number(b?.messageRef?.blockNumber || -1) - Number(a?.messageRef?.blockNumber || -1))[0] || null;
|
|
const latestMessageBlock = Number(latestMessage?.messageRef?.blockNumber);
|
|
const latestMetaBlock = Number(latestMeta?.messageRef?.blockNumber);
|
|
const latest = Number.isFinite(latestMetaBlock) && (!Number.isFinite(latestMessageBlock) || latestMetaBlock > latestMessageBlock)
|
|
? latestMeta
|
|
: latestMessage;
|
|
const latestBlockNumber = Number(latest?.messageRef?.blockNumber);
|
|
const latestBlockHash = normalizeHex32(latest?.messageRef?.blockHash, '');
|
|
const latestLineStep = resolveLatestLineStep(latest);
|
|
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
|
prevLineNumber = latestBlockNumber;
|
|
prevLineHashHex = latestBlockHash;
|
|
thisLineNumber = latest?.kind === 'created'
|
|
? 0
|
|
: Number.isFinite(latestLineStep)
|
|
? Math.max(0, latestLineStep + 1)
|
|
: (latestBlockNumber === lineCode ? 0 : 1);
|
|
}
|
|
} catch {
|
|
// fallback to root anchor
|
|
}
|
|
|
|
return {
|
|
ownerBlockchainName,
|
|
lineCode,
|
|
rootHashHex,
|
|
prevLineNumber,
|
|
prevLineHashHex,
|
|
thisLineNumber,
|
|
channelSlug,
|
|
};
|
|
}
|
|
|
|
async addBlockTextPost({ login, channel, text, storagePwd, msgSubType = MSG_SUBTYPE_TEXT_POST }) {
|
|
const cleanLogin = (login || '').trim();
|
|
if (!cleanLogin) throw new Error('Missing login');
|
|
const cleanText = String(text || '').trim();
|
|
const cleanSubType = Number(msgSubType || MSG_SUBTYPE_TEXT_POST);
|
|
const allowedTextSubTypes = new Set([
|
|
MSG_SUBTYPE_TEXT_POST,
|
|
MSG_SUBTYPE_TEXT_ENTRYPOINT,
|
|
MSG_SUBTYPE_TEXT_EXERCISE,
|
|
MSG_SUBTYPE_TEXT_SERVICE,
|
|
MSG_SUBTYPE_TEXT_COURSE,
|
|
]);
|
|
if (!allowedTextSubTypes.has(cleanSubType)) {
|
|
throw new Error('Unsupported channel text subtype');
|
|
}
|
|
const selector = channel || {};
|
|
const owner = String(selector?.ownerBlockchainName || '').trim();
|
|
const root = Number(selector?.channelRootBlockNumber);
|
|
const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanSubType}:${cleanText}`;
|
|
|
|
return this.runWriteLocked(key, async () => {
|
|
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
|
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
|
|
|
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
|
|
|
|
const bodyBytes = makeTextPostBodyBytes({
|
|
lineCode: tail.lineCode,
|
|
prevLineNumber: tail.prevLineNumber,
|
|
prevLineHashHex: tail.prevLineHashHex,
|
|
thisLineNumber: tail.thisLineNumber,
|
|
text: cleanText,
|
|
});
|
|
|
|
const payload = await this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: MSG_TYPE_TEXT,
|
|
msgSubType: cleanSubType,
|
|
msgVersion: 1,
|
|
bodyBytes,
|
|
channelSlug: tail.channelSlug,
|
|
});
|
|
|
|
return {
|
|
...payload,
|
|
channel: {
|
|
ownerBlockchainName: tail.ownerBlockchainName,
|
|
channelRootBlockNumber: tail.lineCode,
|
|
channelRootBlockHash: tail.rootHashHex,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
async addBlockChannelMeta({ login, channel, title = '', description = '', avatar = null, storagePwd }) {
|
|
const cleanLogin = (login || '').trim();
|
|
if (!cleanLogin) throw new Error('Missing login');
|
|
const selector = channel || {};
|
|
const owner = String(selector?.ownerBlockchainName || '').trim();
|
|
const root = Number(selector?.channelRootBlockNumber);
|
|
const metaText = composeChannelMetaText({ title, description, avatar });
|
|
const key = `channel-meta:${cleanLogin}:${owner}:${root}:${metaText}`;
|
|
|
|
return this.runWriteLocked(key, async () => {
|
|
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
|
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
|
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
|
|
const bodyBytes = makeTextLineBodyBytesAllowEmpty({
|
|
lineCode: tail.lineCode,
|
|
prevLineNumber: tail.prevLineNumber,
|
|
prevLineHashHex: tail.prevLineHashHex,
|
|
thisLineNumber: tail.thisLineNumber,
|
|
text: metaText,
|
|
});
|
|
|
|
const payload = await this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: MSG_TYPE_TEXT,
|
|
msgSubType: MSG_SUBTYPE_TEXT_CHANNEL_META,
|
|
msgVersion: 1,
|
|
bodyBytes,
|
|
channelSlug: tail.channelSlug,
|
|
});
|
|
|
|
return {
|
|
...payload,
|
|
channel: {
|
|
ownerBlockchainName: tail.ownerBlockchainName,
|
|
channelRootBlockNumber: tail.lineCode,
|
|
channelRootBlockHash: tail.rootHashHex,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
|
|
onEvent(op, handler) {
|
|
if (!op || typeof handler !== 'function') return () => {};
|
|
if (!this.eventListeners.has(op)) {
|
|
this.eventListeners.set(op, new Set());
|
|
const unsubscribe = this.ws.onEvent(op, (data) => {
|
|
const handlers = this.eventListeners.get(op);
|
|
if (!handlers) return;
|
|
handlers.forEach((callback) => {
|
|
try { callback(data); } catch {}
|
|
});
|
|
});
|
|
this.wsEventUnsubscribers.set(op, unsubscribe);
|
|
}
|
|
const handlers = this.eventListeners.get(op);
|
|
handlers.add(handler);
|
|
return () => {
|
|
handlers.delete(handler);
|
|
if (handlers.size) return;
|
|
this.eventListeners.delete(op);
|
|
const unsubscribe = this.wsEventUnsubscribers.get(op);
|
|
try { unsubscribe?.(); } catch {}
|
|
this.wsEventUnsubscribers.delete(op);
|
|
};
|
|
}
|
|
|
|
async upsertPushToken({ endpoint, p256dhKey, authKey, sessionId, platform = 'web', userAgent = navigator.userAgent || '' }) {
|
|
const response = await this.ws.request('UpsertPushToken', { endpoint, p256dhKey, authKey, sessionId, platform, userAgent });
|
|
if (response.status !== 200) throw opError('UpsertPushToken', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async sendTestWebPush({ login = '', sessionId = '', title = '', text = '' } = {}) {
|
|
const payload = {};
|
|
if (String(login || '').trim()) payload.login = String(login || '').trim();
|
|
if (String(sessionId || '').trim()) payload.sessionId = String(sessionId || '').trim();
|
|
if (String(title || '').trim()) payload.title = String(title || '').trim();
|
|
if (String(text || '').trim()) payload.text = String(text || '').trim();
|
|
const response = await this.ws.request('SendTestWebPush', payload);
|
|
if (response.status !== 200) throw opError('SendTestWebPush', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
normalizeDmLogin(login) {
|
|
return String(login || '').trim().toLowerCase();
|
|
}
|
|
|
|
async buildSignedDmBlock({
|
|
signerLogin,
|
|
fromLogin,
|
|
toLogin,
|
|
storagePwd,
|
|
timeMs,
|
|
nonce,
|
|
messageType,
|
|
revisionTimeMs = 0,
|
|
reencryptedAtMs = 0,
|
|
bodyBytes = new Uint8Array(0),
|
|
}) {
|
|
const cleanSignerLogin = this.normalizeDmLogin(signerLogin);
|
|
const cleanFromLogin = this.normalizeDmLogin(fromLogin);
|
|
const cleanToLogin = this.normalizeDmLogin(toLogin);
|
|
if (!cleanSignerLogin || !cleanFromLogin || !cleanToLogin) throw new Error('Не передан signerLogin/fromLogin/toLogin');
|
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
|
|
if (!(bodyBytes instanceof Uint8Array) || bodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
|
|
throw new Error(`body должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`);
|
|
}
|
|
|
|
const secrets = await loadEncryptedUserSecrets(cleanSignerLogin, storagePwd);
|
|
const clientPriv = secrets?.clientKey || secrets?.clientKey;
|
|
if (!clientPriv) throw new Error('Не найден приватный clientKey');
|
|
const privateKey = await importPkcs8Ed25519(clientPriv);
|
|
|
|
const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
|
|
const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
|
|
const preimage = concatBytes(
|
|
DM_PREFIX_V1,
|
|
uint8Bytes(DM_FORMAT_VERSION_MAJOR),
|
|
uint8Bytes(DM_FORMAT_VERSION_MINOR),
|
|
uint8Bytes(toBytes.length), toBytes,
|
|
uint8Bytes(fromBytes.length), fromBytes,
|
|
uint64Bytes(timeMs),
|
|
uint32Bytes(nonce),
|
|
uint8Bytes(messageType),
|
|
uint64Bytes(revisionTimeMs),
|
|
uint64Bytes(reencryptedAtMs),
|
|
uint32Bytes(bodyBytes.length),
|
|
bodyBytes,
|
|
);
|
|
const signature = await signBytes(privateKey, preimage);
|
|
return concatBytes(preimage, signature);
|
|
}
|
|
|
|
parseSignedMessageBlob(blobB64) {
|
|
const bytes = base64ToBytes(String(blobB64 || '').trim());
|
|
return parseSignedMessageBlockBytes(bytes);
|
|
}
|
|
|
|
parseReadReceiptPayload(payloadBytes) {
|
|
return parseReadReceiptBodyBytes(payloadBytes);
|
|
}
|
|
|
|
classifyDmDecryptFailure(error) {
|
|
return classifyDmDecryptFailure(error);
|
|
}
|
|
|
|
async decryptSignedMessageContent({ parsed, blobB64 = '', login, storagePwd }) {
|
|
const parsedBlock = parsed || this.parseSignedMessageBlob(blobB64);
|
|
const messageType = Number(parsedBlock?.messageType || 0);
|
|
if (messageType !== DM_TYPE_INCOMING && messageType !== DM_TYPE_OUTGOING_COPY) {
|
|
return { text: '', plainBytes: new Uint8Array(0) };
|
|
}
|
|
if (!storagePwd) throw new Error('Не передан storagePwd для расшифровки');
|
|
const cleanLogin = String(login || '').trim();
|
|
if (!cleanLogin) throw new Error('Не передан login для расшифровки');
|
|
|
|
const payload = parsedBlock.encryptedBodyPayload || parseEncryptedDmBodyBytes(parsedBlock.payloadBytes);
|
|
if (payload.cryptoMethod !== DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM || payload.cryptoVersion !== DM_CRYPTO_VERSION_1_0) {
|
|
throw new Error('Неподдерживаемый метод шифрования DM');
|
|
}
|
|
|
|
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
|
const clientPrivPkcs8 = String(secrets?.clientKey || '').trim();
|
|
if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey');
|
|
const recipientSeed = extractEd25519SeedFromPkcs8B64(clientPrivPkcs8);
|
|
const recipientX25519Priv = ed25519SeedToX25519Private(recipientSeed);
|
|
const recipientEd25519PubB64 = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
|
|
const recipientX25519Pub = ed25519PublicToX25519Public(base64ToBytes(recipientEd25519PubB64));
|
|
const sharedSecret = x25519SharedSecret(recipientX25519Priv, payload.ephemeralPubKey);
|
|
const salt = concatBytes(payload.ephemeralPubKey, recipientX25519Pub);
|
|
const aesKeyBytes = await hkdfSha256(sharedSecret, salt, DM_HKDF_INFO, 32);
|
|
const plainBytes = await decryptBytesAesGcm(payload.cipherText, aesKeyBytes, payload.iv);
|
|
return {
|
|
text: new TextDecoder().decode(plainBytes),
|
|
plainBytes,
|
|
};
|
|
}
|
|
|
|
async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) {
|
|
const body = { incomingBlobB64, outgoingBlobB64 };
|
|
const response = await this.ws.request('SendMessagePair', body);
|
|
if (response.status !== 200) throw opError('SendMessagePair', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async sendDirectMessageRevision({
|
|
login,
|
|
toLogin,
|
|
text = '',
|
|
storagePwd,
|
|
timeMs,
|
|
nonce,
|
|
revisionTimeMs = 0,
|
|
reencryptedAtMs = 0,
|
|
}) {
|
|
const cleanFromLogin = this.normalizeDmLogin(login);
|
|
const cleanToLogin = this.normalizeDmLogin(toLogin);
|
|
const cleanText = String(text || '');
|
|
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
|
const normalizedTimeMs = Number(timeMs);
|
|
const normalizedNonce = Number(nonce);
|
|
const normalizedRevisionTimeMs = Number(revisionTimeMs || 0);
|
|
const normalizedReencryptedAtMs = Number(reencryptedAtMs || 0);
|
|
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
|
|
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
|
|
if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs < 0) throw new Error('Некорректный revisionTimeMs');
|
|
if (!Number.isFinite(normalizedReencryptedAtMs) || normalizedReencryptedAtMs < 0) throw new Error('Некорректный reencryptedAtMs');
|
|
|
|
const plainBytes = utf8Bytes(cleanText);
|
|
const targetUser = await this.getUser(cleanToLogin);
|
|
if (!targetUser?.exists || !String(targetUser?.clientKey || '').trim()) {
|
|
throw new Error('Не найден clientKey получателя');
|
|
}
|
|
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
|
|
const senderPrivPkcs8 = String(secrets?.clientKey || '').trim();
|
|
if (!senderPrivPkcs8) throw new Error('Не найден приватный clientKey отправителя');
|
|
const senderPublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(senderPrivPkcs8);
|
|
|
|
const incomingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({
|
|
plainBytes,
|
|
recipientClientKeyB64: String(targetUser.clientKey || '').trim(),
|
|
});
|
|
const outgoingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({
|
|
plainBytes,
|
|
recipientClientKeyB64: senderPublicKeyB64,
|
|
});
|
|
|
|
const incomingBlock = await this.buildSignedDmBlock({
|
|
signerLogin: cleanFromLogin,
|
|
fromLogin: cleanFromLogin,
|
|
toLogin: cleanToLogin,
|
|
storagePwd,
|
|
timeMs: normalizedTimeMs,
|
|
nonce: normalizedNonce,
|
|
messageType: DM_TYPE_INCOMING,
|
|
revisionTimeMs: normalizedRevisionTimeMs,
|
|
reencryptedAtMs: normalizedReencryptedAtMs,
|
|
bodyBytes: incomingEncryptedBodyBytes,
|
|
});
|
|
const outgoingBlock = await this.buildSignedDmBlock({
|
|
signerLogin: cleanFromLogin,
|
|
fromLogin: cleanFromLogin,
|
|
toLogin: cleanToLogin,
|
|
storagePwd,
|
|
timeMs: normalizedTimeMs,
|
|
nonce: normalizedNonce,
|
|
messageType: DM_TYPE_OUTGOING_COPY,
|
|
revisionTimeMs: normalizedRevisionTimeMs,
|
|
reencryptedAtMs: normalizedReencryptedAtMs,
|
|
bodyBytes: outgoingEncryptedBodyBytes,
|
|
});
|
|
|
|
const payload = await this.sendMessagePair({
|
|
incomingBlobB64: bytesToBase64(incomingBlock),
|
|
outgoingBlobB64: bytesToBase64(outgoingBlock),
|
|
});
|
|
return {
|
|
...payload,
|
|
localIncomingBlobB64: bytesToBase64(incomingBlock),
|
|
localOutgoingBlobB64: bytesToBase64(outgoingBlock),
|
|
localBaseKey: dmBaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs: normalizedTimeMs, nonce: normalizedNonce }),
|
|
};
|
|
}
|
|
|
|
async sendDirectMessage({ login, toLogin, text, storagePwd }) {
|
|
const cleanText = String(text || '');
|
|
if (!cleanText) throw new Error('Пустое сообщение');
|
|
return this.sendDirectMessageRevision({
|
|
login,
|
|
toLogin,
|
|
text: cleanText,
|
|
storagePwd,
|
|
timeMs: Date.now(),
|
|
nonce: Math.floor(Math.random() * 0x100000000),
|
|
revisionTimeMs: 0,
|
|
});
|
|
}
|
|
|
|
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs, deleteByRecipient = false }) {
|
|
const cleanLogin = this.normalizeDmLogin(login);
|
|
const cleanPeerLogin = this.normalizeDmLogin(toLogin);
|
|
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
|
|
const normalizedTimeMs = Number(timeMs);
|
|
const normalizedNonce = Number(nonce);
|
|
const normalizedRevisionTimeMs = Number(revisionTimeMs || 0);
|
|
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
|
|
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
|
|
if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs <= 0) throw new Error('Некорректный revisionTimeMs');
|
|
|
|
const block = await this.buildSignedDmBlock({
|
|
signerLogin: cleanLogin,
|
|
fromLogin: deleteByRecipient ? cleanPeerLogin : cleanLogin,
|
|
toLogin: deleteByRecipient ? cleanLogin : cleanPeerLogin,
|
|
storagePwd,
|
|
timeMs: normalizedTimeMs,
|
|
nonce: normalizedNonce,
|
|
messageType: deleteByRecipient ? DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT : DM_TYPE_MESSAGE_DELETED_BY_SENDER,
|
|
revisionTimeMs: normalizedRevisionTimeMs,
|
|
reencryptedAtMs: 0,
|
|
bodyBytes: new Uint8Array(0),
|
|
});
|
|
const blobB64 = bytesToBase64(block);
|
|
const response = await this.ws.request('DeleteMessage', { blobB64 });
|
|
if (response.status !== 200) throw opError('DeleteMessage', response);
|
|
return {
|
|
...(response.payload || {}),
|
|
localBlobB64: blobB64,
|
|
};
|
|
}
|
|
|
|
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce }) {
|
|
const cleanLogin = this.normalizeDmLogin(login);
|
|
const cleanToLogin = this.normalizeDmLogin(toLogin);
|
|
const cleanRefToLogin = this.normalizeDmLogin(refToLogin);
|
|
const cleanRefFromLogin = this.normalizeDmLogin(refFromLogin);
|
|
const timeMs = Date.now();
|
|
const nonce = Math.floor(Math.random() * 0x100000000);
|
|
const payload = buildReadReceiptPayloadBytes({
|
|
refToLogin: cleanRefToLogin,
|
|
refFromLogin: cleanRefFromLogin,
|
|
refTimeMs,
|
|
refNonce,
|
|
});
|
|
|
|
const type3 = await this.buildSignedDmBlock({
|
|
signerLogin: cleanLogin,
|
|
fromLogin: cleanLogin,
|
|
toLogin: cleanToLogin,
|
|
storagePwd,
|
|
timeMs,
|
|
nonce,
|
|
messageType: DM_TYPE_READ_INCOMING,
|
|
bodyBytes: payload,
|
|
});
|
|
const type4 = await this.buildSignedDmBlock({
|
|
signerLogin: cleanLogin,
|
|
fromLogin: cleanLogin,
|
|
toLogin: cleanToLogin,
|
|
storagePwd,
|
|
timeMs,
|
|
nonce,
|
|
messageType: DM_TYPE_READ_OUTGOING_COPY,
|
|
bodyBytes: payload,
|
|
});
|
|
return this.sendMessagePair({
|
|
incomingBlobB64: bytesToBase64(type3),
|
|
outgoingBlobB64: bytesToBase64(type4),
|
|
});
|
|
}
|
|
|
|
async deleteConversation({ login, toLogin, storagePwd, deleteByRecipient = false, timeMs = Date.now(), nonce = Math.floor(Math.random() * 0x100000000) }) {
|
|
const cleanLogin = this.normalizeDmLogin(login);
|
|
const cleanPeerLogin = this.normalizeDmLogin(toLogin);
|
|
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
|
|
const normalizedTimeMs = Number(timeMs);
|
|
const normalizedNonce = Number(nonce);
|
|
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
|
|
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
|
|
|
|
const block = await this.buildSignedDmBlock({
|
|
signerLogin: cleanLogin,
|
|
fromLogin: deleteByRecipient ? cleanPeerLogin : cleanLogin,
|
|
toLogin: deleteByRecipient ? cleanLogin : cleanPeerLogin,
|
|
storagePwd,
|
|
timeMs: normalizedTimeMs,
|
|
nonce: normalizedNonce,
|
|
messageType: deleteByRecipient ? DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT : DM_TYPE_CONVERSATION_DELETED_BY_SENDER,
|
|
revisionTimeMs: 0,
|
|
reencryptedAtMs: 0,
|
|
bodyBytes: new Uint8Array(0),
|
|
});
|
|
const blobB64 = bytesToBase64(block);
|
|
const response = await this.ws.request('DeleteConversation', { blobB64 });
|
|
if (response.status !== 200) throw opError('DeleteConversation', response);
|
|
return {
|
|
...(response.payload || {}),
|
|
localBlobB64: blobB64,
|
|
};
|
|
}
|
|
|
|
async getDirectMessages({ peerLogin, limit = 50, beforeTimeMs = 0, beforeMessageKey = '' } = {}) {
|
|
const payload = {
|
|
peerLogin: String(peerLogin || '').trim(),
|
|
limit: Number(limit || 0),
|
|
};
|
|
if (!payload.peerLogin) throw new Error('Не передан peerLogin');
|
|
if (Number.isFinite(Number(beforeTimeMs)) && Number(beforeTimeMs) > 0) {
|
|
payload.beforeTimeMs = Math.trunc(Number(beforeTimeMs));
|
|
}
|
|
if (String(beforeMessageKey || '').trim()) {
|
|
payload.beforeMessageKey = String(beforeMessageKey || '').trim();
|
|
}
|
|
const response = await this.ws.request('GetDirectMessages', payload);
|
|
if (response.status !== 200) throw opError('GetDirectMessages', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async ackSessionDelivery(messageKey) {
|
|
const response = await this.ws.request('AckSessionDelivery', { messageKey });
|
|
if (response.status !== 200) throw opError('AckSessionDelivery', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
|
|
async callInviteBroadcast({ toLogin, callId, type = 100, data = '' }) {
|
|
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type, data });
|
|
if (response.status !== 200) throw opError('CallInviteBroadcast', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async callSignalToSession({ toLogin, targetSessionId, callId, type, data = '' }) {
|
|
const response = await this.ws.request('CallSignalToSession', { toLogin, targetSessionId, callId, type, data });
|
|
if (response.status !== 200) throw opError('CallSignalToSession', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async getCallIceConfig() {
|
|
const response = await this.ws.request('GetCallIceConfig', {});
|
|
if (response.status !== 200) throw opError('GetCallIceConfig', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async sendCallDeliveryReport(payload = {}) {
|
|
const response = await this.ws.request('CallDeliveryReport', payload);
|
|
if (response.status !== 200) throw opError('CallDeliveryReport', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async listContacts() {
|
|
const response = await this.ws.request('ListContacts', {});
|
|
if (response.status !== 200) throw opError('ListContacts', response);
|
|
const payload = response.payload || {};
|
|
if (Array.isArray(payload.dialogs) && !Array.isArray(payload.contacts)) {
|
|
payload.contacts = extractContactsFromDialogs(payload.dialogs);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
|
|
async addCloseFriend(toLogin) {
|
|
const response = await this.ws.request('AddCloseFriend', { toLogin });
|
|
if (response.status !== 200) throw opError('AddCloseFriend', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async getUserCounters() {
|
|
const response = await this.ws.request('GetUserCounters', {});
|
|
if (response.status !== 200) throw opError('GetUserCounters', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async getNotifications(countsOnly = false) {
|
|
const response = await this.ws.request('GetNotifications', countsOnly ? { countsOnly: true } : {});
|
|
if (response.status !== 200) throw opError('GetNotifications', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async setNotificationSeen({ login, category, seenAtMs, storagePwd }) {
|
|
const cleanLogin = this.normalizeDmLogin(login);
|
|
const cleanCategory = String(category || '').trim().toLowerCase();
|
|
const categoryCode = NTF_CATEGORY[cleanCategory];
|
|
if (!cleanLogin || !categoryCode) throw new Error('Некорректный login/category уведомлений');
|
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи состояния уведомлений');
|
|
const normalizedSeenAtMs = Math.max(0, Math.trunc(Number(seenAtMs || 0)));
|
|
const timeMs = Date.now();
|
|
const nonce = Math.floor(Math.random() * 0x100000000);
|
|
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
|
const clientPriv = secrets?.clientKey;
|
|
if (!clientPriv) throw new Error('Не найден приватный clientKey');
|
|
const privateKey = await importPkcs8Ed25519(clientPriv);
|
|
const loginBytes = ensureAsciiBytes(cleanLogin, 'login');
|
|
const preimage = concatBytes(
|
|
NTF_PREFIX_V1,
|
|
uint8Bytes(NTF_FORMAT_VERSION_MAJOR), uint8Bytes(NTF_FORMAT_VERSION_MINOR),
|
|
uint8Bytes(loginBytes.length), loginBytes,
|
|
uint64Bytes(timeMs), uint32Bytes(nonce),
|
|
uint8Bytes(NTF_STATE_SEEN_WATERMARK), uint8Bytes(categoryCode),
|
|
uint64Bytes(normalizedSeenAtMs),
|
|
);
|
|
const signature = await signBytes(privateKey, preimage);
|
|
const response = await this.ws.request('SetNotificationState', { blobB64: bytesToBase64(concatBytes(preimage, signature)) });
|
|
if (response.status !== 200) throw opError('SetNotificationState', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async getUserConnectionsGraph(login) {
|
|
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
|
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async listUserProfileRelations(login, listType, limit = 100, offset = 0) {
|
|
const response = await this.ws.request('ListUserProfileRelations', { login, listType, limit, offset });
|
|
if (response.status !== 200) throw opError('ListUserProfileRelations', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async listUserProfileChannels(login, mode, limit = 100, offset = 0) {
|
|
const response = await this.ws.request('ListUserProfileChannels', { login, mode, limit, offset });
|
|
if (response.status !== 200) throw opError('ListUserProfileChannels', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async searchUsers(prefix, options = {}) {
|
|
const payload = { prefix };
|
|
if (typeof options?.isServer === 'boolean') {
|
|
payload.isServer = options.isServer;
|
|
}
|
|
const response = await this.ws.request('SearchUsers', payload);
|
|
if (response.status !== 200) throw opError('SearchUsers', response);
|
|
return response.payload?.logins || [];
|
|
}
|
|
|
|
|
|
|
|
async getUserParam(login, param) {
|
|
const cleanLogin = (login || '').trim();
|
|
const cleanParam = (param || '').trim();
|
|
if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param');
|
|
|
|
const response = await this.ws.request('GetUserParam', { login: cleanLogin, param: cleanParam });
|
|
if (response.status === 200) return response.payload || {};
|
|
|
|
if (response.status === 404 || response.status === 204) return {};
|
|
|
|
throw opError('GetUserParam', response);
|
|
}
|
|
|
|
async listUserParams(login) {
|
|
const cleanLogin = (login || '').trim();
|
|
if (!cleanLogin) throw new Error('Не передан login');
|
|
|
|
const response = await this.ws.request('ListUserParams', { login: cleanLogin });
|
|
if (response.status !== 200) throw opError('ListUserParams', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async listUserSettings(login) {
|
|
const cleanLogin = String(login || '').trim();
|
|
if (!cleanLogin) throw new Error('Не передан login');
|
|
|
|
const response = await this.ws.request('ListUserSettings', { login: cleanLogin });
|
|
if (response.status !== 200) throw opError('ListUserSettings', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async setChannelReadState({
|
|
login,
|
|
ownerBlockchainName,
|
|
channelName,
|
|
readCount,
|
|
timeMs,
|
|
storagePwd,
|
|
}) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const cleanOwnerBch = String(ownerBlockchainName || '').trim();
|
|
const cleanChannelName = String(channelName || '').trim();
|
|
const cleanReadCount = Math.max(0, Math.trunc(Number(readCount || 0)));
|
|
const cleanTimeMs = Math.trunc(Number(timeMs));
|
|
if (!cleanLogin || !cleanOwnerBch || !cleanChannelName) {
|
|
throw new Error('Не переданы login/ownerBlockchainName/channelName');
|
|
}
|
|
if (!Number.isFinite(cleanTimeMs) || cleanTimeMs <= 0) {
|
|
throw new Error('Не передан корректный timeMs');
|
|
}
|
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи SetChannelReadState.');
|
|
|
|
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
|
const clientPrivPkcs8 = String(secrets?.clientKey || '').trim();
|
|
if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey');
|
|
const privateKey = await importPkcs8Ed25519(clientPrivPkcs8);
|
|
const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
|
|
|
|
const preimage = 'SHiNe/ChannelReadState:' + [
|
|
escapeUserSettingPart(cleanLogin),
|
|
escapeUserSettingPart(cleanOwnerBch),
|
|
escapeUserSettingPart(cleanChannelName),
|
|
String(cleanTimeMs),
|
|
String(cleanReadCount),
|
|
].join('|');
|
|
const signature = await signBase64(privateKey, preimage);
|
|
|
|
const response = await this.ws.request('SetChannelReadState', {
|
|
login: cleanLogin,
|
|
owner_bch_name: cleanOwnerBch,
|
|
channel_name: cleanChannelName,
|
|
read_count: cleanReadCount,
|
|
time_ms: cleanTimeMs,
|
|
client_key: clientKey,
|
|
signature,
|
|
});
|
|
if (response.status !== 200) throw opError('SetChannelReadState', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async upsertUserSetting({
|
|
login,
|
|
settingType,
|
|
settingKey,
|
|
timeMs,
|
|
valueText = '',
|
|
valueNum = 0,
|
|
storagePwd,
|
|
}) {
|
|
const cleanLogin = String(login || '').trim();
|
|
const cleanSettingKey = String(settingKey || '').trim();
|
|
const cleanValueText = String(valueText ?? '');
|
|
const cleanTimeMs = Number(timeMs);
|
|
const cleanSettingType = Number(settingType);
|
|
const cleanValueNum = Number(valueNum ?? 0);
|
|
if (!cleanLogin || !cleanSettingKey) throw new Error('Не переданы login/settingKey');
|
|
if (!Number.isFinite(cleanTimeMs) || cleanTimeMs <= 0) throw new Error('Не передан корректный timeMs');
|
|
if (!Number.isFinite(cleanSettingType)) throw new Error('Не передан корректный settingType');
|
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи UpsertUserSetting.');
|
|
|
|
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
|
const clientPrivPkcs8 = String(secrets?.clientKey || '').trim();
|
|
if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey');
|
|
const privateKey = await importPkcs8Ed25519(clientPrivPkcs8);
|
|
const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
|
|
|
|
const preimage = 'SHiNe/UserSettings:' + [
|
|
escapeUserSettingPart(cleanLogin),
|
|
String(cleanSettingType),
|
|
escapeUserSettingPart(cleanSettingKey),
|
|
String(Math.trunc(cleanTimeMs)),
|
|
escapeUserSettingPart(cleanValueText),
|
|
String(Math.trunc(cleanValueNum)),
|
|
].join('|');
|
|
const signature = await signBase64(privateKey, preimage);
|
|
|
|
const response = await this.ws.request('UpsertUserSetting', {
|
|
login: cleanLogin,
|
|
setting_type: Math.trunc(cleanSettingType),
|
|
setting_key: cleanSettingKey,
|
|
time_ms: Math.trunc(cleanTimeMs),
|
|
value_text: cleanValueText,
|
|
value_num: Math.trunc(cleanValueNum),
|
|
client_key: clientKey,
|
|
signature,
|
|
});
|
|
if (response.status !== 200) throw opError('UpsertUserSetting', response);
|
|
return response.payload || {};
|
|
}
|
|
|
|
async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) {
|
|
const cleanKind = String(kind || '').trim().toLowerCase();
|
|
const kinds = CONNECTION_SUBTYPES[cleanKind];
|
|
if (!kinds) throw new Error(`Неподдерживаемый тип связи: ${kind}`);
|
|
const subType = enabled ? kinds.on : kinds.off;
|
|
return this.addBlockConnection({ login, toLogin, subType, storagePwd });
|
|
}
|
|
|
|
async addBlockUserParam({ login, param, value, storagePwd }) {
|
|
const cleanLogin = (login || '').trim();
|
|
const cleanParam = (param || '').trim();
|
|
const cleanValue = String(value ?? '').trim();
|
|
if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param.');
|
|
if (!cleanValue) throw new Error('Значение параметра не может быть пустым.');
|
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
|
|
const bodyBytes = makeUserParamBodyBytes({
|
|
lineCode: 0,
|
|
prevLineNumber: -1,
|
|
prevLineHashHex: ZERO_HASH_HEX,
|
|
thisLineNumber: -1,
|
|
key: cleanParam,
|
|
value: cleanValue,
|
|
});
|
|
return this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: 4,
|
|
msgSubType: 1,
|
|
msgVersion: 1,
|
|
bodyBytes,
|
|
});
|
|
}
|
|
|
|
async addBlockConnection({ login, toLogin, subType, storagePwd }) {
|
|
const cleanLogin = (login || '').trim();
|
|
const cleanToLogin = (toLogin || '').trim();
|
|
const cleanSubType = Number(subType);
|
|
if (!cleanLogin || !cleanToLogin) throw new Error('Не переданы login/toLogin для CONNECTION.');
|
|
if (!Number.isFinite(cleanSubType)) throw new Error('Не передан subType для CONNECTION.');
|
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
|
|
if (cleanLogin.toLowerCase() === cleanToLogin.toLowerCase()) {
|
|
throw new Error('Нельзя создать связь на самого себя.');
|
|
}
|
|
|
|
const user = await this.getUser(cleanLogin);
|
|
if (user?.exists === false) throw new Error('Текущий пользователь не найден.');
|
|
|
|
const targetUser = await this.getUser(cleanToLogin);
|
|
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
|
|
const targetBlockchainName = String(targetUser.blockchainName || '').trim();
|
|
const targetForkNumber = forkNumberFromBlockchainNameValue(targetBlockchainName);
|
|
if (!targetForkNumber) throw new Error('Не удалось определить fork пользователя цели.');
|
|
const targetHeaderHash = await this.resolveHeaderHashForBlockchain(targetBlockchainName);
|
|
if (targetHeaderHash === ZERO64) throw new Error('HEADER пользователя цели не найден.');
|
|
const bodyBytes = makeConnectionBodyBytes({
|
|
lineCode: 0,
|
|
prevLineNumber: -1,
|
|
prevLineHashHex: ZERO_HASH_HEX,
|
|
thisLineNumber: -1,
|
|
toLogin: cleanToLogin,
|
|
toForkNumber: targetForkNumber,
|
|
toBlockNumber: 0,
|
|
toBlockHashHex: targetHeaderHash,
|
|
});
|
|
return this.addBlockSigned({
|
|
login: cleanLogin,
|
|
storagePwd,
|
|
msgType: 3,
|
|
msgSubType: cleanSubType,
|
|
msgVersion: 1,
|
|
bodyBytes,
|
|
});
|
|
}
|
|
|
|
|
|
async reportClientDebug({ runId = '', level = 'info', message = '', details = '' } = {}) {
|
|
try {
|
|
const response = await this.ws.request('ClientDebugLog', { runId, level, message, details }, 3000);
|
|
return response?.status === 200;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async reportClientError(details) {
|
|
try {
|
|
const response = await this.ws.request('ClientErrorLog', details || {}, 3000);
|
|
return response?.status === 200;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async reportClientUiError(details = {}) {
|
|
try {
|
|
const payload = {
|
|
source: 'ui_error',
|
|
code: 'UI_RUNTIME_ERROR',
|
|
...details,
|
|
};
|
|
const response = await this.sendCallDeliveryReport({
|
|
type: 'ui_error',
|
|
value: JSON.stringify(payload),
|
|
});
|
|
return !!response;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
close() {
|
|
this.ws.close();
|
|
}
|
|
}
|