package blockchain.body; import blockchain.LineIndex; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Objects; /** * TextBody — type=1, ver=1. * * Формат bodyBytes (BigEndian): * [2] type=1 * [2] ver=1 * * [2] subType (uint16): подтип текстового сообщения * 1 = новое сообщение (начало ветки) * 2 = ответ на сообщение (reply) * 3 = репост (repost) * 4 = редактирование (edit) * * [2] textLenBytes (uint16) — длина текста в байтах UTF-8 * [N] text UTF-8 * * Далее ТОЛЬКО если subType == 2 или subType == 3 или subType == 4: * [1] toBlockchainNameLen (uint8) * [N] toBlockchainName UTF-8 * [4] toBlockGlobalNumber (int32) * [32] toBlockHash32 (raw 32 bytes) * * ЛИНИЯ: * - строго lineIndex=1 */ public final class TextBody implements BodyRecord, BodyHasTarget { public static final short TYPE = 1; public static final short VER = 1; public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); // subType: public static final short SUB_NEW = 1; public static final short SUB_REPLY = 2; public static final short SUB_REPOST = 3; public static final short SUB_EDIT = 4; /** Подтип текстового сообщения (1/2/3/4). */ public final short subType; /** Текст сообщения (строго валидный UTF-8, не пустой/не blank). */ public final String message; // Заполняются только если subType == SUB_REPLY || SUB_REPOST || SUB_EDIT public final String toBlockchainName; public final int toBlockGlobalNumber; public final byte[] toBlockHash32; /* ===================================================================== */ /* ====================== Конструктор из байт =========================== */ /* ===================================================================== */ /** Десериализация из полного bodyBytes (включая type/version). */ public TextBody(byte[] bodyBytes) { Objects.requireNonNull(bodyBytes, "bodyBytes == null"); // минимум: type+ver (4) + subType(2) + textLen(2) if (bodyBytes.length < 4 + 2 + 2) { throw new IllegalArgumentException("TextBody too short"); } ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); short type = bb.getShort(); short ver = bb.getShort(); if (type != TYPE || ver != VER) { throw new IllegalArgumentException("Not TextBody: type=" + type + " ver=" + ver); } this.subType = bb.getShort(); if (this.subType != SUB_NEW && this.subType != SUB_REPLY && this.subType != SUB_REPOST && this.subType != SUB_EDIT) { throw new IllegalArgumentException("Bad subType: " + (this.subType & 0xFFFF)); } int textLen = Short.toUnsignedInt(bb.getShort()); if (textLen <= 0) { throw new IllegalArgumentException("Text payload is empty"); } if (bb.remaining() < textLen) { throw new IllegalArgumentException("Text payload too short (len=" + textLen + ")"); } byte[] textBytes = new byte[textLen]; bb.get(textBytes); var decoder = StandardCharsets.UTF_8 .newDecoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT); try { this.message = decoder.decode(ByteBuffer.wrap(textBytes)).toString(); } catch (CharacterCodingException e) { throw new IllegalArgumentException("Text payload is not valid UTF-8", e); } if (this.message.isBlank()) { throw new IllegalArgumentException("Text message is blank"); } // Поля ссылки — только для reply/repost/edit if (this.subType == SUB_REPLY || this.subType == SUB_REPOST || this.subType == SUB_EDIT) { if (bb.remaining() < 1) { throw new IllegalArgumentException("Missing toBlockchainNameLen"); } int nameLen = Byte.toUnsignedInt(bb.get()); if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); if (bb.remaining() < nameLen + 4 + 32) { throw new IllegalArgumentException("Reply/Repost/Edit payload too short"); } byte[] nameBytes = new byte[nameLen]; bb.get(nameBytes); this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); this.toBlockGlobalNumber = bb.getInt(); this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32); // Запрет мусора в конце if (bb.remaining() != 0) { throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); } } else { // SUB_NEW this.toBlockchainName = null; this.toBlockGlobalNumber = 0; this.toBlockHash32 = null; // если кто-то подсунул хвост — лучше упасть, чтобы формат не “плыл” if (bb.remaining() != 0) { throw new IllegalArgumentException("Unexpected tail for subType=NEW, remaining=" + bb.remaining()); } } } /* ===================================================================== */ /* ====================== Конструкторы “для тестов” ====================== */ /* ===================================================================== */ public TextBody(String message) { this(SUB_NEW, message); } /** Сообщение subType=NEW (1). */ public TextBody(short subType, String message) { Objects.requireNonNull(message, "message == null"); if (subType != SUB_NEW) { throw new IllegalArgumentException("This constructor is only for SUB_NEW"); } if (message.isBlank()) { throw new IllegalArgumentException("message is blank"); } this.subType = subType; this.message = message; this.toBlockchainName = null; this.toBlockGlobalNumber = 0; this.toBlockHash32 = null; } /** Сообщение subType=REPLY (2) или subType=REPOST (3) или subType=EDIT (4) со ссылкой на блок. */ public TextBody(short subType, String message, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) { Objects.requireNonNull(message, "message == null"); Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); if (subType != SUB_REPLY && subType != SUB_REPOST && subType != SUB_EDIT) { throw new IllegalArgumentException("subType must be SUB_REPLY or SUB_REPOST or SUB_EDIT for this constructor"); } if (message.isBlank()) throw new IllegalArgumentException("message is blank"); if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); this.subType = subType; this.message = message; this.toBlockchainName = toBlockchainName; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); } /* ===================================================================== */ /* ====================== BodyRecord контракт =========================== */ /* ===================================================================== */ @Override public short type() { return TYPE; } @Override public short version() { return VER; } @Override public short subType() { return subType; } @Override public short expectedLineIndex() { return LineIndex.TEXT; } @Override public TextBody check() { if (subType != SUB_NEW && subType != SUB_REPLY && subType != SUB_REPOST && subType != SUB_EDIT) { throw new IllegalArgumentException("Bad subType: " + (subType & 0xFFFF)); } if (message == null || message.isBlank()) { throw new IllegalArgumentException("Text message is blank"); } if (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) { if (toBlockchainName == null || toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); } else { if (toBlockchainName != null) throw new IllegalArgumentException("toBlockchainName must be null for SUB_NEW"); if (toBlockHash32 != null) throw new IllegalArgumentException("toBlockHash32 must be null for SUB_NEW"); } return this; } @Override public byte[] toBytes() { byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); if (msgUtf8.length == 0) { throw new IllegalArgumentException("Text payload is empty"); } if (msgUtf8.length > 65535) { throw new IllegalArgumentException("Text too long (>65535 bytes)"); } // base: type+ver + subType + textLen + textBytes int cap = 4 + 2 + 2 + msgUtf8.length; byte[] nameBytes = null; if (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) { nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); if (nameBytes.length == 0 || nameBytes.length > 255) { throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); } if (toBlockHash32 == null || toBlockHash32.length != 32) { throw new IllegalArgumentException("toBlockHash32 != 32"); } cap += 1 + nameBytes.length + 4 + 32; } else { if (toBlockchainName != null || toBlockHash32 != null) { throw new IllegalArgumentException("SUB_NEW must not contain reply/repost/edit fields"); } } ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); bb.putShort(TYPE); bb.putShort(VER); bb.putShort(subType); bb.putShort((short) msgUtf8.length); bb.put(msgUtf8); if (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) { bb.put((byte) nameBytes.length); bb.put(nameBytes); bb.putInt(toBlockGlobalNumber); bb.put(toBlockHash32); } return bb.array(); } @Override public String toString() { String st = switch (subType) { case SUB_NEW -> "NEW (1)"; case SUB_REPLY -> "REPLY (2)"; case SUB_REPOST -> "REPOST (3)"; case SUB_EDIT -> "EDIT (4)"; default -> "UNKNOWN"; }; if (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) { return """ TextBody { тип записи : TEXT (type=1, ver=1) ожидаемая линия : 1 subType : %s длина сообщения : %d байт текст сообщения : "%s" ссылка на блок : "%s" #%d hash цели (hex) : %s } """.formatted( st, message.getBytes(StandardCharsets.UTF_8).length, message, toBlockchainName, toBlockGlobalNumber, toBlockHashHex() ); } return """ TextBody { тип записи : TEXT (type=1, ver=1) ожидаемая линия : 1 subType : %s длина сообщения : %d байт текст сообщения : "%s" } """.formatted( st, message.getBytes(StandardCharsets.UTF_8).length, message ); } public String toBlockHashHex() { if (toBlockHash32 == null) return "null"; char[] HEX = "0123456789abcdef".toCharArray(); char[] out = new char[64]; for (int i = 0; i < 32; i++) { int v = toBlockHash32[i] & 0xFF; out[i * 2] = HEX[v >>> 4]; out[i * 2 + 1] = HEX[v & 0x0F]; } return new String(out); } /* ===================================================================== */ /* ====================== BodyHasTarget контракт ========================= */ /* ===================================================================== */ /** В формате TextBody login цели не хранится => null. */ @Override public String toLogin() { return null; } @Override public String toBchName() { return (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) ? toBlockchainName : null; } @Override public Integer toBlockGlobalNumber() { return (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) ? toBlockGlobalNumber : null; } @Override public byte[] toBlockHasheBytes() { return (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) ? toBlockHash32 : null; } }