Каналы: добавить профиль через TEXT_CHANNEL_META

This commit is contained in:
AidarKC
2026-08-01 01:57:41 +04:00
parent 24afcba20a
commit 110ff5e12e
31 changed files with 1312 additions and 191 deletions
+161 -48
View File
@@ -54,6 +54,7 @@ const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
const MSG_SUBTYPE_TEXT_REPLY = 20;
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
const MSG_SUBTYPE_TEXT_REPOST = 30;
const MSG_SUBTYPE_TEXT_CHANNEL_META = 70;
const MSG_SUBTYPE_REACTION_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
@@ -698,6 +699,39 @@ function normalizeChannelDescription(value) {
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(`<SHiNE: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(`<SHiNE:avatar;v=1;size=${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: 'Введите логин пользователя.' };
@@ -806,6 +840,23 @@ function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, this
);
}
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 normalizeMessageRefTarget(target, actionName = 'action') {
const cleanBch = String(target?.blockchainName || '').trim();
const cleanBlockNumber = Number(target?.blockNumber);
@@ -2141,6 +2192,67 @@ export class AuthService {
});
}
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 rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (rootHashHex === ZERO64) {
const ownChannels = await this.listOwnChannelsForBlockchain(login, blockchainName);
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
if (!rootChannel) throw new Error('Channel root not found');
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
}
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 = Number.isFinite(latestLineStep)
? Math.max(0, latestLineStep + 1)
: Math.max(1, latestBlockNumber === lineCode ? 1 : 0);
}
} catch {
// fallback to root anchor
}
return {
ownerBlockchainName,
lineCode,
rootHashHex,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
};
}
async addBlockTextPost({ login, channel, text, storagePwd }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
@@ -2154,53 +2266,13 @@ export class AuthService {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const ownerBlockchainName = owner;
const lineCode = root;
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 rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (rootHashHex === ZERO64) {
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
if (!rootChannel) throw new Error('Channel root not found');
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
}
let prevLineNumber = lineCode;
let prevLineHashHex = rootHashHex;
let thisLineNumber = 0;
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 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;
// Для нового POST берём следующий шаг после последнего сообщения линии.
thisLineNumber = Number.isFinite(latestLineStep)
? Math.max(0, latestLineStep + 1)
: 1;
}
} catch {
// fallback to root anchor
}
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
const bodyBytes = makeTextPostBodyBytes({
lineCode,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
lineCode: tail.lineCode,
prevLineNumber: tail.prevLineNumber,
prevLineHashHex: tail.prevLineHashHex,
thisLineNumber: tail.thisLineNumber,
text: cleanText,
});
@@ -2216,9 +2288,50 @@ export class AuthService {
return {
...payload,
channel: {
ownerBlockchainName,
channelRootBlockNumber: lineCode,
channelRootBlockHash: rootHashHex,
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,
});
return {
...payload,
channel: {
ownerBlockchainName: tail.ownerBlockchainName,
channelRootBlockNumber: tail.lineCode,
channelRootBlockHash: tail.rootHashHex,
},
};
});