From d5124423252869d085f95b4727d4765bcdb53cb48094ece85853de635d2442c1 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 9 Aug 2026 18:59:59 +0400 Subject: [PATCH 01/27] =?UTF-8?q?=D0=A1=D0=B5=D1=80=D0=B2=D0=B5=D1=80:=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B5=20TEXT=20=D1=82=D0=B8=D0=BF=D1=8B=20=D0=B8?= =?UTF-8?q?=20STATUS=5FACTION?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/blockchain/BodyRecordParser.java | 9 + .../src/main/java/blockchain/MsgSubType.java | 29 +- .../blockchain/body/StatusActionBody.java | 165 ++++ .../java/blockchain/body/TextLineBody.java | 52 +- .../java/blockchain/body/TextRatingBody.java | 157 ++++ .../java/shine/db/DatabaseInitializer.java | 9 +- .../src/main/java/shine/db/MsgSubType.java | 29 +- .../java/shine/db/dao/SubscriptionsDAO.java | 16 +- .../blockchain/Net_AddBlock_Handler.java | 67 ++ .../channels/ChannelsReadSupport.java | 47 +- .../Net_GetChannelMessages_Handler.java | 2 +- .../Net_GetMessageThread_Handler.java | 2 +- .../Net_MarkChannelMessagesSeen_Handler.java | 9 +- VERSION.properties | 2 +- docs/API/04_Add_Block_to_Blockchain_API.md | 23 +- .../00_Blockchain_Formats_and_Block_Types.md | 4 +- docs/Blockchain/02_Channel_Commands.md | 2 +- docs/Blockchain/11_TEXT_Blocks.md | 34 +- docs/Blockchain/15_STATUS_ACTION_Blocks.md | 62 ++ docs/Blockchain/16_TEXT_Channel_Meta.md | 2 +- docs/Blockchain/CHANGELOG.md | 10 + docs/Blockchain/README.md | 12 +- Черновик_CHANNEL_MEMBERSHIP_для_каналов.md | 60 ++ ...олнений_каналов_и_типов_сообщений_SHiNE.md | 723 ++++++++++++++++++ 24 files changed, 1479 insertions(+), 48 deletions(-) create mode 100644 SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java create mode 100644 SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextRatingBody.java create mode 100644 docs/Blockchain/15_STATUS_ACTION_Blocks.md create mode 100644 Черновик_CHANNEL_MEMBERSHIP_для_каналов.md create mode 100644 Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java index 21103a16..35cf71b0 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java @@ -36,6 +36,10 @@ public final class BodyRecordParser { case TextBody.KEY -> { if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) + || st == (MsgSubType.TEXT_EXERCISE & 0xFFFF) + || st == (MsgSubType.TEXT_SERVICE & 0xFFFF) + || st == (MsgSubType.TEXT_COURSE & 0xFFFF) + || st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { yield new TextLineBody(subType, version, bodyBytes); @@ -46,12 +50,17 @@ public final class BodyRecordParser { yield new TextReplyBody(subType, version, bodyBytes); } + if (st == (MsgSubType.TEXT_RATING & 0xFFFF)) { + yield new TextRatingBody(subType, version, bodyBytes); + } + throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st); } case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes); case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes); case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes); + case StatusActionBody.KEY -> new StatusActionBody(subType, version, bodyBytes); default -> throw new IllegalArgumentException(String.format( "Unknown body type/version from header: type=%d ver=%d subType=%d", diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java index f5b72745..4588d1b8 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java @@ -64,17 +64,29 @@ public final class MsgSubType { */ public static final short TEXT_EDIT_REPLY = 21; + /** RATING — target-based отзыв на конкретный блок. */ + public static final short TEXT_RATING = 30; + /** - * REPOST — репост сообщения в линии канала. + * REPOST — отложенная будущая заготовка репоста сообщения в линии канала. * Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария. */ - public static final short TEXT_REPOST = 30; + public static final short TEXT_REPOST = 50; /** * CHANNEL_META — скрытый технический снимок профиля канала. * Имеет hasLine, использует body как POST: line-поля + UTF-8 текст. */ - public static final short TEXT_CHANNEL_META = 70; + public static final short TEXT_CHANNEL_META = 90; + + /** ENTRYPOINT — входная страница канала (line-based). */ + public static final short TEXT_ENTRYPOINT = 100; + /** EXERCISE — упражнение/комплекс (line-based). */ + public static final short TEXT_EXERCISE = 110; + /** SERVICE — услуга/процедура (line-based). */ + public static final short TEXT_SERVICE = 120; + /** COURSE — курс (line-based). */ + public static final short TEXT_COURSE = 130; /* ===================== REACTION (msg_type=2) ===================== */ @@ -144,4 +156,15 @@ public final class MsgSubType { /** Параметр профиля key/value (обе строки). */ public static final short USER_PARAM_TEXT_TEXT = 1; + + /* ===================== STATUS_ACTION (msg_type=5) ===================== */ + + public static final short STATUS_DONE_ONCE = 10; + public static final short STATUS_INTERESTED = 20; + public static final short STATUS_STARTED = 30; + public static final short STATUS_IN_STUDY = 40; + public static final short STATUS_COMPLETED = 50; + public static final short STATUS_ABANDONED = 60; + public static final short STATUS_LEARNED = 70; + public static final short STATUS_CONFIRMED = 80; } diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java new file mode 100644 index 00000000..d9979386 --- /dev/null +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java @@ -0,0 +1,165 @@ +package blockchain.body; + +import blockchain.MsgSubType; + +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; + +/** + * StatusActionBody — type=5, ver=1. + * + * Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение. + * + * Формат bodyBytes (BigEndian): + * [1] toBlockchainNameLen (uint8) + * [N] toBlockchainName UTF-8 + * [4] toBlockGlobalNumber + * [32] toBlockHash32 + * [2] textLenBytes (uint16) + * [M] text UTF-8 + */ +public final class StatusActionBody implements BodyRecord, BodyHasTarget { + + public static final short TYPE = 5; + public static final short VER = 1; + public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); + + public final short subType; + public final short version; + public final String toBlockchainName; + public final int toBlockGlobalNumber; + public final byte[] toBlockHash32; + public final String message; + + public StatusActionBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + this.subType = subType; + this.version = version; + + if ((version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("StatusActionBody version must be 1, got=" + (version & 0xFFFF)); + } + if (!isSupportedSubType(subType)) { + throw new IllegalArgumentException("Unsupported STATUS_ACTION subType: " + (subType & 0xFFFF)); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short"); + + int nameLen = Byte.toUnsignedInt(bb.get()); + if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0"); + ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION 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); + this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text"); + + ensureNoTail(bb, "StatusActionBody"); + } + + public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) { + Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + Objects.requireNonNull(message, "message == null"); + if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType"); + 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.version = VER; + this.toBlockchainName = toBlockchainName; + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + this.message = message; + } + + @Override + public StatusActionBody check() { + if (!isSupportedSubType(subType)) { + throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF)); + } + if (toBlockchainName == null || toBlockchainName.isBlank()) { + throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank"); + } + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32 == null || toBlockHash32.length != 32) { + throw new IllegalArgumentException("toBlockHash32 invalid"); + } + if (message == null) throw new IllegalArgumentException("message is null"); + return this; + } + + @Override + public byte[] toBytes() { + byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); + if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); + + byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (nameUtf8.length == 0 || nameUtf8.length > 255) { + throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255"); + } + + ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length) + .order(ByteOrder.BIG_ENDIAN); + bb.put((byte) nameUtf8.length); + bb.put(nameUtf8); + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + return bb.array(); + } + + @Override public String toBchName() { return toBlockchainName; } + @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } + @Override public byte[] toBlockHashBytes() { return toBlockHash32; } + + private static boolean isSupportedSubType(short subType) { + int st = subType & 0xFFFF; + return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF) + || st == (MsgSubType.STATUS_INTERESTED & 0xFFFF) + || st == (MsgSubType.STATUS_STARTED & 0xFFFF) + || st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF) + || st == (MsgSubType.STATUS_COMPLETED & 0xFFFF) + || st == (MsgSubType.STATUS_ABANDONED & 0xFFFF) + || st == (MsgSubType.STATUS_LEARNED & 0xFFFF) + || st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF); + } + + private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) { + int len = Short.toUnsignedInt(bb.getShort()); + if (len == 0) return ""; + if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); + + byte[] bytes = new byte[len]; + bb.get(bytes); + + var decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + + try { + return decoder.decode(ByteBuffer.wrap(bytes)).toString(); + } catch (CharacterCodingException e) { + throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); + } + } + + private static void ensureMin(ByteBuffer bb, int need, String msg) { + if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")"); + } + + private static void ensureNoTail(ByteBuffer bb, String ctx) { + if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining()); + } +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java index 2e392f12..8f3b4917 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java @@ -16,12 +16,16 @@ import java.util.Objects; * subType: * - POST (10) * - EDIT_POST (11) - * - REPOST (30) - * - CHANNEL_META (70) + * - REPOST (50) + * - CHANNEL_META (90) + * - ENTRYPOINT (100) + * - EXERCISE (110) + * - SERVICE (120) + * - COURSE (130) * * Формат bodyBytes (BigEndian): * - * POST / CHANNEL_META: + * POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE: * [4] lineCode * [4] prevLineNumber * [32] prevLineHash32 @@ -91,7 +95,11 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) && st != (MsgSubType.TEXT_REPOST & 0xFFFF) - && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { + && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF) + && st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) + && st != (MsgSubType.TEXT_EXERCISE & 0xFFFF) + && st != (MsgSubType.TEXT_SERVICE & 0xFFFF) + && st != (MsgSubType.TEXT_COURSE & 0xFFFF)) { throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st); } @@ -159,12 +167,16 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) && st != (MsgSubType.TEXT_REPOST & 0xFFFF) - && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { + && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF) + && st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) + && st != (MsgSubType.TEXT_EXERCISE & 0xFFFF) + && st != (MsgSubType.TEXT_SERVICE & 0xFFFF) + && st != (MsgSubType.TEXT_COURSE & 0xFFFF)) { throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META"); } if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - if (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) { + if (requiresNonBlankMessage(st) && message.isBlank()) { throw new IllegalArgumentException("message is blank"); } @@ -211,7 +223,11 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) && st != (MsgSubType.TEXT_REPOST & 0xFFFF) - && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) + && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF) + && st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) + && st != (MsgSubType.TEXT_EXERCISE & 0xFFFF) + && st != (MsgSubType.TEXT_SERVICE & 0xFFFF) + && st != (MsgSubType.TEXT_COURSE & 0xFFFF)) throw new IllegalArgumentException("Bad TextLineBody subType: " + st); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); @@ -238,8 +254,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge } else { if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null"); - } else if (message == null || message.isBlank()) { + } else if (requiresNonBlankMessage(st) && (message == null || message.isBlank())) { throw new IllegalArgumentException("Text message is blank"); + } else if (message == null) { + throw new IllegalArgumentException("Text message is null"); } if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null) throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields"); @@ -254,12 +272,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); int st = subType & 0xFFFF; - if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) { + if (requiresNonBlankMessage(st) && msgUtf8.length == 0) { throw new IllegalArgumentException("Text payload is empty"); } int cap; - if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { + if (st == (MsgSubType.TEXT_POST & 0xFFFF) + || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF) + || st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) + || st == (MsgSubType.TEXT_EXERCISE & 0xFFFF) + || st == (MsgSubType.TEXT_SERVICE & 0xFFFF) + || st == (MsgSubType.TEXT_COURSE & 0xFFFF)) { cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length; } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { // EDIT_POST @@ -318,6 +341,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF); } + private static boolean requiresNonBlankMessage(int st) { + return st == (MsgSubType.TEXT_POST & 0xFFFF) + || st == (MsgSubType.TEXT_REPOST & 0xFFFF) + || st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) + || st == (MsgSubType.TEXT_EXERCISE & 0xFFFF) + || st == (MsgSubType.TEXT_SERVICE & 0xFFFF) + || st == (MsgSubType.TEXT_COURSE & 0xFFFF); + } + private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) { int len = Short.toUnsignedInt(bb.getShort()); if (len == 0) { diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextRatingBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextRatingBody.java new file mode 100644 index 00000000..270a3904 --- /dev/null +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextRatingBody.java @@ -0,0 +1,157 @@ +package blockchain.body; + +import blockchain.MsgSubType; + +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; + +/** + * TextRatingBody — type=1, ver=1. + * + * subType: + * - RATING (30) + * + * Формат bodyBytes (BigEndian): + * [1] toBlockchainNameLen (uint8) + * [N] toBlockchainName UTF-8 + * [4] toBlockGlobalNumber + * [32] toBlockHash32 + * [2] textLenBytes (uint16) + * [M] text UTF-8 + */ +public final class TextRatingBody 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); + + public final short subType; + public final short version; + public final String toBlockchainName; + public final int toBlockGlobalNumber; + public final byte[] toBlockHash32; + public final String message; + + public TextRatingBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + this.subType = subType; + this.version = version; + + if ((version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("TextRatingBody version must be 1, got=" + (version & 0xFFFF)); + } + if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) { + throw new IllegalArgumentException("TextRatingBody supports only TEXT_RATING"); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short"); + + int nameLen = Byte.toUnsignedInt(bb.get()); + if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0"); + ensureMin(bb, nameLen + 4 + 32 + 2, "RATING 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); + this.message = readStrictUtf8Len16(bb, "TextRatingBody text"); + + ensureNoTail(bb, "TextRatingBody"); + } + + public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) { + Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + Objects.requireNonNull(message, "message == null"); + 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"); + if (message.isBlank()) throw new IllegalArgumentException("message is blank"); + + this.subType = MsgSubType.TEXT_RATING; + this.version = VER; + this.toBlockchainName = toBlockchainName; + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + this.message = message; + } + + @Override + public TextRatingBody check() { + if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) { + throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF)); + } + if (toBlockchainName == null || toBlockchainName.isBlank()) { + throw new IllegalArgumentException("RATING toBlockchainName is blank"); + } + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32 == null || toBlockHash32.length != 32) { + throw new IllegalArgumentException("toBlockHash32 invalid"); + } + if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank"); + 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)"); + + byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (nameUtf8.length == 0 || nameUtf8.length > 255) { + throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255"); + } + + ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length) + .order(ByteOrder.BIG_ENDIAN); + bb.put((byte) nameUtf8.length); + bb.put(nameUtf8); + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + return bb.array(); + } + + @Override public String toBchName() { return toBlockchainName; } + @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } + @Override public byte[] toBlockHashBytes() { return toBlockHash32; } + + private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) { + int len = Short.toUnsignedInt(bb.getShort()); + if (len == 0) throw new IllegalArgumentException(fieldName + " is empty"); + if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); + + byte[] bytes = new byte[len]; + bb.get(bytes); + + var decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + + try { + String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); + if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); + return s; + } catch (CharacterCodingException e) { + throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); + } + } + + private static void ensureMin(ByteBuffer bb, int need, String msg) { + if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")"); + } + + private static void ensureNoTail(ByteBuffer bb, String ctx) { + if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining()); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 7bccfda8..793c49a1 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -32,8 +32,13 @@ public final class DatabaseInitializer { public static final short TEXT_EDIT_POST = 11; public static final short TEXT_REPLY = 20; public static final short TEXT_EDIT_REPLY = 21; - public static final short TEXT_REPOST = 30; - public static final short TEXT_CHANNEL_META = 70; + public static final short TEXT_RATING = 30; + public static final short TEXT_REPOST = 50; + public static final short TEXT_CHANNEL_META = 90; + public static final short TEXT_ENTRYPOINT = 100; + public static final short TEXT_EXERCISE = 110; + public static final short TEXT_SERVICE = 120; + public static final short TEXT_COURSE = 130; public static final short REACTION_LIKE = 1; public static final short REACTION_UNLIKE = 2; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java index 6952cd20..11d3b91b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java @@ -30,11 +30,23 @@ public final class MsgSubType { /** EDIT_REPLY — редактирование исходного ответа. */ public static final short TEXT_EDIT_REPLY = 21; - /** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */ - public static final short TEXT_REPOST = 30; + /** RATING — target-based отзыв на конкретный блок. */ + public static final short TEXT_RATING = 30; + + /** REPOST — отложенная будущая заготовка репоста сообщения в линии канала. */ + public static final short TEXT_REPOST = 50; /** CHANNEL_META — скрытый технический снимок профиля канала. */ - public static final short TEXT_CHANNEL_META = 70; + public static final short TEXT_CHANNEL_META = 90; + + /** ENTRYPOINT — входная страница канала (line-based). */ + public static final short TEXT_ENTRYPOINT = 100; + /** EXERCISE — упражнение/комплекс (line-based). */ + public static final short TEXT_EXERCISE = 110; + /** SERVICE — услуга/процедура (line-based). */ + public static final short TEXT_SERVICE = 120; + /** COURSE — курс (line-based). */ + public static final short TEXT_COURSE = 130; /* ===================== REACTION (msg_type=2) ===================== */ @@ -123,6 +135,17 @@ public final class MsgSubType { /** Параметр профиля key/value (обе строки). */ public static final short USER_PARAM_TEXT_TEXT = 1; + /* ===================== STATUS_ACTION (msg_type=5) ===================== */ + + public static final short STATUS_DONE_ONCE = 10; + public static final short STATUS_INTERESTED = 20; + public static final short STATUS_STARTED = 30; + public static final short STATUS_IN_STUDY = 40; + public static final short STATUS_COMPLETED = 50; + public static final short STATUS_ABANDONED = 60; + public static final short STATUS_LEARNED = 70; + public static final short STATUS_CONFIRMED = 80; + /* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */ // Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения, // не трогая уже занятые коды. diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java index cd089db4..83882403 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java @@ -13,7 +13,7 @@ import java.util.List; * Возвращает по каждой активной подписке (FOLLOW) + "сам на себя": * - login цели (channelLogin) * - blockchainName цели (channelBchName) - * - count публикаций (TEXT_POST) + * - count публикаций (видимые line-based TEXT-сообщения канала) * - last publication: bytes оригинального блока (для timestamp) * - last publication: bytes актуального блока (edit или orig) — для текста превью * @@ -92,7 +92,7 @@ public final class SubscriptionsDAO { /** * Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой: - * - count публикаций (TEXT_POST) + * - count публикаций (видимые line-based TEXT-сообщения канала) * - последнюю публикацию (orig bytes) + её edit (если есть) * * Поведение при 0 публикаций: @@ -131,7 +131,7 @@ public final class SubscriptionsDAO { ON s.channel_login = b.login AND s.channel_bch_name = b.bch_name WHERE b.msg_type = ? - AND b.msg_sub_type IN (?, ?) + AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?) GROUP BY b.login, b.bch_name ), last_pub AS ( @@ -144,7 +144,7 @@ public final class SubscriptionsDAO { ON s.channel_login = b.login AND s.channel_bch_name = b.bch_name WHERE b.msg_type = ? - AND b.msg_sub_type IN (?, ?) + AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?) GROUP BY b.login, b.bch_name ), last_pub_block AS ( @@ -209,11 +209,19 @@ public final class SubscriptionsDAO { ps.setInt(i++, MSG_TYPE_TEXT); ps.setInt(i++, (int) MsgSubType.TEXT_POST); ps.setInt(i++, (int) MsgSubType.TEXT_REPOST); + ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT); + ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE); + ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE); + ps.setInt(i++, (int) MsgSubType.TEXT_COURSE); // last_pub ps.setInt(i++, MSG_TYPE_TEXT); ps.setInt(i++, (int) MsgSubType.TEXT_POST); ps.setInt(i++, (int) MsgSubType.TEXT_REPOST); + ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT); + ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE); + ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE); + ps.setInt(i++, (int) MsgSubType.TEXT_COURSE); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 5b3e6d3f..c487fd4f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -6,6 +6,7 @@ import blockchain.MsgSubType; import blockchain.body.BodyHasLine; import blockchain.body.BodyHasTarget; import blockchain.body.CreateChannelBody; +import blockchain.body.StatusActionBody; import blockchain.body.TextLineBody; import blockchain.body.UserParamBody; import org.slf4j.Logger; @@ -167,6 +168,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine"; case "channel_name_already_exists" -> "Такое название канала уже занято"; case "repost_disabled" -> "Репосты временно отключены до будущей реализации"; + case "entrypoint_edit_forbidden" -> "TEXT_ENTRYPOINT нельзя редактировать через TEXT_EDIT_POST"; + case "status_confirmed_target_must_be_status_action" -> "STATUS_CONFIRMED должен ссылаться на STATUS_ACTION"; case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется"; default -> "Ошибка: " + code; @@ -388,6 +391,34 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L); } + if ((block.type & 0xFFFF) == 1 + && (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + try { + String editError = validateEditPostTarget(blockchainName, block); + if (editError != null) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, editError, serverLastNum, serverLastHashHex); + } + } catch (Exception e) { + log.error("AddBlock: edit_post_target_check_failed (login={}, blockchainName={}, blockNumber={})", + login, blockchainName, block.blockNumber, e); + return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); + } + } + + if ((block.type & 0xFFFF) == 5 + && (block.subType & 0xFFFF) == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) { + try { + String statusError = validateStatusConfirmedTarget(block); + if (statusError != null) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, statusError, serverLastNum, serverLastHashHex); + } + } catch (Exception e) { + log.error("AddBlock: status_confirmed_target_check_failed (login={}, blockchainName={}, blockNumber={})", + login, blockchainName, block.blockNumber, e); + return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); + } + } + // 4.2) запрет дырок: blockNumber строго last+1 int expectedBlockNumber = serverLastNum + 1; if (block.blockNumber != expectedBlockNumber) { @@ -605,6 +636,42 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { String slug; } + private String validateEditPostTarget(String ownerBch, BchBlockEntry block) throws Exception { + if (!(block.body instanceof TextLineBody editBody)) return null; + Integer targetBlockNumber = editBody.toBlockGlobalNumber(); + byte[] targetHash = editBody.toBlockHashBytes(); + if (targetBlockNumber == null || targetHash == null || targetHash.length != 32) return "bad_block_body"; + + BlockEntry target = blocksDAO.getByNumber(ownerBch, targetBlockNumber); + if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) { + return null; + } + if (target.getMsgType() != 1) return null; + if (target.getMsgSubType() == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)) { + return "entrypoint_edit_forbidden"; + } + return null; + } + + private String validateStatusConfirmedTarget(BchBlockEntry block) throws Exception { + if (!(block.body instanceof StatusActionBody statusBody)) return "bad_block_body"; + String targetBch = statusBody.toBchName(); + Integer targetBlockNumber = statusBody.toBlockGlobalNumber(); + byte[] targetHash = statusBody.toBlockHashBytes(); + if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) { + return "bad_block_body"; + } + + BlockEntry target = blocksDAO.getByNumber(targetBch, targetBlockNumber); + if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) { + return null; + } + if (target.getMsgType() != 5) { + return "status_confirmed_target_must_be_status_action"; + } + return null; + } + private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) throws Exception { try (Connection c = shine.db.DbController.getInstance().getConnection(); PreparedStatement ps = c.prepareStatement(""" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index 35fbfc29..a3a2d560 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -22,6 +22,7 @@ final class ChannelsReadSupport { static final int MSG_TYPE_TEXT = 1; static final int MSG_TYPE_REACTION = 2; static final int MSG_TYPE_TECH = 0; + static final int MSG_TYPE_STATUS_ACTION = 5; static final String STORIES_CHANNEL_NAME = "stories"; static final String COMMAND_ADD = "add"; static final String COMMAND_REMOVE = "remove"; @@ -126,13 +127,17 @@ final class ChannelsReadSupport { } static int countPosts(Connection c, String ownerBch, int lineCode) throws SQLException { - String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?"; + String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?"; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, ownerBch); ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(3, MsgSubType.TEXT_POST); ps.setInt(4, MsgSubType.TEXT_REPOST); - ps.setInt(5, lineCode); + ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT); + ps.setInt(6, MsgSubType.TEXT_EXERCISE); + ps.setInt(7, MsgSubType.TEXT_SERVICE); + ps.setInt(8, MsgSubType.TEXT_COURSE); + ps.setInt(9, lineCode); try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getInt("cnt") : 0; } @@ -143,7 +148,7 @@ final class ChannelsReadSupport { String sql = """ SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number FROM blocks - WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=? + WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=? ORDER BY block_number DESC LIMIT 1 """; @@ -152,7 +157,11 @@ final class ChannelsReadSupport { ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(3, MsgSubType.TEXT_POST); ps.setInt(4, MsgSubType.TEXT_REPOST); - ps.setInt(5, lineCode); + ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT); + ps.setInt(6, MsgSubType.TEXT_EXERCISE); + ps.setInt(7, MsgSubType.TEXT_SERVICE); + ps.setInt(8, MsgSubType.TEXT_COURSE); + ps.setInt(9, lineCode); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) return null; PostBlock pb = new PostBlock(); @@ -204,6 +213,10 @@ final class ChannelsReadSupport { ti.text = tlb.message; } else if (e.body instanceof TextReplyBody trb) { ti.text = trb.message; + } else if (e.body instanceof blockchain.body.TextRatingBody trb) { + ti.text = trb.message; + } else if (e.body instanceof blockchain.body.StatusActionBody sab) { + ti.text = sab.message; } else if (e.body instanceof TextBody tb) { ti.text = tb.message; } @@ -218,7 +231,7 @@ final class ChannelsReadSupport { String sql = """ SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number FROM blocks - WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=? + WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=? ORDER BY block_number """ + order + " LIMIT ?"; try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -226,8 +239,12 @@ final class ChannelsReadSupport { ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(3, MsgSubType.TEXT_POST); ps.setInt(4, MsgSubType.TEXT_REPOST); - ps.setInt(5, lineCode); - ps.setInt(6, limit); + ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT); + ps.setInt(6, MsgSubType.TEXT_EXERCISE); + ps.setInt(7, MsgSubType.TEXT_SERVICE); + ps.setInt(8, MsgSubType.TEXT_COURSE); + ps.setInt(9, lineCode); + ps.setInt(10, limit); try (ResultSet rs = ps.executeQuery()) { List out = new ArrayList<>(); while (rs.next()) { @@ -589,6 +606,22 @@ final class ChannelsReadSupport { return sb.toString(); } + static boolean isChannelFeedSubType(int subType) { + return subType == MsgSubType.TEXT_POST + || subType == MsgSubType.TEXT_REPOST + || subType == MsgSubType.TEXT_ENTRYPOINT + || subType == MsgSubType.TEXT_EXERCISE + || subType == MsgSubType.TEXT_SERVICE + || subType == MsgSubType.TEXT_COURSE; + } + + static boolean supportsEditPostVersions(int subType) { + return subType == MsgSubType.TEXT_POST + || subType == MsgSubType.TEXT_EXERCISE + || subType == MsgSubType.TEXT_SERVICE + || subType == MsgSubType.TEXT_COURSE; + } + static final class PostBlock { String login; String bchName; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index 9a882ea4..882ad706 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -143,7 +143,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { v1.setCreatedAtMs(postText.createdAtMs); versionsOut.add(v1); - if (post.msgSubType == MsgSubType.TEXT_POST) { + if (ChannelsReadSupport.supportsEditPostVersions(post.msgSubType)) { List edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash); for (ChannelsReadSupport.PostBlock edit : edits) { ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java index 243ea507..83b045b1 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java @@ -208,7 +208,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { first.setCreatedAtMs(base.createdAtMs); versions.add(first); - if (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_POST) { + if (row.msgSubType == MsgSubType.TEXT_REPLY || ChannelsReadSupport.supportsEditPostVersions(row.msgSubType)) { short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST; for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) { ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java index a41b9811..4f1c167c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java @@ -58,7 +58,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler { AND block_number = ? AND block_hash = ? AND msg_type = ? - AND msg_sub_type = ? + AND msg_sub_type IN (?, ?, ?, ?, ?, ?) %s LIMIT 1 """.formatted(strictChannelMatch ? "AND line_code = ?" : ""); @@ -98,8 +98,13 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler { existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex)); existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT); existsPs.setInt(5, MsgSubType.TEXT_POST); + existsPs.setInt(6, MsgSubType.TEXT_REPOST); + existsPs.setInt(7, MsgSubType.TEXT_ENTRYPOINT); + existsPs.setInt(8, MsgSubType.TEXT_EXERCISE); + existsPs.setInt(9, MsgSubType.TEXT_SERVICE); + existsPs.setInt(10, MsgSubType.TEXT_COURSE); if (strictChannelMatch) { - existsPs.setInt(6, expectedRoot); + existsPs.setInt(11, expectedRoot); } boolean exists; diff --git a/VERSION.properties b/VERSION.properties index 64d6dd3f..6e597b10 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ client.version=1.5.4 -server.version=1.4.5 +server.version=1.4.6 diff --git a/docs/API/04_Add_Block_to_Blockchain_API.md b/docs/API/04_Add_Block_to_Blockchain_API.md index c156605f..64584640 100644 --- a/docs/API/04_Add_Block_to_Blockchain_API.md +++ b/docs/API/04_Add_Block_to_Blockchain_API.md @@ -88,6 +88,8 @@ - `limit_exceeded` - `chain_resync_in_progress` — цепочка временно заблокирована полным resync - `repost_disabled` — репосты временно отключены до будущей реализации +- `entrypoint_edit_forbidden` — `TEXT_ENTRYPOINT` нельзя редактировать через `TEXT_EDIT_POST` +- `status_confirmed_target_must_be_status_action` — `STATUS_CONFIRMED` должен ссылаться на статусный блок - `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META` - `internal_error` @@ -104,8 +106,13 @@ - `TEXT_EDIT_POST (11)` - `TEXT_REPLY (20)` - `TEXT_EDIT_REPLY (21)` - - `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled` - - `TEXT_CHANNEL_META (70)` — скрытый технический снимок профиля канала + - `TEXT_RATING (30)` — target-based отзыв на конкретный блок + - `TEXT_REPOST (50)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled` + - `TEXT_CHANNEL_META (90)` — скрытый технический снимок профиля канала + - `TEXT_ENTRYPOINT (100)` — входная страница канала + - `TEXT_EXERCISE (110)` — line-based материал упражнения + - `TEXT_SERVICE (120)` — line-based материал услуги / процедуры + - `TEXT_COURSE (130)` — line-based материал курса 3. **REACTION (type=2)** - `REACTION_LIKE (1)` @@ -135,6 +142,16 @@ 5. **USER_PARAM (type=4)** - `USER_PARAM_TEXT_TEXT (1)` +6. **STATUS_ACTION (type=5)** + - `STATUS_DONE_ONCE (10)` + - `STATUS_INTERESTED (20)` + - `STATUS_STARTED (30)` + - `STATUS_IN_STUDY (40)` + - `STATUS_COMPLETED (50)` + - `STATUS_ABANDONED (60)` + - `STATUS_LEARNED (70)` + - `STATUS_CONFIRMED (80)` + ## 6. Практические payload-форматы для каналов и вложений `AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата. @@ -173,7 +190,7 @@ ### Изменение профиля канала -Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=70)`. +Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=90)`. Текстовое содержимое body использует тот же формат полного снимка профиля: diff --git a/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md b/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md index 52d25999..af0b71d2 100644 --- a/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md +++ b/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md @@ -11,14 +11,16 @@ - `12_REACTION_Blocks.md` — реакции (`type=2`). - `13_CONNECTION_Blocks.md` — связи/подписки (`type=3`). - `14_USER_PARAM_Blocks.md` — пользовательские параметры (`type=4`). +- `15_STATUS_ACTION_Blocks.md` — статусные действия (`type=5`). ## Быстрая карта типов - `type=0` — TECH: HEADER, CREATE_CHANNEL. -- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/REPOST. +- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE. - `type=2` — REACTION: LIKE/UNLIKE. - `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции. - `type=4` — USER_PARAM: key/value-параметры пользователя. +- `type=5` — STATUS_ACTION: DONE_ONCE/INTERESTED/STARTED/IN_STUDY/COMPLETED/ABANDONED/LEARNED/CONFIRMED. ## Примечание diff --git a/docs/Blockchain/02_Channel_Commands.md b/docs/Blockchain/02_Channel_Commands.md index 417040f9..823da7cd 100644 --- a/docs/Blockchain/02_Channel_Commands.md +++ b/docs/Blockchain/02_Channel_Commands.md @@ -9,7 +9,7 @@ Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок: - `msg_type=1` -- `subType=70` +- `subType=90` - `TEXT_CHANNEL_META` Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md). diff --git a/docs/Blockchain/11_TEXT_Blocks.md b/docs/Blockchain/11_TEXT_Blocks.md index 63d68d65..c629eb85 100644 --- a/docs/Blockchain/11_TEXT_Blocks.md +++ b/docs/Blockchain/11_TEXT_Blocks.md @@ -1,6 +1,6 @@ # TEXT блоки (`type=1`, `version=1`) -TEXT-тип хранит сообщения и редактирования. +TEXT-тип хранит сообщения, материалы и редактирования. ## Подтипы @@ -21,24 +21,52 @@ TEXT-тип хранит сообщения и редактирования. - target на исходный REPLY + новый текст. - допускается пустой `text` для логического удаления сообщения (без физического удаления блока). -5. `subType=30` — `TEXT_REPOST` +5. `subType=30` — `TEXT_RATING` + - target-based отзыв на конкретный блок; + - содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва; + - не является сообщением линии канала. + +6. `subType=50` — `TEXT_REPOST` - репост сообщения в линию канала; - содержит line-поля + target на оригинальное сообщение + текст комментария; - на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются); - временно отключён для записи через `AddBlock` до будущей реализации репостов. -6. `subType=70` — `TEXT_CHANNEL_META` +7. `subType=90` — `TEXT_CHANNEL_META` - скрытый технический снимок профиля канала; - содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием; - не отображается как обычное сообщение ленты; - применяется сервером к текущему состоянию канала. +8. `subType=100` — `TEXT_ENTRYPOINT` + - входная страница канала; + - line-based сообщение с тем же body, что у `TEXT_POST`; + - не редактируется через `TEXT_EDIT_POST`: новая версия создаётся новым `TEXT_ENTRYPOINT`. + +9. `subType=110` — `TEXT_EXERCISE` + - line-based материал упражнения; + - использует тот же body, что у `TEXT_POST`. + +10. `subType=120` — `TEXT_SERVICE` + - line-based материал услуги / процедуры; + - использует тот же body, что у `TEXT_POST`. + +11. `subType=130` — `TEXT_COURSE` + - line-based материал курса; + - использует тот же body, что у `TEXT_POST`. + Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md). ## Правило для edit `EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit. +Важно: + +- `TEXT_EDIT_POST` — технический edit для line-based сообщений канала; +- `TEXT_EDIT_REPLY` — технический edit для reply-сообщений; +- `TEXT_ENTRYPOINT` через `TEXT_EDIT_POST` не редактируется. + ## Пустой text в edit - Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`. diff --git a/docs/Blockchain/15_STATUS_ACTION_Blocks.md b/docs/Blockchain/15_STATUS_ACTION_Blocks.md new file mode 100644 index 00000000..f683be56 --- /dev/null +++ b/docs/Blockchain/15_STATUS_ACTION_Blocks.md @@ -0,0 +1,62 @@ +# STATUS_ACTION блоки (`type=5`, `version=1`) + +`STATUS_ACTION` хранит статусные действия пользователя по отношению к конкретному материалу. + +## Подтипы + +1. `subType=10` — `STATUS_DONE_ONCE` + - выполнил / прошёл один раз. + +2. `subType=20` — `STATUS_INTERESTED` + - заинтересовался материалом. + +3. `subType=30` — `STATUS_STARTED` + - начал. + +4. `subType=40` — `STATUS_IN_STUDY` + - находится в процессе полноценного изучения. + +5. `subType=50` — `STATUS_COMPLETED` + - завершил. + +6. `subType=60` — `STATUS_ABANDONED` + - бросил. + +7. `subType=70` — `STATUS_LEARNED` + - выучил упражнение / комплекс. + +8. `subType=80` — `STATUS_CONFIRMED` + - подтвердил чужой status-блок. + +## Формат body + +Все `STATUS_ACTION` используют один и тот же бинарный body-формат: + +```text +[1] toBlockchainNameLen (uint8) +[N] toBlockchainName UTF-8 +[4] toBlockGlobalNumber +[32] toBlockHash32 +[2] textLenBytes (uint16) +[M] text UTF-8 +``` + +Где: + +- `toBlockchainName` — блокчейн, в котором находится целевой материал; +- `toBlockGlobalNumber` — номер целевого блока; +- `toBlockHash32` — хэш целевого блока; +- `text` — опциональное пояснение пользователя к статусу. + +## Правила + +- Каждый `STATUS_ACTION` всегда является target-based сообщением. +- `STATUS_ACTION` не является line-based сообщением канала. +- Текст может быть пустым: основной смысл задаётся самим `subType`. +- Базовые статусы пользователь ставит сам за себя. +- `STATUS_CONFIRMED` ставится другим человеком на конкретный status-блок. + +## Что не поддерживается + +- Отдельного `EDIT` для `STATUS_ACTION` нет. +- Если нужно изменить смысл статуса, пишется новое статусное событие. diff --git a/docs/Blockchain/16_TEXT_Channel_Meta.md b/docs/Blockchain/16_TEXT_Channel_Meta.md index 51220e23..211b1832 100644 --- a/docs/Blockchain/16_TEXT_Channel_Meta.md +++ b/docs/Blockchain/16_TEXT_Channel_Meta.md @@ -5,7 +5,7 @@ ## Блок - `msg_type = 1` -- `subType = 70` +- `subType = 90` - `msgVersion = 1` - body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index e9b5a317..fe70c227 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,5 +1,15 @@ # История изменений документации блокчейна +## 2026-08-09 18:55:16 +0400 +- Базовый коммит-ориентир: `43f54c9`. +- Для первой итерации новых контентных типов обновлена карта `TEXT`-подтипов: + - `TEXT_RATING` добавлен как `subType=30` и трактуется как target-based отзыв на конкретный блок; + - `TEXT_REPOST` перенесён на `subType=50` и оставлен как отложенная заготовка; + - `TEXT_CHANNEL_META` перенесён на `subType=90`; + - добавлены line-based `TEXT_ENTRYPOINT (100)`, `TEXT_EXERCISE (110)`, `TEXT_SERVICE (120)`, `TEXT_COURSE (130)`. +- Добавлен новый верхнеуровневый тип `STATUS_ACTION (type=5)` с подтипами `10/20/30/40/50/60/70/80`. +- `CHANNEL_MEMBERSHIP` в текущую реализацию не включён и ведётся отдельно как отложенная тема. + ## 2026-08-04 12:00:00 +0400 - Базовый коммит-ориентир: `391b18a`. - Формат вложений расширен до `SHiNE:attach v=2` для опционального второго preview-файла: добавлены поля `previewAr` и `previewSha256` при сохранении совместимости со старыми `v=1`. diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index 438eb4ff..822b3430 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -17,15 +17,17 @@ Социальные связи (`msg_type=3`). 7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md) Параметры пользователя (`msg_type=4`). -8. [15_TEXT_Attachments.md](./15_TEXT_Attachments.md) +8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md) + Статусные действия пользователя (`msg_type=5`). +9. [16_TEXT_Attachments.md](./16_TEXT_Attachments.md) Вложения в TEXT-сообщениях через `SHiNE:attach v=1/v=2`, включая опциональные `previewAr/previewSha256` для видео и крупных изображений. -9. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md) +10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md) Скрытый `TEXT_CHANNEL_META` для профиля канала. -10. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md) +11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md) Типы каналов и формат `CreateChannelBody`. -11. [02_Channel_Commands.md](./02_Channel_Commands.md) +12. [02_Channel_Commands.md](./02_Channel_Commands.md) Команды в текстовых сообщениях каналов. -12. [CHANGELOG.md](./CHANGELOG.md) +13. [CHANGELOG.md](./CHANGELOG.md) Журнал изменений документации. ## Смежная документация diff --git a/Черновик_CHANNEL_MEMBERSHIP_для_каналов.md b/Черновик_CHANNEL_MEMBERSHIP_для_каналов.md new file mode 100644 index 00000000..be9c38f4 --- /dev/null +++ b/Черновик_CHANNEL_MEMBERSHIP_для_каналов.md @@ -0,0 +1,60 @@ +# Черновик CHANNEL_MEMBERSHIP для каналов + +## Статус документа + +Этот файл является отдельным отложенным черновиком. + +Важно: + +- тема временно вынесена из основной первой итерации; +- в текущую реализацию не входит; +- документ нужен, чтобы не потерять уже согласованные мысли и вернуться к ним позже. + +## Зачем вынесено отдельно + +`CHANNEL_MEMBERSHIP` пока решено не делать вместе с `TEXT` и `STATUS_ACTION`, чтобы: + +- не перегружать текущую реализацию; +- не смешивать две разные задачи; +- спокойно завершить первую итерацию по контенту и статусам; +- вернуться к membership позже отдельным этапом. + +## Отложенная схема ссылок + +На текущий момент в отложенный черновик заносится такая схема: + +- `JOIN_REQUEST` ссылается на root-блок канала; +- `JOIN_ACCEPTED` ссылается на `JOIN_REQUEST`; +- `LEFT` ссылается на root-блок канала; +- `REMOVED` ссылается на последнее membership-событие этого участника в этом канале, лучше всего на `JOIN_ACCEPTED`. + +## Предварительные подтипы + +Если тема будет возвращена в реализацию, предварительно рассматриваются: + +- `subType=10` — `CHANNEL_JOIN_REQUEST` +- `subType=20` — `CHANNEL_JOIN_ACCEPTED` +- `subType=30` — `CHANNEL_LEFT` +- `subType=40` — `CHANNEL_REMOVED` + +## Что ещё нужно будет отдельно решить позже + +Перед возвратом к теме нужно будет отдельно утвердить: + +- точный байтовый формат `CHANNEL_MEMBERSHIP`; +- в чьём блокчейне пишутся membership-события; +- политику прав доступа: + - кто может писать `JOIN_ACCEPTED`; + - кто может писать `REMOVED`; + - кто и как подтверждает актуальный состав канала; +- как сервер строит read-model текущего состава канала; +- как UI показывает pending-заявки, принятых участников, вышедших и исключённых. + +## Краткий итог + +`CHANNEL_MEMBERSHIP` не отменён, а именно отложен. + +Следующий рекомендуемый шаг: + +- сначала завершить первую итерацию `TEXT + STATUS_ACTION`; +- потом отдельным этапом вернуться к membership-логике каналов. diff --git a/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md b/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md new file mode 100644 index 00000000..ca008a28 --- /dev/null +++ b/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md @@ -0,0 +1,723 @@ +# Черновик дополнений каналов и типов сообщений SHiNE + +## Статус документа + +Этот файл является рабочим черновиком первой итерации. + +Важно: + +- это не финальная спецификация; +- формат блокчейна этим документом пока не меняется автоматически; +- документ фиксирует согласованную на текущий момент концепцию; +- в первую итерацию входят только `TEXT` и `STATUS_ACTION`; +- всё, что касается `CHANNEL_MEMBERSHIP`, вынесено в отдельный отложенный черновик: + - `Черновик_CHANNEL_MEMBERSHIP_для_каналов.md` + +## Цель + +Нужно расширить текущую модель каналов SHiNE так, чтобы канал был не только лентой обычных постов, но и пространством: + +- материалов; +- упражнений; +- услуг / процедур; +- курсов; +- входных страниц каналов; +- истории действий пользователей по этим материалам. + +При этом важно: + +- не ломать существующий блокчейн без отдельного согласованного шага; +- минимально менять верхнеуровневые `type`; +- по возможности переиспользовать уже существующий текстовый тип сообщений; +- отделять собственно контент от событий состояния. + +## Базовая идея первой итерации + +На текущий момент согласована следующая модель: + +- старый `type=1`, который раньше назывался `TEXT`, по коду можно не менять; +- в документации и логике его можно трактовать шире как основной контентный тип сообщений; +- все новые смысловые материалы каналов добавляются как новые `subType` внутри `type=1`; +- отдельным верхнеуровневым `type` в первой итерации становится только: + - `type=5` — действия пользователя по материалам. + +Иначе говоря: + +- `type=1` = сообщения и материалы; +- `type=5` = статусные действия пользователя; +- `type=6` = тема вынесена в отдельный отложенный черновик и в первую итерацию не входит. + +## Текущие старые верхнеуровневые типы + +Они сохраняются: + +- `type=0` — `TECH` +- `type=1` — `TEXT` +- `type=2` — `REACTION` +- `type=3` — `CONNECTION` +- `type=4` — `USER_PARAM` + +## Верхнеуровневые типы первой итерации + +Используются: + +- `0` — `TECH` +- `1` — `TEXT` +- `2` — `REACTION` +- `3` — `CONNECTION` +- `4` — `USER_PARAM` +- `5` — `STATUS_ACTION` + +Отдельно: + +- `6` — `CHANNEL_MEMBERSHIP` + - зарезервирован как следующая тема; + - вынесен в отдельный черновик; + - в текущую реализацию не входит. + +## Подтипы внутри `type=1` + +Согласованная таблица первой итерации: + +- `subType=10` — `TEXT_POST` +- `subType=11` — `TEXT_EDIT_POST` +- `subType=20` — `TEXT_REPLY` +- `subType=21` — `TEXT_EDIT_REPLY` +- `subType=30` — `TEXT_RATING` +- `subType=50` — `TEXT_REPOST` +- `subType=90` — `TEXT_CHANNEL_META` +- `subType=100` — `TEXT_ENTRYPOINT` +- `subType=110` — `TEXT_EXERCISE` +- `subType=120` — `TEXT_SERVICE` +- `subType=130` — `TEXT_COURSE` + +## Принцип форматов внутри `type=1` + +### Line-based сообщения + +Следующие подтипы считаются line-based и используют тот же body, что и обычный `TEXT_POST`: + +- `TEXT_POST` +- `TEXT_REPOST` +- `TEXT_CHANNEL_META` +- `TEXT_ENTRYPOINT` +- `TEXT_EXERCISE` +- `TEXT_SERVICE` +- `TEXT_COURSE` + +### Target-based сообщения + +Следующие подтипы считаются target-based: + +- `TEXT_REPLY` +- `TEXT_EDIT_REPLY` +- `TEXT_RATING` + +### Edit для `TEXT` + +В первой итерации согласовано оставить в коде два технических edit-подтипа: + +- `TEXT_EDIT_POST` + - edit для сообщений линии канала; +- `TEXT_EDIT_REPLY` + - edit для reply-сообщений. + +То есть в документации больше не используется старое упрощённое описание “один общий `TEXT_EDIT`”. + +## Смысл подтипов `type=1` + +### `TEXT_POST` + +Обычный текстовый пост в канале. + +### `TEXT_EDIT_POST` + +Редактирование line-based текстового сообщения канала. + +Принцип: + +- edit всегда ссылается на оригинальный блок; +- edit не должен ссылаться на предыдущий edit; +- фактический тип и правила берутся из оригинального сообщения. + +### `TEXT_REPLY` + +Обычный ответ / комментарий на сообщение. + +Важно: + +- reply остаётся единым; +- отвечать можно на `post`, `rating`, `exercise`, `service`, `course`, `entrypoint`, `status_action`; +- reply сам является target-based сообщением. + +### `TEXT_EDIT_REPLY` + +Редактирование reply-сообщения. + +### `TEXT_RATING` + +Текстовый отзыв / мнение / оценка на конкретный блок. + +Смысл: + +- это не line-based пост; +- это target-based сообщение-отзыв; +- оно всегда ссылается на конкретный блок, который оценивает; +- оценку пользователя как отдельную сущность в эту итерацию не включаем. + +### `TEXT_REPOST` + +Отложенная будущая заготовка. + +На текущем этапе: + +- код зарезервирован; +- бизнес-логика не входит в первую итерацию; +- детальная реализация может быть возвращена позже. + +### `TEXT_CHANNEL_META` + +Специальное скрытое сообщение метаданных канала. + +Через него задаются: + +- красивое имя канала; +- аватар; +- описание; +- и другие общие channel meta. + +Оно: + +- не является обычным пользовательским сообщением; +- не должно показываться в ленте как обычный пост; +- должно парситься по отдельным техническим правилам. + +### `TEXT_ENTRYPOINT` + +Входная / главная страница канала. + +Смысл: + +- это не отдельный верхнеуровневый тип; +- это специальный line-based текстовый материал; +- он служит входной страницей канала; +- он может объяснять структуру канала, давать ссылки, вводить человека в тему. + +Принцип: + +- `TEXT_ENTRYPOINT` не редактируется через `TEXT_EDIT_POST`; +- новая версия создаётся новым сообщением `TEXT_ENTRYPOINT`; +- если в канале несколько `entrypoint`, актуальным считается последний. + +### `TEXT_EXERCISE` + +Упражнение или комплекс упражнений. + +Смысл: + +- материал, который можно выполнять много раз; +- материал, который можно выучить; +- по нему удобно строить статистику выполнений и освоения. + +### `TEXT_SERVICE` + +Услуга / процедура. + +Смысл: + +- материал, который пользователь проходит; +- обычно не “учится выполнять”, а именно получает / проходит; +- прохождение фиксируется отдельным `STATUS_ACTION`. + +### `TEXT_COURSE` + +Курс. + +Смысл: + +- материал, по которому есть путь обучения; +- пользователь может заинтересоваться, начать, учиться, завершить, бросить. + +## Канал `0` + +Согласована новая трактовка канала `0`. + +Канал `0` становится: + +- обычным каналом публикаций пользователя по умолчанию; +- местом его основной ленты; +- местом его главной страницы, если там есть `entrypoint`. + +### Что это значит + +Если у пользователя в канале `0` существует `TEXT_ENTRYPOINT`, тогда: + +- ссылка `SHiNE/` открывает именно этот последний `entrypoint`. + +Если `entrypoint` в канале `0` нет, тогда: + +- `SHiNE/` открывает обычную ленту публикаций канала `0`. + +## Правила открытия каналов и ссылок + +Согласована следующая логика: + +- `SHiNE/` + - главная страница пользователя, то есть канал `0`; +- `SHiNE//` + - основная ссылка канала. + +### Как открывается `SHiNE//` + +Если у канала есть хотя бы один `TEXT_ENTRYPOINT`, тогда: + +- по умолчанию открывается последний актуальный `entrypoint`; +- UI показывает, сколько после него было новых сообщений; +- UI показывает кнопку перехода в конец канала. + +Если `entrypoint` нет: + +- открывается обычная лента канала. + +Для подписанного пользователя: + +- логичнее вести его в место новых непрочитанных сообщений; +- а не всегда принудительно открывать entrypoint; +- при этом сверху можно показывать кнопку `Открыть entrypoint`. + +### Ссылки на конкретные сообщения + +Остаются обычные формы: + +- `SHiNE///` +- `SHiNE////` + +Если нужно показать старую конкретную версию `entrypoint`, даётся ссылка именно на номер нужного сообщения. + +## Новый тип `STATUS_ACTION` + +`type=5` вводится для действий пользователя по контенту. + +## Принцип `STATUS_ACTION` + +Согласовано: + +- `STATUS_ACTION` всегда является target-based сообщением; +- статусное действие всегда ссылается на конкретный блок-материал; +- это не обычный пост в канале; +- из таких сообщений можно собирать виртуальную ленту пользователя. + +### Как трактуется текст в `STATUS_ACTION` + +Основной смысл блока задаётся самим статусным событием. + +Текст внутри такого блока: + +- это комментарий пользователя к действию, если он есть; +- не является главным смыслом записи; +- служит пояснением. + +Примеры: + +- “Начал сегодня” +- “Решил пройти серьёзно” +- “Сделал это после практики” +- “Выучил базовый комплекс” + +## Подтипы `STATUS_ACTION` + +Предлагаемая таблица: + +- `subType=10` — `STATUS_DONE_ONCE` +- `subType=20` — `STATUS_INTERESTED` +- `subType=30` — `STATUS_STARTED` +- `subType=40` — `STATUS_IN_STUDY` +- `subType=50` — `STATUS_COMPLETED` +- `subType=60` — `STATUS_ABANDONED` +- `subType=70` — `STATUS_LEARNED` +- `subType=80` — `STATUS_CONFIRMED` + +## Смысл подтипов `STATUS_ACTION` + +### `STATUS_DONE_ONCE` + +Факт одного выполнения / прохождения. + +Это накопительное событие. + +### `STATUS_INTERESTED` + +Пользователя заинтересовал материал. + +### `STATUS_STARTED` + +Пользователь начал. + +Это начальный статус процесса, но ещё не “устойчивое обучение”. + +### `STATUS_IN_STUDY` + +Пользователь уже полноценно находится в обучении. + +Смысл: + +- не просто попробовал; +- а реально учится; +- это отдельный статус, более сильный, чем `started`. + +### `STATUS_COMPLETED` + +Пользователь завершил курс / прохождение. + +### `STATUS_ABANDONED` + +Пользователь бросил. + +### `STATUS_LEARNED` + +Пользователь выучил упражнение или комплекс и знает, как его делать. + +Это не то же самое, что “сделал один раз”. + +### `STATUS_CONFIRMED` + +Подтверждение чужого status-события. + +Важно: + +- подтверждается не курс вообще; +- не упражнение вообще; +- а конкретный `STATUS_ACTION` конкретного пользователя. + +## Кто ставит статусы + +Согласовано правило: + +- любой базовый статус ставит сам пользователь от своего имени; +- другие люди не ставят статус за него; +- другие люди могут только подтверждать его status-событие через `STATUS_CONFIRMED`. + +То есть: + +- `INTERESTED`, `STARTED`, `IN_STUDY`, `COMPLETED`, `ABANDONED`, `LEARNED`, `DONE_ONCE` + - ставит сам пользователь; +- `CONFIRMED` + - ставят другие люди на конкретный статусный блок. + +### Вес подтверждений + +В базовой версии веса подтверждений не вводятся. + +Но в будущем можно добавить: + +- более значимое подтверждение от создателя курса; +- более значимое подтверждение от создателя упражнения; +- весовые коэффициенты от близких / доверенных людей; +- слабые подтверждения от обычных пользователей. + +## Матрица допустимости статусов по типам контента + +### Для `TEXT_SERVICE` + +Разрешены: + +- `STATUS_DONE_ONCE` +- `STATUS_CONFIRMED` + +Не разрешены: + +- `INTERESTED` +- `STARTED` +- `IN_STUDY` +- `COMPLETED` +- `ABANDONED` +- `LEARNED` + +### Для `TEXT_EXERCISE` + +Разрешены: + +- `STATUS_DONE_ONCE` +- `STATUS_LEARNED` +- `STATUS_CONFIRMED` + +Логика: + +- упражнение можно выполнять много раз; +- упражнение можно выучить; +- `LEARNED` и `DONE_ONCE` не конфликтуют и живут параллельно. + +### Для `TEXT_COURSE` + +Разрешены: + +- `STATUS_INTERESTED` +- `STATUS_STARTED` +- `STATUS_IN_STUDY` +- `STATUS_COMPLETED` +- `STATUS_ABANDONED` +- `STATUS_CONFIRMED` + +### Для `TEXT_ENTRYPOINT` + +Статусные действия не ставятся. + +### Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_RATING`, `TEXT_CHANNEL_META` + +Статусные действия не ставятся. + +## Накопительные и текущие состояния + +Нужно различать: + +- накопительные события; +- текущий статус. + +### Накопительные события + +К ним относится: + +- `STATUS_DONE_ONCE` + +Они могут встречаться сколько угодно раз. + +### Текущий статус + +Для некоторых статусов есть “актуальное состояние”. + +Например: + +- `INTERESTED` +- `STARTED` +- `IN_STUDY` +- `COMPLETED` +- `ABANDONED` +- `LEARNED` + +Текущий статус определяется как последнее событие соответствующей оси. + +### Для упражнения + +У упражнения есть две независимые оси: + +- количественная: сколько раз выполнено; +- качественная: выучено или нет. + +Это значит: + +- `STATUS_DONE_ONCE` накапливается; +- `STATUS_LEARNED` живёт как отдельный качественный статус. + +### Для курса + +У курса текущий статус вычисляется по последнему из: + +- `INTERESTED` +- `STARTED` +- `IN_STUDY` +- `COMPLETED` +- `ABANDONED` + +## Виртуальная лента достижений пользователя + +Согласована важная идея: + +- не нужно создавать отдельный физический канал для истории выполнений пользователя; +- вместо этого строится виртуальная лента из его `STATUS_ACTION`. + +### Что это даёт + +Можно одновременно получить: + +- историю человека; +- статистику по самому материалу. + +Например, по упражнению можно увидеть: + +- сколько разных людей его выполняли; +- сколько всего выполнений было; +- сколько раз конкретный человек его выполнял; +- кто его выучил. + +### Что попадает в виртуальную ленту + +Попадают status-события пользователя: + +- выполненные упражнения; +- выученные упражнения; +- интерес к курсам; +- начатые курсы; +- обучение в процессе; +- завершённые курсы; +- брошенные курсы; +- прохождения услуг; +- подтверждения к этим событиям. + +### Можно ли это обсуждать + +Да. + +Такие статусные сообщения остаются обычными объектами обсуждения: + +- на них можно отвечать; +- их можно комментировать; +- на них можно писать отзывы; +- их можно подтверждать. + +То есть: + +- лента виртуальная; +- но сами записи реальные и обсуждаемые. + +## Как это должно выглядеть в интерфейсе + +### В каналах + +Для разных типов сообщений UI должен понимать роль сообщения. + +Например: + +- у `TEXT_EXERCISE` можно показать кнопки: + - `Выполнил` + - `Выучил` +- у `TEXT_SERVICE`: + - `Прошёл` +- у `TEXT_COURSE`: + - `Интересно` + - `Начал` + - `Учусь` + - `Завершил` + - `Бросил` + +### Для `entrypoint` + +Если канал открывается по основной ссылке и у него есть `entrypoint`, UI: + +- показывает сам последний `entrypoint`; +- показывает число новых сообщений после него; +- показывает кнопку перехода в конец канала. + +Если пользователь уже подписан на канал: + +- открывать лучше место новых непрочитанных сообщений; +- но с кнопкой открытия последнего `entrypoint`. + +### Для виртуальной ленты достижений + +В профиле пользователя UI может показывать: + +- отдельную вкладку / раздел; +- где лента строится из его `STATUS_ACTION`. + +## Что именно не нужно делать в первой итерации + +### Не нужен отдельный физический канал достижений + +Технически не нужен. + +### Не нужны отдельные edit-подтипы для каждого вида контента + +Не нужны: + +- `TEXT_EDIT_EXERCISE` +- `TEXT_EDIT_SERVICE` +- `TEXT_EDIT_COURSE` + +Достаточно текущего разделения: + +- `TEXT_EDIT_POST` +- `TEXT_EDIT_REPLY` + +### Не нужен отдельный верхнеуровневый тип для `entrypoint` + +Он остаётся подтипом `type=1`. + +### Не нужен `EDIT` для status-событий + +`STATUS_ACTION` не редактируются. + +Если нужно изменить смысл, пишется новое событие. + +### Не нужен `CHANNEL_MEMBERSHIP` в первой итерации + +Эта тема отложена в отдельный черновик. + +## Сводная таблица + +### Верхнеуровневые `type` + +| Код | Имя | Статус | +|---|---|---| +| 0 | TECH | старый | +| 1 | TEXT | старый код, новое расширенное смысловое описание | +| 2 | REACTION | старый | +| 3 | CONNECTION | старый | +| 4 | USER_PARAM | старый | +| 5 | STATUS_ACTION | первая итерация | +| 6 | CHANNEL_MEMBERSHIP | вынесено в отдельный отложенный черновик | + +### Подтипы `type=1` + +| subType | Имя | Смысл | +|---|---|---| +| 10 | TEXT_POST | обычный post в линии канала | +| 11 | TEXT_EDIT_POST | edit line-based сообщения | +| 20 | TEXT_REPLY | target-based reply | +| 21 | TEXT_EDIT_REPLY | edit reply | +| 30 | TEXT_RATING | target-based отзыв на конкретный блок | +| 50 | TEXT_REPOST | отложенная будущая заготовка | +| 90 | TEXT_CHANNEL_META | скрытые метаданные канала | +| 100 | TEXT_ENTRYPOINT | входная страница канала | +| 110 | TEXT_EXERCISE | упражнение | +| 120 | TEXT_SERVICE | услуга / процедура | +| 130 | TEXT_COURSE | курс | + +### Подтипы `type=5` + +| subType | Имя | Смысл | +|---|---|---| +| 10 | STATUS_DONE_ONCE | выполнил / прошёл один раз | +| 20 | STATUS_INTERESTED | заинтересовался | +| 30 | STATUS_STARTED | начал | +| 40 | STATUS_IN_STUDY | учится полноценно | +| 50 | STATUS_COMPLETED | завершил | +| 60 | STATUS_ABANDONED | бросил | +| 70 | STATUS_LEARNED | выучил | +| 80 | STATUS_CONFIRMED | подтверждение status-события | + +## Что ещё нужно отдельно утвердить перед реализацией + +Перед началом реальной реализации желательно отдельно утвердить: + +- точный байтовый формат новых line-based `subType` внутри `type=1`; +- точный байтовый формат `TEXT_RATING` как target-based отзыва; +- точный байтовый формат `STATUS_ACTION`; +- правила target-ссылок для status-событий; +- правила target-ссылок для отзывов на status-события; +- поведение удаления: + - можно ли логически удалять `TEXT_EXERCISE` / `TEXT_SERVICE` / `TEXT_COURSE`; + - можно ли удалять старые `entrypoint` или только оставлять их в истории; +- серверные read-model таблицы и индексы. + +## Предварительная оценка готовности к реализации + +На текущий момент концепция уже достаточно зрелая, чтобы начинать проектирование реализации первой итерации. + +Реализовывать это уже можно, если дополнительно утвердить: + +- байтовые форматы; +- правила валидации; +- API чтения новых сущностей; +- UI-матрицу действий по каждому типу контента. + +## Краткий итог + +Согласованная модель первой итерации сейчас такая: + +- почти всё новое содержимое каналов живёт внутри `type=1`; +- `entrypoint` — это специальное текстовое сообщение, а не отдельный верхнеуровневый тип; +- `entrypoint` не редактируется, а версионируется новыми сообщениями; +- канал `0` становится каналом публикаций пользователя и его главной страницей; +- действия пользователя по материалам выносятся в `STATUS_ACTION`; +- `TEXT_RATING` трактуется как target-based отзыв на конкретный блок; +- `CHANNEL_MEMBERSHIP` отложен в отдельный черновик и в первую итерацию не входит. From 5c38f8f0d8d53b5ca9d7bdf4b6d154e49566cbda5e8690b5c64014567aa71c4d Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 9 Aug 2026 19:56:25 +0400 Subject: [PATCH 02/27] =?UTF-8?q?Deploy:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20backup-=D1=81=D1=85=D0=B5=D0=BC=D1=83=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=B3=D0=BD=D0=BE=D1=80=20=D0=B0=D1=80=D1=85?= =?UTF-8?q?=D0=B8=D0=B2=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/backup/backup-version.properties | 6 +- .../shineup.me/captures/01_host_disk.txt | 22 +- .../captures/02_enabled_services.txt | 59 +- .../captures/03_running_services.txt | 17 +- .../shineup.me/captures/04_listen_ports.txt | 44 +- .../shineup.me/captures/05_docker_ps.txt | 4 +- .../captures/06_home_player_sizes.txt | 9 +- .../shineup.me/captures/07_var_sizes.txt | 11 +- .../shineup.me/captures/UPDATED_AT_UTC.txt | 2 +- .../scheme/shineup.me/configs/Caddyfile | 37 +- .../systemd/agent-memory.service.MISSING.txt | 1 + .../configs/systemd/shine-server.service | 4 +- .../scheme/shineup.me/configs/turnserver.conf | 724 +----------------- .../shineup.me/scripts/refresh_scheme.sh | 24 +- 14 files changed, 130 insertions(+), 834 deletions(-) create mode 100644 deploy/backup/scheme/shineup.me/configs/systemd/agent-memory.service.MISSING.txt diff --git a/deploy/backup/backup-version.properties b/deploy/backup/backup-version.properties index 4cb102d0..72dd5eb9 100644 --- a/deploy/backup/backup-version.properties +++ b/deploy/backup/backup-version.properties @@ -1,3 +1,3 @@ -backup.schema.version=1 -backup.full.version=2 -last.full.backup.date=2026-07-10 +backup.schema.version=2 +backup.full.version=3 +last.full.backup.date=2026-08-08 diff --git a/deploy/backup/scheme/shineup.me/captures/01_host_disk.txt b/deploy/backup/scheme/shineup.me/captures/01_host_disk.txt index c0980e47..d4094a65 100644 --- a/deploy/backup/scheme/shineup.me/captures/01_host_disk.txt +++ b/deploy/backup/scheme/shineup.me/captures/01_host_disk.txt @@ -1,17 +1,15 @@ -cld9-012186 +p702072.kvmvps --- -Linux cld9-012186 6.8.0-124-generic #124-Ubuntu SMP PREEMPT_DYNAMIC Tue May 26 13:00:45 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux +Linux p702072.kvmvps 5.15.0-187-generic #197-Ubuntu SMP Fri Jul 17 19:17:01 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux --- Filesystem Size Used Avail Use% Mounted on -tmpfs 392M 1.2M 391M 1% /run -/dev/vda2 32G 14G 17G 46% / -tmpfs 2.0G 0 2.0G 0% /dev/shm +tmpfs 197M 1.2M 196M 1% /run +/dev/sda1 40G 6.7G 31G 18% / +tmpfs 982M 0 982M 0% /dev/shm tmpfs 5.0M 0 5.0M 0% /run/lock -tmpfs 392M 12K 392M 1% /run/user/1002 +tmpfs 197M 0 197M 0% /run/user/1000 --- -NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS -sr0 iso9660 Joliet Extension cidata 2026-06-02-12-22-36-00 -sr1 -vda -├─vda1 -└─vda2 ext4 1.0 c422dce2-e6a3-4ce4-a9c1-17ab14e9193a 16.1G 44% / +NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS +sda +└─sda1 ext4 1.0 42e8f44f-2dd9-4fc6-8485-6581d6528df5 30.6G 17% / +sr0 diff --git a/deploy/backup/scheme/shineup.me/captures/02_enabled_services.txt b/deploy/backup/scheme/shineup.me/captures/02_enabled_services.txt index d10ea2d8..b3e8d9ff 100644 --- a/deploy/backup/scheme/shineup.me/captures/02_enabled_services.txt +++ b/deploy/backup/scheme/shineup.me/captures/02_enabled_services.txt @@ -1,13 +1,48 @@ -UNIT FILE STATE PRESET -agent-memory.service enabled enabled -caddy.service enabled enabled -containerd.service enabled enabled -coturn.service enabled enabled -docker.service enabled enabled -elaira-agent.service enabled enabled -hermes-dashboard.service enabled enabled -hermes-gateway.service enabled enabled -shine-server.service enabled enabled -ubuntu-fan.service enabled enabled +UNIT FILE STATE VENDOR PRESET +apparmor.service enabled enabled +blk-availability.service enabled enabled +caddy.service enabled enabled +console-setup.service enabled enabled +containerd.service enabled enabled +coturn.service enabled enabled +cron.service enabled enabled +dmesg.service enabled enabled +docker.service enabled enabled +e2scrub_reap.service enabled enabled +finalrd.service enabled enabled +getty@.service enabled enabled +gpu-manager.service enabled enabled +grub-common.service enabled enabled +grub-initrd-fallback.service enabled enabled +guestfs-firstboot.service enabled enabled +irqbalance.service enabled enabled +keyboard-setup.service enabled enabled +lvm2-monitor.service enabled enabled +lxd-agent.service enabled enabled +ModemManager.service enabled enabled +multipathd.service enabled enabled +networkd-dispatcher.service enabled enabled +open-iscsi.service enabled enabled +open-vm-tools.service enabled enabled +pollinate.service enabled enabled +rsyslog.service enabled enabled +secureboot-db.service enabled enabled +setvtrgb.service enabled enabled +shine-server.service enabled enabled +snap.lxd.activate.service enabled enabled +ssh.service enabled enabled +systemd-networkd-wait-online.service enabled disabled +systemd-networkd.service enabled enabled +systemd-pstore.service enabled enabled +systemd-resolved.service enabled enabled +systemd-timesyncd.service enabled enabled +thermald.service enabled enabled +ua-reboot-cmds.service enabled enabled +ubuntu-advantage.service enabled enabled +ubuntu-fan.service enabled enabled +udisks2.service enabled enabled +ufw.service enabled enabled +unattended-upgrades.service enabled enabled +vgauth.service enabled enabled -10 unit files listed. +45 unit files listed. diff --git a/deploy/backup/scheme/shineup.me/captures/03_running_services.txt b/deploy/backup/scheme/shineup.me/captures/03_running_services.txt index bfef0583..9b8ffe75 100644 --- a/deploy/backup/scheme/shineup.me/captures/03_running_services.txt +++ b/deploy/backup/scheme/shineup.me/captures/03_running_services.txt @@ -1,20 +1,20 @@ UNIT LOAD ACTIVE SUB DESCRIPTION - agent-memory.service loaded active running agent-memory service caddy.service loaded active running Caddy containerd.service loaded active running containerd container runtime coturn.service loaded active running coTURN STUN/TURN Server cron.service loaded active running Regular background program processing daemon dbus.service loaded active running D-Bus System Message Bus docker.service loaded active running Docker Application Container Engine - elaira-agent.service loaded active running Elaira self-hosted agent getty@tty1.service loaded active running Getty on tty1 - hermes-gateway.service loaded active running Hermes Agent Gateway - Messaging Platform Integration + irqbalance.service loaded active running irqbalance daemon ModemManager.service loaded active running Modem Manager multipathd.service loaded active running Device-Mapper Multipath Device Controller + networkd-dispatcher.service loaded active running Dispatcher daemon for systemd-networkd + packagekit.service loaded active running PackageKit Daemon polkit.service loaded active running Authorization Manager qemu-guest-agent.service loaded active running QEMU Guest Agent rsyslog.service loaded active running System Logging Service - shine-server.service loaded active running SHiNE Server + shine-server.service loaded active running SHiNE Server (shineup.me) ssh.service loaded active running OpenBSD Secure Shell server systemd-hostnamed.service loaded active running Hostname Service systemd-journald.service loaded active running Journal Service @@ -26,10 +26,9 @@ udisks2.service loaded active running Disk Manager unattended-upgrades.service loaded active running Unattended Upgrades Shutdown upower.service loaded active running Daemon for power management - user@1002.service loaded active running User Manager for UID 1002 - -Legend: LOAD → Reflects whether the unit definition was properly loaded. - ACTIVE → The high-level unit activation state, i.e. generalization of SUB. - SUB → The low-level unit activation state, values depend on unit type. + user@1000.service loaded active running User Manager for UID 1000 +LOAD = Reflects whether the unit definition was properly loaded. +ACTIVE = The high-level unit activation state, i.e. generalization of SUB. +SUB = The low-level unit activation state, values depend on unit type. 28 loaded units listed. diff --git a/deploy/backup/scheme/shineup.me/captures/04_listen_ports.txt b/deploy/backup/scheme/shineup.me/captures/04_listen_ports.txt index da6b5b3b..32c79855 100644 --- a/deploy/backup/scheme/shineup.me/captures/04_listen_ports.txt +++ b/deploy/backup/scheme/shineup.me/captures/04_listen_ports.txt @@ -1,32 +1,12 @@ -State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess -LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=323411,fd=15)) -LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=37)) -LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=15)) -LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=34)) -LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=13)) -LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=58)) -LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=21)) -LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=60)) -LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=23)) -LISTEN 0 2048 0.0.0.0:8000 0.0.0.0:* users:(("uvicorn",pid=2056697,fd=6)) -LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=8096,fd=14)) -LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* users:(("systemd-resolve",pid=323411,fd=17)) -LISTEN 0 4096 0.0.0.0:2222 0.0.0.0:* users:(("docker-proxy",pid=8410,fd=7)) -LISTEN 0 4096 127.0.0.1:45999 0.0.0.0:* users:(("containerd",pid=2121968,fd=15)) -LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("docker-proxy",pid=8433,fd=7)) -LISTEN 0 2048 127.0.0.1:9119 0.0.0.0:* users:(("hermes",pid=1913356,fd=7)) -LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=323588,fd=3),("systemd",pid=1,fd=130)) -LISTEN 0 1024 185.229.109.118:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=40)) -LISTEN 0 1024 185.229.109.118:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=17)) -LISTEN 0 1024 185.229.109.118:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=56)) -LISTEN 0 1024 185.229.109.118:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=19)) -LISTEN 0 100 *:8018 *:* users:(("java",pid=9688,fd=13)) -LISTEN 0 50 *:7070 *:* users:(("java",pid=1732508,fd=16)) -LISTEN 0 4096 [::]:2222 [::]:* users:(("docker-proxy",pid=8417,fd=7)) -LISTEN 0 4096 [::]:22 [::]:* users:(("sshd",pid=323588,fd=4),("systemd",pid=1,fd=132)) -LISTEN 0 4096 *:80 *:* users:(("caddy",pid=8096,fd=16)) -LISTEN 0 4096 *:443 *:* users:(("caddy",pid=8096,fd=15)) -LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=1689866,fd=25)) -LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=1689866,fd=62)) -LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=1689866,fd=27)) -LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=1689866,fd=64)) +State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess +LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=839,fd=3)) +LISTEN 0 1024 0.0.0.0:3478 0.0.0.0:* users:(("turnserver",pid=522,fd=34)) +LISTEN 0 1024 0.0.0.0:3478 0.0.0.0:* users:(("turnserver",pid=522,fd=33)) +LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=804,fd=4)) +LISTEN 0 4096 127.0.0.1:42113 0.0.0.0:* users:(("containerd",pid=536,fd=16)) +LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=466,fd=14)) +LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("docker-proxy",pid=1240,fd=7)) +LISTEN 0 4096 *:80 *:* users:(("caddy",pid=804,fd=9)) +LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=839,fd=4)) +LISTEN 0 4096 *:443 *:* users:(("caddy",pid=804,fd=7)) +LISTEN 0 50 *:7070 *:* users:(("java",pid=1289,fd=19)) diff --git a/deploy/backup/scheme/shineup.me/captures/05_docker_ps.txt b/deploy/backup/scheme/shineup.me/captures/05_docker_ps.txt index d3af29ef..785befd4 100644 --- a/deploy/backup/scheme/shineup.me/captures/05_docker_ps.txt +++ b/deploy/backup/scheme/shineup.me/captures/05_docker_ps.txt @@ -1,2 +1,2 @@ -NAMES IMAGE STATUS PORTS -gitea gitea/gitea:1.22.6 Up 5 weeks 127.0.0.1:3000->3000/tcp, 0.0.0.0:2222->22/tcp, [::]:2222->22/tcp +NAMES IMAGE STATUS PORTS +shine-postgres postgres:18 Up 19 hours 127.0.0.1:5432->5432/tcp diff --git a/deploy/backup/scheme/shineup.me/captures/06_home_player_sizes.txt b/deploy/backup/scheme/shineup.me/captures/06_home_player_sizes.txt index 49f99b01..f6af8b4d 100644 --- a/deploy/backup/scheme/shineup.me/captures/06_home_player_sizes.txt +++ b/deploy/backup/scheme/shineup.me/captures/06_home_player_sizes.txt @@ -1,8 +1 @@ -4.0K /home/player/hosts.codex.test -8.0K /home/player/Work_AGENTS -804K /home/player/sites -33M /home/player/elaira-agent -36M /home/player/agent-memory -46M /home/player/gitea -183M /home/player/SHiNE -1.7G /home/player/hermes +85M /home/player/SHiNE diff --git a/deploy/backup/scheme/shineup.me/captures/07_var_sizes.txt b/deploy/backup/scheme/shineup.me/captures/07_var_sizes.txt index 5dc0c1f5..619833f2 100644 --- a/deploy/backup/scheme/shineup.me/captures/07_var_sizes.txt +++ b/deploy/backup/scheme/shineup.me/captures/07_var_sizes.txt @@ -2,11 +2,10 @@ 4.0K /var/local 4.0K /var/mail 4.0K /var/opt -4.0K /var/snap 16K /var/spool -88K /var/tmp -3.2M /var/backups -143M /var/cache -1.2G /var/lib -1.5G /var/log +72K /var/tmp +1.9M /var/backups +319M /var/cache +1.1G /var/log +1.5G /var/lib 2.8G /var diff --git a/deploy/backup/scheme/shineup.me/captures/UPDATED_AT_UTC.txt b/deploy/backup/scheme/shineup.me/captures/UPDATED_AT_UTC.txt index 30b0fd41..7135beff 100644 --- a/deploy/backup/scheme/shineup.me/captures/UPDATED_AT_UTC.txt +++ b/deploy/backup/scheme/shineup.me/captures/UPDATED_AT_UTC.txt @@ -1 +1 @@ -2026-07-10T08:27:36Z +2026-08-08T20:05:20Z diff --git a/deploy/backup/scheme/shineup.me/configs/Caddyfile b/deploy/backup/scheme/shineup.me/configs/Caddyfile index b1829b0b..055b2c6e 100644 --- a/deploy/backup/scheme/shineup.me/configs/Caddyfile +++ b/deploy/backup/scheme/shineup.me/configs/Caddyfile @@ -1,8 +1,5 @@ -openmindsoft.io, www.openmindsoft.io { - encode zstd gzip - root * /home/player/sites/OpenMindSoft.io - try_files {path} /index.html - file_server +{ + auto_https disable_redirects } shineup.me { @@ -46,33 +43,3 @@ shineup.me { } } } - - -git.shineup.me { - encode zstd gzip - reverse_proxy 127.0.0.1:3000 -} - -test-solana-tickets.shineup.me, test-solana-tickets.shiningpeople.ru { - encode zstd gzip - root * /home/player/sites/test-solana-tickets.shineup.me - try_files {path} /index.html - file_server - header -Etag - header { - Cache-Control "no-store, no-cache, must-revalidate, max-age=0" - Pragma "no-cache" - Expires "0" - } -} - -hermes.shineup.me { - encode zstd gzip - basicauth { - player $2a$14$a41XVsBhgxgKN2MVS5Vt3Otu4C6mmv2FRo1gYDjvPDEYwYkPGnj1e - } - reverse_proxy 127.0.0.1:9119 { - header_up Host 127.0.0.1:9119 - header_up Origin http://127.0.0.1:9119 - } -} diff --git a/deploy/backup/scheme/shineup.me/configs/systemd/agent-memory.service.MISSING.txt b/deploy/backup/scheme/shineup.me/configs/systemd/agent-memory.service.MISSING.txt new file mode 100644 index 00000000..0632d271 --- /dev/null +++ b/deploy/backup/scheme/shineup.me/configs/systemd/agent-memory.service.MISSING.txt @@ -0,0 +1 @@ +missing: /etc/systemd/system/agent-memory.service diff --git a/deploy/backup/scheme/shineup.me/configs/systemd/shine-server.service b/deploy/backup/scheme/shineup.me/configs/systemd/shine-server.service index 693bd670..d44da2f6 100644 --- a/deploy/backup/scheme/shineup.me/configs/systemd/shine-server.service +++ b/deploy/backup/scheme/shineup.me/configs/systemd/shine-server.service @@ -1,5 +1,5 @@ [Unit] -Description=SHiNE Server +Description=SHiNE Server (shineup.me) After=network.target [Service] @@ -7,7 +7,7 @@ Type=simple User=player Group=player WorkingDirectory=/home/player/SHiNE/shine-server -ExecStart=/usr/bin/java -Dserver.1port=7070 -jar /home/player/SHiNE/shine-server/shine-server.jar +ExecStart=/usr/bin/java -Dserver.port=7070 -jar /home/player/SHiNE/shine-server/shine-server.jar Restart=always RestartSec=3 diff --git a/deploy/backup/scheme/shineup.me/configs/turnserver.conf b/deploy/backup/scheme/shineup.me/configs/turnserver.conf index 4ef9e9f9..b6ee829d 100644 --- a/deploy/backup/scheme/shineup.me/configs/turnserver.conf +++ b/deploy/backup/scheme/shineup.me/configs/turnserver.conf @@ -1,709 +1,17 @@ -# Coturn TURN SERVER configuration file -# -# Boolean values note: where boolean value is supposed to be used, -# you can use '0', 'off', 'no', 'false', 'f' as 'false, -# and you can use '1', 'on', 'yes', 'true', 't' as 'true' -# If the value is missed, then it means 'true'. -# - -# Listener interface device (optional, Linux only). -# NOT RECOMMENDED. -# -#listening-device=eth0 - -# TURN listener port for UDP and TCP (Default: 3478). -# Note: actually, TLS & DTLS sessions can connect to the -# "plain" TCP & UDP port(s), too - if allowed by configuration. -# -#listening-port=3478 - -# TURN listener port for TLS (Default: 5349). -# Note: actually, "plain" TCP & UDP sessions can connect to the TLS & DTLS -# port(s), too - if allowed by configuration. The TURN server -# "automatically" recognizes the type of traffic. Actually, two listening -# endpoints (the "plain" one and the "tls" one) are equivalent in terms of -# functionality; but we keep both endpoints to satisfy the RFC 5766 specs. -# For secure TCP connections, we currently support SSL version 3 and -# TLS version 1.0, 1.1 and 1.2. -# For secure UDP connections, we support DTLS version 1. -# -#tls-listening-port=5349 - -# Alternative listening port for UDP and TCP listeners; -# default (or zero) value means "listening port plus one". -# This is needed for RFC 5780 support -# (STUN extension specs, NAT behavior discovery). The TURN Server -# supports RFC 5780 only if it is started with more than one -# listening IP address of the same family (IPv4 or IPv6). -# RFC 5780 is supported only by UDP protocol, other protocols -# are listening to that endpoint only for "symmetry". -# -#alt-listening-port=0 - -# Alternative listening port for TLS and DTLS protocols. -# Default (or zero) value means "TLS listening port plus one". -# -#alt-tls-listening-port=0 - -# Listener IP address of relay server. Multiple listeners can be specified. -# If no IP(s) specified in the config file or in the command line options, -# then all IPv4 and IPv6 system IPs will be used for listening. -# -#listening-ip=172.17.19.101 -#listening-ip=10.207.21.238 -#listening-ip=2607:f0d0:1002:51::4 - -# Auxiliary STUN/TURN server listening endpoint. -# Aux servers have almost full TURN and STUN functionality. -# The (minor) limitations are: -# -# 1) Auxiliary servers do not have alternative ports and -# they do not support STUN RFC 5780 functionality (CHANGE REQUEST). -# -# 2) Auxiliary servers also are never returning ALTERNATIVE-SERVER reply. -# -# Valid formats are 1.2.3.4:5555 for IPv4 and [1:2::3:4]:5555 for IPv6. -# -# There may be multiple aux-server options, each will be used for listening -# to client requests. -# -#aux-server=172.17.19.110:33478 -#aux-server=[2607:f0d0:1002:51::4]:33478 - -# (recommended for older Linuxes only) -# Automatically balance UDP traffic over auxiliary servers (if configured). -# The load balancing is using the ALTERNATE-SERVER mechanism. -# The TURN client must support 300 ALTERNATE-SERVER response for this -# functionality. -# -#udp-self-balance - -# Relay interface device for relay sockets (optional, Linux only). -# NOT RECOMMENDED. -# -#relay-device=eth1 - -# Relay address (the local IP address that will be used to relay the -# packets to the peer). -# Multiple relay addresses may be used. -# The same IP(s) can be used as both listening IP(s) and relay IP(s). -# -# If no relay IP(s) specified, then the turnserver will apply the default -# policy: it will decide itself which relay addresses to be used, and it -# will always be using the client socket IP address as the relay IP address -# of the TURN session (if the requested relay address family is the same -# as the family of the client socket). -# -#relay-ip=172.17.19.105 -#relay-ip=2607:f0d0:1002:51::5 - -# For Amazon EC2 users: -# -# TURN Server public/private address mapping, if the server is behind NAT. -# In that situation, if a -X is used in form "-X " then that ip will be reported -# as relay IP address of all allocations. This scenario works only in a simple case -# when one single relay address is be used, and no RFC5780 functionality is required. -# That single relay address must be mapped by NAT to the 'external' IP. -# The "external-ip" value, if not empty, is returned in XOR-RELAYED-ADDRESS field. -# For that 'external' IP, NAT must forward ports directly (relayed port 12345 -# must be always mapped to the same 'external' port 12345). -# -# In more complex case when more than one IP address is involved, -# that option must be used several times, each entry must -# have form "-X ", to map all involved addresses. -# RFC5780 NAT discovery STUN functionality will work correctly, -# if the addresses are mapped properly, even when the TURN server itself -# is behind A NAT. -# -# By default, this value is empty, and no address mapping is used. -# -#external-ip=60.70.80.91 -# -#OR: -# -#external-ip=60.70.80.91/172.17.19.101 -#external-ip=60.70.80.92/172.17.19.102 - - -# Number of the relay threads to handle the established connections -# (in addition to authentication thread and the listener thread). -# If explicitly set to 0 then application runs relay process in a -# single thread, in the same thread with the listener process -# (the authentication thread will still be a separate thread). -# -# If this parameter is not set, then the default OS-dependent -# thread pattern algorithm will be employed. Usually the default -# algorithm is the most optimal, so you have to change this option -# only if you want to make some fine tweaks. -# -# In the older systems (Linux kernel before 3.9), -# the number of UDP threads is always one thread per network listening -# endpoint - including the auxiliary endpoints - unless 0 (zero) or -# 1 (one) value is set. -# -#relay-threads=0 - -# Lower and upper bounds of the UDP relay endpoints: -# (default values are 49152 and 65535) -# -#min-port=49152 -#max-port=65535 - -# Uncomment to run TURN server in 'normal' 'moderate' verbose mode. -# By default the verbose mode is off. -#verbose - -# Uncomment to run TURN server in 'extra' verbose mode. -# This mode is very annoying and produces lots of output. -# Not recommended under any normal circumstances. -# -#Verbose - -# Uncomment to use fingerprints in the TURN messages. -# By default the fingerprints are off. -# -#fingerprint - -# Uncomment to use long-term credential mechanism. -# By default no credentials mechanism is used (any user allowed). -# -#lt-cred-mech - -# This option is opposite to lt-cred-mech. -# (TURN Server with no-auth option allows anonymous access). -# If neither option is defined, and no users are defined, -# then no-auth is default. If at least one user is defined, -# in this file or in command line or in usersdb file, then -# lt-cred-mech is default. -# -#no-auth - -# TURN REST API flag. -# (Time Limited Long Term Credential) -# Flag that sets a special authorization option that is based upon authentication secret. -# -# This feature's purpose is to support "TURN Server REST API", see -# "TURN REST API" link in the project's page -# https://github.com/coturn/coturn/ -# -# This option is used with timestamp: -# -# usercombo -> "timestamp:userid" -# turn user -> usercombo -# turn password -> base64(hmac(secret key, usercombo)) -# -# This allows TURN credentials to be accounted for a specific user id. -# If you don't have a suitable id, the timestamp alone can be used. -# This option is just turning on secret-based authentication. -# The actual value of the secret is defined either by option static-auth-secret, -# or can be found in the turn_secret table in the database (see below). -# -# Read more about it: -# - https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00 -# - https://www.ietf.org/proceedings/87/slides/slides-87-behave-10.pdf -# -# Be aware that use-auth-secret overrides some part of lt-cred-mech. -# Notice that this feature depends internally on lt-cred-mech, so if you set -# use-auth-secret then it enables internally automatically lt-cred-mech option -# like if you enable both. -# -# You can use only one of the to auth mechanisms in the same time because, -# both mechanism use the username and password validation in different way. -# -# This way be aware that you can't use both auth mechnaism in the same time! -# Use in config either the lt-cred-mech or the use-auth-secret -# to avoid any confusion. -# +listening-port=3478 +fingerprint +lt-cred-mech use-auth-secret - -# 'Static' authentication secret value (a string) for TURN REST API only. -# If not set, then the turn server -# will try to use the 'dynamic' value in turn_secret table -# in user database (if present). The database-stored value can be changed on-the-fly -# by a separate program, so this is why that other mode is 'dynamic'. -# -#static-auth-secret=north - -# Server name used for -# the oAuth authentication purposes. -# The default value is the realm name. -# -#server-name=blackdow.carleon.gov - -# Flag that allows oAuth authentication. -# -#oauth - -# 'Static' user accounts for long term credentials mechanism, only. -# This option cannot be used with TURN REST API. -# 'Static' user accounts are NOT dynamically checked by the turnserver process, -# so that they can NOT be changed while the turnserver is running. -# -#user=username1:key1 -#user=username2:key2 -# OR: -#user=username1:password1 -#user=username2:password2 -# -# Keys must be generated by turnadmin utility. The key value depends -# on user name, realm, and password: -# -# Example: -# $ turnadmin -k -u ninefingers -r north.gov -p youhavetoberealistic -# Output: 0xbc807ee29df3c9ffa736523fb2c4e8ee -# ('0x' in the beginning of the key is what differentiates the key from -# password. If it has 0x then it is a key, otherwise it is a password). -# -# The corresponding user account entry in the config file will be: -# -#user=ninefingers:0xbc807ee29df3c9ffa736523fb2c4e8ee -# Or, equivalently, with open clear password (less secure): -#user=ninefingers:youhavetoberealistic -# - -# SQLite database file name. -# -# Default file name is /var/db/turndb or /usr/local/var/db/turndb or -# /var/lib/turn/turndb. -# -#userdb=/var/db/turndb - -# PostgreSQL database connection string in the case that we are using PostgreSQL -# as the user database. -# This database can be used for long-term credential mechanism -# and it can store the secret value for secret-based timed authentication in TURN RESP API. -# See http://www.postgresql.org/docs/8.4/static/libpq-connect.html for 8.x PostgreSQL -# versions connection string format, see -# http://www.postgresql.org/docs/9.2/static/libpq-connect.html#LIBPQ-CONNSTRING -# for 9.x and newer connection string formats. -# -#psql-userdb="host= dbname= user= password= connect_timeout=30" - -# MySQL database connection string in the case that we are using MySQL -# as the user database. -# This database can be used for long-term credential mechanism -# and it can store the secret value for secret-based timed authentication in TURN RESP API. -# -# Optional connection string parameters for the secure communications (SSL): -# ca, capath, cert, key, cipher -# (see http://dev.mysql.com/doc/refman/5.1/en/ssl-options.html for the -# command options description). -# -# Use string format as below (space separated parameters, all optional): -# -#mysql-userdb="host= dbname= user= password= port= connect_timeout= read_timeout=" - -# If you want to use in the MySQL connection string the password in encrypted format, -# then set in this option the MySQL password encryption secret key file. -# -# Warning: If this option is set, then mysql password must be set in "mysql-userdb" in encrypted format! -# If you want to use cleartext password then do not set this option! -# -# This is the file path which contain secret key of aes encryption while using password encryption. -# -#secret-key-file=/path/ - -# MongoDB database connection string in the case that we are using MongoDB -# as the user database. -# This database can be used for long-term credential mechanism -# and it can store the secret value for secret-based timed authentication in TURN RESP API. -# Use string format is described at http://hergert.me/docs/mongo-c-driver/mongoc_uri.html -# -#mongo-userdb="mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]]" - -# Redis database connection string in the case that we are using Redis -# as the user database. -# This database can be used for long-term credential mechanism -# and it can store the secret value for secret-based timed authentication in TURN RESP API. -# Use string format as below (space separated parameters, all optional): -# -#redis-userdb="ip= dbname= password= port= connect_timeout=" - -# Redis status and statistics database connection string, if used (default - empty, no Redis stats DB used). -# This database keeps allocations status information, and it can be also used for publishing -# and delivering traffic and allocation event notifications. -# The connection string has the same parameters as redis-userdb connection string. -# Use string format as below (space separated parameters, all optional): -# -#redis-statsdb="ip= dbname= password= port= connect_timeout=" - -# The default realm to be used for the users when no explicit -# origin/realm relationship was found in the database, or if the TURN -# server is not using any database (just the commands-line settings -# and the userdb file). Must be used with long-term credentials -# mechanism or with TURN REST API. -# -# Note: If default realm is not specified at all, then realm falls back to the host domain name. -# If domain name is empty string, or '(None)', then it is initialized to am empty string. -# -#realm=mycompany.org - -# The flag that sets the origin consistency -# check: across the session, all requests must have the same -# main ORIGIN attribute value (if the ORIGIN was -# initially used by the session). -# -#check-origin-consistency - -# Per-user allocation quota. -# default value is 0 (no quota, unlimited number of sessions per user). -# This option can also be set through the database, for a particular realm. -# -#user-quota=0 - -# Total allocation quota. -# default value is 0 (no quota). -# This option can also be set through the database, for a particular realm. -# -#total-quota=0 - -# Max bytes-per-second bandwidth a TURN session is allowed to handle -# (input and output network streams are treated separately). Anything above -# that limit will be dropped or temporary suppressed (within -# the available buffer limits). -# This option can also be set through the database, for a particular realm. -# -#max-bps=0 - -# -# Maximum server capacity. -# Total bytes-per-second bandwidth the TURN server is allowed to allocate -# for the sessions, combined (input and output network streams are treated separately). -# -# bps-capacity=0 - -# Uncomment if no UDP client listener is desired. -# By default UDP client listener is always started. -# -#no-udp - -# Uncomment if no TCP client listener is desired. -# By default TCP client listener is always started. -# -#no-tcp - -# Uncomment if no TLS client listener is desired. -# By default TLS client listener is always started. -# -#no-tls - -# Uncomment if no DTLS client listener is desired. -# By default DTLS client listener is always started. -# -#no-dtls - -# Uncomment if no UDP relay endpoints are allowed. -# By default UDP relay endpoints are enabled (like in RFC 5766). -# -#no-udp-relay - -# Uncomment if no TCP relay endpoints are allowed. -# By default TCP relay endpoints are enabled (like in RFC 6062). -# -#no-tcp-relay - -# Uncomment if extra security is desired, -# with nonce value having limited lifetime. -# By default, the nonce value is unique for a session, -# and has unlimited lifetime. -# Set this option to limit the nonce lifetime. -# It defaults to 600 secs (10 min) if no value is provided. After that delay, -# the client will get 438 error and will have to re-authenticate itself. -# -#stale-nonce=600 - -# Uncomment if you want to set the maximum allocation -# time before it has to be refreshed. -# Default is 3600s. -# -#max-allocate-lifetime=3600 - - -# Uncomment to set the lifetime for the channel. -# Default value is 600 secs (10 minutes). -# This value MUST not be changed for production purposes. -# -#channel-lifetime=600 - -# Uncomment to set the permission lifetime. -# Default to 300 secs (5 minutes). -# In production this value MUST not be changed, -# however it can be useful for test purposes. -# -#permission-lifetime=300 - -# Certificate file. -# Use an absolute path or path relative to the -# configuration file. -# -#cert=/usr/local/etc/turn_server_cert.pem - -# Private key file. -# Use an absolute path or path relative to the -# configuration file. -# Use PEM file format. -# -#pkey=/usr/local/etc/turn_server_pkey.pem - -# Private key file password, if it is in encoded format. -# This option has no default value. -# -#pkey-pwd=... - -# Allowed OpenSSL cipher list for TLS/DTLS connections. -# Default value is "DEFAULT". -# -#cipher-list="DEFAULT" - -# CA file in OpenSSL format. -# Forces TURN server to verify the client SSL certificates. -# By default it is not set: there is no default value and the client -# certificate is not checked. -# -# Example: -#CA-file=/etc/ssh/id_rsa.cert - -# Curve name for EC ciphers, if supported by OpenSSL -# library (TLS and DTLS). The default value is prime256v1, -# if pre-OpenSSL 1.0.2 is used. With OpenSSL 1.0.2+, -# an optimal curve will be automatically calculated, if not defined -# by this option. -# -#ec-curve-name=prime256v1 - -# Use 566 bits predefined DH TLS key. Default size of the key is 1066. -# -#dh566 - -# Use 2066 bits predefined DH TLS key. Default size of the key is 1066. -# -#dh2066 - -# Use custom DH TLS key, stored in PEM format in the file. -# Flags --dh566 and --dh2066 are ignored when the DH key is taken from a file. -# -#dh-file= - -# Flag to prevent stdout log messages. -# By default, all log messages are going to both stdout and to -# the configured log file. With this option everything will be -# going to the configured log only (unless the log file itself is stdout). -# -#no-stdout-log - -# Option to set the log file name. -# By default, the turnserver tries to open a log file in -# /var/log, /var/tmp, /tmp and current directories directories -# (which open operation succeeds first that file will be used). -# With this option you can set the definite log file name. -# The special names are "stdout" and "-" - they will force everything -# to the stdout. Also, the "syslog" name will force everything to -# the system log (syslog). -# In the runtime, the logfile can be reset with the SIGHUP signal -# to the turnserver process. -# -#log-file=/var/tmp/turn.log - -# Option to redirect all log output into system log (syslog). -# -syslog - -# This flag means that no log file rollover will be used, and the log file -# name will be constructed as-is, without PID and date appendage. -# This option can be used, for example, together with the logrotate tool. -# -#simple-log - -# Option to set the "redirection" mode. The value of this option -# will be the address of the alternate server for UDP & TCP service in form of -# [:]. The server will send this value in the attribute -# ALTERNATE-SERVER, with error 300, on ALLOCATE request, to the client. -# Client will receive only values with the same address family -# as the client network endpoint address family. -# See RFC 5389 and RFC 5766 for ALTERNATE-SERVER functionality description. -# The client must use the obtained value for subsequent TURN communications. -# If more than one --alternate-server options are provided, then the functionality -# can be more accurately described as "load-balancing" than a mere "redirection". -# If the port number is omitted, then the default port -# number 3478 for the UDP/TCP protocols will be used. -# Colon (:) characters in IPv6 addresses may conflict with the syntax of -# the option. To alleviate this conflict, literal IPv6 addresses are enclosed -# in square brackets in such resource identifiers, for example: -# [2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478 . -# Multiple alternate servers can be set. They will be used in the -# round-robin manner. All servers in the pool are considered of equal weight and -# the load will be distributed equally. For example, if we have 4 alternate servers, -# then each server will receive 25% of ALLOCATE requests. A alternate TURN server -# address can be used more than one time with the alternate-server option, so this -# can emulate "weighting" of the servers. -# -# Examples: -#alternate-server=1.2.3.4:5678 -#alternate-server=11.22.33.44:56789 -#alternate-server=5.6.7.8 -#alternate-server=[2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478 - -# Option to set alternative server for TLS & DTLS services in form of -# :. If the port number is omitted, then the default port -# number 5349 for the TLS/DTLS protocols will be used. See the previous -# option for the functionality description. -# -# Examples: -#tls-alternate-server=1.2.3.4:5678 -#tls-alternate-server=11.22.33.44:56789 -#tls-alternate-server=[2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478 - -# Option to suppress TURN functionality, only STUN requests will be processed. -# Run as STUN server only, all TURN requests will be ignored. -# By default, this option is NOT set. -# -#stun-only - -# Option to suppress STUN functionality, only TURN requests will be processed. -# Run as TURN server only, all STUN requests will be ignored. -# By default, this option is NOT set. -# -#no-stun - -# This is the timestamp/username separator symbol (character) in TURN REST API. -# The default value is ':'. -# rest-api-separator=: - -# Flag that can be used to allow peers on the loopback addresses (127.x.x.x and ::1). -# This is an extra security measure. -# -# (To avoid any security issue that allowing loopback access may raise, -# the no-loopback-peers option is replaced by allow-loopback-peers.) -# -# Allow it only for testing in a development environment! -# In production it adds a possible security vulnerability, so for security reasons -# it is not allowed using it together with empty cli-password. -# -#allow-loopback-peers - -# Flag that can be used to disallow peers on well-known broadcast addresses (224.0.0.0 and above, and FFXX:*). -# This is an extra security measure. -# -#no-multicast-peers - -# Option to set the max time, in seconds, allowed for full allocation establishment. -# Default is 60 seconds. -# -#max-allocate-timeout=60 - -# Option to allow or ban specific ip addresses or ranges of ip addresses. -# If an ip address is specified as both allowed and denied, then the ip address is -# considered to be allowed. This is useful when you wish to ban a range of ip -# addresses, except for a few specific ips within that range. -# -# This can be used when you do not want users of the turn server to be able to access -# machines reachable by the turn server, but would otherwise be unreachable from the -# internet (e.g. when the turn server is sitting behind a NAT) -# -# Examples: -# denied-peer-ip=83.166.64.0-83.166.95.255 -# allowed-peer-ip=83.166.68.45 - -# File name to store the pid of the process. -# Default is /var/run/turnserver.pid (if superuser account is used) or -# /var/tmp/turnserver.pid . -# -#pidfile="/var/run/turnserver.pid" - -# Require authentication of the STUN Binding request. -# By default, the clients are allowed anonymous access to the STUN Binding functionality. -# -#secure-stun - -# Mobility with ICE (MICE) specs support. -# -#mobility - -# Allocate Address Family according -# If enabled then TURN server allocates address family according the TURN -# Client <=> Server communication address family. -# (By default coTURN works according RFC 6156.) -# !!Warning: Enabling this option breaks RFC6156 section-4.2 (violates use default IPv4)!! -# -#keep-address-family - - -# User name to run the process. After the initialization, the turnserver process -# will make an attempt to change the current user ID to that user. -# -#proc-user= - -# Group name to run the process. After the initialization, the turnserver process -# will make an attempt to change the current group ID to that group. -# -#proc-group= - -# Turn OFF the CLI support. -# By default it is always ON. -# See also options cli-ip and cli-port. -# -#no-cli - -#Local system IP address to be used for CLI server endpoint. Default value -# is 127.0.0.1. -# -#cli-ip=127.0.0.1 - -# CLI server port. Default is 5766. -# -#cli-port=5766 - -# CLI access password. Default is empty (no password). -# For the security reasons, it is recommended to use the encrypted -# for of the password (see the -P command in the turnadmin utility). -# -# Secure form for password 'qwerty': -# -#cli-password=$5$79a316b350311570$81df9cfb9af7f5e5a76eada31e7097b663a0670f99a3c07ded3f1c8e59c5658a -# -# Or unsecure form for the same password: -# -#cli-password=qwerty - -# Enable Web-admin support on https. By default it is Disabled. -# If it is enabled it also enables a http a simple static banner page -# with a small reminder that the admin page is available only on https. -# -#web-admin - -# Local system IP address to be used for Web-admin server endpoint. Default value is 127.0.0.1. -# -#web-admin-ip=127.0.0.1 - -# Web-admin server port. Default is 8080. -# -#web-admin-port=8080 - -# Web-admin server listen on STUN/TURN worker threads -# By default it is disabled for security resons! (Not recommended in any production environment!) -# -#web-admin-listen-on-workers - -# Server relay. NON-STANDARD AND DANGEROUS OPTION. -# Only for those applications when we want to run -# server applications on the relay endpoints. -# This option eliminates the IP permissions check on -# the packets incoming to the relay endpoints. -# -#server-relay - -# Maximum number of output sessions in ps CLI command. -# This value can be changed on-the-fly in CLI. The default value is 256. -# -#cli-max-output-sessions - -# Set network engine type for the process (for internal purposes). -# -#ne=[1|2|3] - -# Do not allow an TLS/DTLS version of protocol -# -#no-tlsv1 -#no-tlsv1_1 -#no-tlsv1_2 -static-auth-secret= +static-auth-secret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106 +realm=turn1.shineup.me +total-quota=200 +stale-nonce=600 +no-multicast-peers +no-loopback-peers +no-cli +simple-log +external-ip=178.208.64.62 +listening-ip=0.0.0.0 +relay-ip=178.208.64.62 +min-port=49000 +max-port=53999 diff --git a/deploy/backup/scheme/shineup.me/scripts/refresh_scheme.sh b/deploy/backup/scheme/shineup.me/scripts/refresh_scheme.sh index 23ecdf5f..ac4f944e 100755 --- a/deploy/backup/scheme/shineup.me/scripts/refresh_scheme.sh +++ b/deploy/backup/scheme/shineup.me/scripts/refresh_scheme.sh @@ -5,9 +5,21 @@ REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}" SCHEME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CAP_DIR="${SCHEME_DIR}/captures" CFG_DIR="${SCHEME_DIR}/configs" +RSYNC_REMOTE_SUDO=(--rsync-path="sudo -n rsync") mkdir -p "${CAP_DIR}" "${CFG_DIR}/systemd" +copy_optional_file() { + local remote_path="$1" + local local_path="$2" + if ssh "${REMOTE_HOST}" "test -f '$remote_path'"; then + rsync -a "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:${remote_path}" "${local_path}" + else + mkdir -p "$(dirname "${local_path}")" + echo "missing: ${remote_path}" > "${local_path}.MISSING.txt" + fi +} + echo "[1/3] Обновляю инвентарь" ssh "${REMOTE_HOST}" 'hostnamectl --static; echo ---; uname -a; echo ---; df -h; echo ---; lsblk -f' > "${CAP_DIR}/01_host_disk.txt" ssh "${REMOTE_HOST}" 'systemctl list-unit-files --type=service --state=enabled --no-pager' > "${CAP_DIR}/02_enabled_services.txt" @@ -18,10 +30,14 @@ ssh "${REMOTE_HOST}" 'du -sh /home/player/* 2>/dev/null | sort -h' > "${CAP_DIR} ssh "${REMOTE_HOST}" 'sudo -n du -xhd1 /var 2>/dev/null | sort -h' > "${CAP_DIR}/07_var_sizes.txt" echo "[2/3] Обновляю ключевые конфиги" -rsync -a "${REMOTE_HOST}:/home/player/SHiNE/caddy/Caddyfile" "${CFG_DIR}/Caddyfile" -rsync -a "${REMOTE_HOST}:/etc/turnserver.conf" "${CFG_DIR}/turnserver.conf" -rsync -a "${REMOTE_HOST}:/etc/systemd/system/shine-server.service" "${CFG_DIR}/systemd/" -rsync -a "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${CFG_DIR}/systemd/" +if ssh "${REMOTE_HOST}" 'test -f /etc/caddy/Caddyfile'; then + rsync -a "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/caddy/Caddyfile" "${CFG_DIR}/Caddyfile" +else + rsync -a "${REMOTE_HOST}:/home/player/SHiNE/caddy/Caddyfile" "${CFG_DIR}/Caddyfile" +fi +copy_optional_file "/etc/turnserver.conf" "${CFG_DIR}/turnserver.conf" +copy_optional_file "/etc/systemd/system/shine-server.service" "${CFG_DIR}/systemd/shine-server.service" +copy_optional_file "/etc/systemd/system/agent-memory.service" "${CFG_DIR}/systemd/agent-memory.service" echo "[3/3] Метка обновления" date -u +%Y-%m-%dT%H:%M:%SZ > "${CAP_DIR}/UPDATED_AT_UTC.txt" From c511910b8fc3c7e1b6c1a9fa8cfba0b8bbd9b9aa70e2148843152683ff4b4a35 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 9 Aug 2026 20:09:54 +0400 Subject: [PATCH 03/27] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B0=20SendSignal=20=D1=81=20client=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Net_CallInviteBroadcast_Handler.java | 25 +- .../JSON/messages/Net_SendSignal_Handler.java | 207 ++++++++++++++++- .../Net_CallInviteBroadcast_Request.java | 4 + .../entyties/Net_SendSignal_Response.java | 27 +++ TODO/README.md | 1 + ...sp32_переход_на_sendsignal_и_client_key.md | 89 ++++++++ VERSION.properties | 4 +- docs/API/05_Technical_Requests_API.md | 19 +- shine-UI/js/app.js | 5 + shine-UI/js/services/auth-service.js | 4 +- shine-UI/js/services/call-service.js | 214 +++++++++++++++--- 11 files changed, 556 insertions(+), 43 deletions(-) create mode 100644 TODO/medium/2026-08-09_esp32_переход_на_sendsignal_и_client_key.md diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java index 359529e0..c21602af 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java @@ -26,6 +26,7 @@ import java.util.Set; public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final int TYPE_INVITE = 100; + private static final int TYPE_CONNECT_START = 170; private static final long PUSH_CALL_TTL_MS = 10_000L; @Override @@ -38,8 +39,11 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler { String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim(); String callId = req.getCallId() == null ? "" : req.getCallId().trim(); int type = req.getType() == null ? TYPE_INVITE : req.getType(); - if (toRequest.isBlank() || callId.isBlank() || type != TYPE_INVITE) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100 обязательны"); + String data = req.getData() == null ? "" : req.getData().trim(); + boolean isInvite = type == TYPE_INVITE; + boolean isConnectStart = type == TYPE_CONNECT_START; + if (toRequest.isBlank() || callId.isBlank() || (!isInvite && !isConnectStart)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100|170 обязательны"); } CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest); @@ -69,13 +73,28 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler { payload.put("fromSessionId", ctx.getSessionId()); payload.put("toLogin", to); payload.put("callId", callId); - payload.put("type", TYPE_INVITE); + payload.put("type", type); payload.put("timeMs", timeMs); + if (!data.isBlank()) { + payload.put("data", data); + } boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingCallInvite", eventId, payload); if (sent) wsDelivered++; } + if (isConnectStart) { + Net_CallInviteBroadcast_Response resp = new Net_CallInviteBroadcast_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setCallId(callId); + resp.setDeliveredWsSessions(wsDelivered); + resp.setDeliveredFcmSessions(0); + resp.setDeliveredWebPushSessions(0); + return resp; + } + for (ActiveSessionEntry session : allTargetSessions) { String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim(); if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java index 7c24e117..7cf84e7e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java @@ -12,10 +12,12 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Response; +import server.logic.ws_protocol.JSON.push.WebPushSender; import server.logic.ws_protocol.JSON.push.WsEventSender; import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; +import shine.db.dao.ActiveSessionsDAO; import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; import shine.db.entities.CurrentUserEntry; @@ -34,6 +36,13 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { private static final String TARGET_MODE_SINGLE = "single_session"; private static final String TARGET_MODE_ALL = "all_sessions"; private static final long ALLOWED_SKEW_MS = 30_000L; + private static final long PUSH_CALL_TTL_MS = 10_000L; + private static final String SIGNAL_CALL_INVITE = "call_invite"; + private static final String SIGNAL_CALL_ACCEPT = "call_accept"; + private static final String SIGNAL_CALL_DECLINE_BUSY = "call_decline_busy"; + private static final String SIGNAL_CALL_TIMEOUT = "call_timeout"; + private static final String SIGNAL_CALL_HANGUP = "call_hangup"; + private static final String SIGNAL_CALL_CONNECT_START = "call_connect_start"; @Override public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { @@ -99,6 +108,10 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_SIGNATURE", "Некорректная подпись session key"); } + if (isCallSignalType(signalType) && clientSignatureB64.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "CLIENT_SIGNATURE_REQUIRED", "Для call_* сигналов обязательна подпись client key"); + } + if (!clientSignatureB64.isBlank()) { String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64); if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) { @@ -107,7 +120,13 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { } List targets = resolveTargets(targetMode, toLogin, targetSessionId); - if (targets.isEmpty()) { + boolean isCallInviteAllSessions = TARGET_MODE_ALL.equals(targetMode) && SIGNAL_CALL_INVITE.equals(signalType); + boolean isCallAcceptSingleSession = TARGET_MODE_SINGLE.equals(targetMode) && SIGNAL_CALL_ACCEPT.equals(signalType); + boolean isCallTerminalSingleSession = TARGET_MODE_SINGLE.equals(targetMode) + && (SIGNAL_CALL_DECLINE_BUSY.equals(signalType) + || SIGNAL_CALL_TIMEOUT.equals(signalType) + || SIGNAL_CALL_HANGUP.equals(signalType)); + if (targets.isEmpty() && !isCallInviteAllSessions) { String code = TARGET_MODE_SINGLE.equals(targetMode) ? "SESSION_NOT_FOUND" : "NO_TARGET_SESSIONS"; String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала"; return NetExceptionResponseFactory.error(req, 404, code, msg); @@ -135,16 +154,62 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { } } - if (deliveredSessionIds.isEmpty()) { + int webPushDelivered = 0; + if (isCallInviteAllSessions) { + webPushDelivered = sendIncomingCallPushToOfflineSessions( + toLogin, + fromLogin, + fromSessionId, + signalRequestId, + deliveredSessionIds + ); + } + + if (deliveredSessionIds.isEmpty() && webPushDelivered <= 0) { return NetExceptionResponseFactory.error(req, 404, "DELIVERY_FAILED", "Не удалось доставить сигнал ни в одну целевую сессию"); } + if (isCallAcceptSingleSession) { + notifyStopOnOtherSessions( + fromLogin, + fromSessionId, + fromLogin, + fromSessionId, + signalRequestId, + "accepted_on_other_device" + ); + } + + if (isCallTerminalSingleSession) { + String deliveredTargetSessionId = deliveredSessionIds.isEmpty() ? targetSessionId : deliveredSessionIds.get(0); + String reason = "terminal_call_signal_" + signalType; + notifyStopOnOtherSessions( + fromLogin, + fromSessionId, + fromLogin, + fromSessionId, + signalRequestId, + reason + ); + notifyStopOnOtherSessions( + toLogin, + deliveredTargetSessionId, + fromLogin, + fromSessionId, + signalRequestId, + reason + ); + } + Net_SendSignal_Response resp = new Net_SendSignal_Response(); resp.setOp(req.getOp()); resp.setRequestId(req.getRequestId()); resp.setStatus(WireCodes.Status.OK); - resp.setDeliveredCount(deliveredSessionIds.size()); + resp.setDeliveredCount(deliveredSessionIds.size() + webPushDelivered); resp.setDeliveredSessionIds(deliveredSessionIds); + resp.setDeliveredWsSessions(deliveredSessionIds.size()); + resp.setDeliveredFcmSessions(webPushDelivered); + resp.setDeliveredWebPushSessions(webPushDelivered); return resp; } @@ -190,6 +255,19 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { + dataSha256B64; } + private static boolean isCallSignalType(String signalType) { + return SIGNAL_CALL_INVITE.equals(signalType) + || "call_ringing".equals(signalType) + || SIGNAL_CALL_ACCEPT.equals(signalType) + || SIGNAL_CALL_DECLINE_BUSY.equals(signalType) + || SIGNAL_CALL_TIMEOUT.equals(signalType) + || SIGNAL_CALL_HANGUP.equals(signalType) + || SIGNAL_CALL_CONNECT_START.equals(signalType) + || "call_offer".equals(signalType) + || "call_answer".equals(signalType) + || "call_ice".equals(signalType); + } + private static boolean verifySignature(String publicKeyValue, String preimage, String signatureB64, String fieldName) throws Exception { byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName); byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); @@ -214,7 +292,130 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { return targets; } + private int sendIncomingCallPushToOfflineSessions( + String toLogin, + String fromLogin, + String fromSessionId, + String callId, + List deliveredOnlineSessionIds + ) throws Exception { + List persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(toLogin); + long sentAtMs = System.currentTimeMillis(); + long expiresAtMs = sentAtMs + PUSH_CALL_TTL_MS; + int delivered = 0; + for (ActiveSessionEntry session : persistedSessions) { + String sessionId = safe(session.getSessionId()); + if (!sessionId.isBlank() && deliveredOnlineSessionIds.contains(sessionId)) { + continue; + } + if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { + continue; + } + String payload = "{\"kind\":\"incoming_call\"" + + ",\"title\":\"SHiNE: входящий звонок\"" + + ",\"text\":\"Вам звонит " + jsonEscape(fromLogin) + "\"" + + ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\"" + + ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\"" + + ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\"" + + ",\"toLogin\":\"" + jsonEscape(toLogin) + "\"" + + ",\"callId\":\"" + jsonEscape(callId) + "\"" + + ",\"sentAtMs\":" + sentAtMs + + ",\"expiresAtMs\":" + expiresAtMs + + "}"; + boolean pushed = WebPushSender.sendBase64Payload( + session.getPushEndpoint(), + session.getPushP256dhKey(), + session.getPushAuthKey(), + payload + ); + if (pushed) { + delivered++; + } + } + return delivered; + } + + private void notifyStopOnOtherSessions( + String targetLogin, + String excludeSessionId, + String fromLogin, + String fromSessionId, + String callId, + String reason + ) throws Exception { + if (isBlank(targetLogin) || isBlank(callId)) { + return; + } + + Set onlineSessionIds = new java.util.HashSet<>(); + Set sameUserSessions = ActiveConnectionsRegistry.getInstance().getByLogin(targetLogin); + for (ConnectionContext siblingCtx : sameUserSessions) { + if (siblingCtx == null || siblingCtx.getWsSession() == null || !siblingCtx.getWsSession().isOpen()) continue; + onlineSessionIds.add(safe(siblingCtx.getSessionId())); + if (!isBlank(excludeSessionId) && excludeSessionId.equals(siblingCtx.getSessionId())) continue; + + String siblingEventId = server.logic.ws_protocol.JSON.utils.NetIdGenerator.eventId("evt"); + ObjectNode siblingPayload = MAPPER.createObjectNode(); + siblingPayload.put("eventId", siblingEventId); + siblingPayload.put("fromLogin", fromLogin); + siblingPayload.put("fromSessionId", fromSessionId); + siblingPayload.put("toLogin", targetLogin); + siblingPayload.put("targetMode", TARGET_MODE_SINGLE); + siblingPayload.put("targetSessionId", safe(siblingCtx.getSessionId())); + siblingPayload.put("signalType", SIGNAL_CALL_HANGUP); + siblingPayload.put("signalRequestId", callId); + siblingPayload.put("data", "{\"callId\":\"" + jsonEscape(callId) + "\",\"type\":150,\"data\":\"" + jsonEscape(reason) + "\"}"); + siblingPayload.put("timeMs", System.currentTimeMillis()); + WsEventSender.sendEvent(siblingCtx, "IncomingSignal", siblingEventId, siblingPayload); + } + + List persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin); + long sentAtMs = System.currentTimeMillis(); + for (ActiveSessionEntry session : persistedSessions) { + String sessionId = safe(session.getSessionId()); + if (!isBlank(excludeSessionId) && excludeSessionId.equals(sessionId)) continue; + if (!sessionId.isBlank() && onlineSessionIds.contains(sessionId)) continue; + if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { + continue; + } + String pushPayload = "{\"kind\":\"stop_call\"" + + ",\"callId\":\"" + jsonEscape(callId) + "\"" + + ",\"reason\":\"" + jsonEscape(reason) + "\"" + + ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\"" + + ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\"" + + ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\"" + + ",\"toLogin\":\"" + jsonEscape(targetLogin) + "\"" + + ",\"sentAtMs\":" + sentAtMs + + "}"; + WebPushSender.sendBase64Payload( + session.getPushEndpoint(), + session.getPushP256dhKey(), + session.getPushAuthKey(), + pushPayload + ); + } + } + private static String safe(String value) { return value == null ? "" : value.trim(); } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private static String jsonEscape(String s) { + if (s == null) return ""; + StringBuilder out = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\') out.append("\\\\"); + else if (c == '"') out.append("\\\""); + else if (c == '\n') out.append("\\n"); + else if (c == '\r') out.append("\\r"); + else if (c == '\t') out.append("\\t"); + else out.append(c); + } + return out.toString(); + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Request.java index 139c5fa0..3703a33c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_CallInviteBroadcast_Request.java @@ -6,6 +6,7 @@ public class Net_CallInviteBroadcast_Request extends Net_Request { private String toLogin; private String callId; private Integer type; + private String data; public String getToLogin() { return toLogin; } public void setToLogin(String toLogin) { this.toLogin = toLogin; } @@ -15,4 +16,7 @@ public class Net_CallInviteBroadcast_Request extends Net_Request { public Integer getType() { return type; } public void setType(Integer type) { this.type = type; } + + public String getData() { return data; } + public void setData(String data) { this.data = data; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java index 53b807df..8842212f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java @@ -8,6 +8,9 @@ import java.util.List; public class Net_SendSignal_Response extends Net_Response { private int deliveredCount; private List deliveredSessionIds = new ArrayList<>(); + private int deliveredWsSessions; + private int deliveredFcmSessions; + private int deliveredWebPushSessions; public int getDeliveredCount() { return deliveredCount; @@ -24,4 +27,28 @@ public class Net_SendSignal_Response extends Net_Response { public void setDeliveredSessionIds(List deliveredSessionIds) { this.deliveredSessionIds = deliveredSessionIds; } + + public int getDeliveredWsSessions() { + return deliveredWsSessions; + } + + public void setDeliveredWsSessions(int deliveredWsSessions) { + this.deliveredWsSessions = deliveredWsSessions; + } + + public int getDeliveredFcmSessions() { + return deliveredFcmSessions; + } + + public void setDeliveredFcmSessions(int deliveredFcmSessions) { + this.deliveredFcmSessions = deliveredFcmSessions; + } + + public int getDeliveredWebPushSessions() { + return deliveredWebPushSessions; + } + + public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { + this.deliveredWebPushSessions = deliveredWebPushSessions; + } } diff --git a/TODO/README.md b/TODO/README.md index 6a13506f..43d0b4dc 100644 --- a/TODO/README.md +++ b/TODO/README.md @@ -49,6 +49,7 @@ - `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений. - `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи. - `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать. +- `medium/2026-08-09_esp32_переход_на_sendsignal_и_client_key.md` - перевести ESP32 и wallet-extension на единый `SendSignal`, обязательный `client key` и подготовить почву для будущего E2E-шифрования payload. - `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - завершить зачистку хвостов после перевода серверной БД с `SQLite` на `PostgreSQL`. ### dao_запуск diff --git a/TODO/medium/2026-08-09_esp32_переход_на_sendsignal_и_client_key.md b/TODO/medium/2026-08-09_esp32_переход_на_sendsignal_и_client_key.md new file mode 100644 index 00000000..f8e56e6d --- /dev/null +++ b/TODO/medium/2026-08-09_esp32_переход_на_sendsignal_и_client_key.md @@ -0,0 +1,89 @@ +# ESP32 и wallet-extension: перейти на единый `SendSignal` и обязательную подпись `client key` + +## Зачем + +Сейчас в проекте уже есть универсальный transport `SendSignal`, который умеет работать и в режим `all_sessions`, и в режим `single_session`. + +При этом в коде всё ещё живут старые call-like транспорты: + +- `CallInviteBroadcast` +- `CallSignalToSession` + +Для web-call логики их можно постепенно убрать в пользу одного `SendSignal`. + +Отдельный хвост остался в ESP32 homeserver/wallet-сценариях: + +- в ESP32-скетче технический ответ wallet RPC всё ещё уходит через `CallSignalToSession`; +- в ESP32 send-signal сценарии сейчас допускается режим только с `sessionSignatureB64`, без обязательной подписи `client key`; +- такое поведение выбивается из общей модели безопасности и создаёт лишнюю специальную ветку. + +## Что уже есть в коде + +- универсальный серверный transport `SendSignal` уже существует и поддерживает: + - `single_session`; + - `all_sessions`. +- web UI уже использует `SendSignal` в части межсессионных сценариев. +- звонки пока ещё используют старые call-specific методы. +- ESP32 homeserver main использует: + - `CallSignalToSession` для wallet RPC response; + - `SendSignal` для части технических ответов. +- в ESP32 есть код генерации: + - `sessionSignatureB64`; + - `clientSignatureB64`; + но для некоторых send-signal вызовов `client key` сейчас не обязателен. + +## Что сделать + +1. Перевести ESP32 сценарии со старого `CallSignalToSession` на `SendSignal(single_session)`. +2. Убрать из ESP32-special flow странное исключение, где можно жить без `client key`. +3. Переделать wallet-extension / связанный ESP32-клиент так, чтобы он тоже хранил `client key` и подписывал им `SendSignal`, как обычные клиенты SHiNE. +4. Сделать `SendSignal` контрактно обязательным по подписи `client key`, а не только `session key`. +5. После этого зачистить legacy call-like транспорт там, где он больше не нужен. +6. Отдельно проверить, не осталось ли старых обработчиков, завязанных на: + - `IncomingCallSignal`; + - `IncomingCallInvite`; + там, где должен работать общий `IncomingSignal`. + +## Отдельно про шифрование + +Нужно продумать будущее расширение: чтобы через `SendSignal` можно было передавать не только подписанные, но и зашифрованные payload. + +Предварительный вывод по текущей архитектуре: + +- для сервера это почти не должно требовать специальной логики; +- сервер в `SendSignal` в основном: + - валидирует подписи; + - маршрутизирует событие в нужные сессии; + - пересылает `data` дальше. + +Если UI/ESP32 начнут передавать уже клиентски зашифрованный `data`, сервер должен это пережить без серьёзных изменений, пока: + +- формат `data` остаётся строковым/JSON-совместимым; +- сервер продолжает считать `SHA-256` от фактической передаваемой строки; +- подписи строятся по уже зашифрованному payload. + +То есть возможное E2E-шифрование `SendSignal` в будущем скорее относится к клиентским участникам протокола, а не к серверной маршрутизации. + +## Что важно учесть при миграции + +- не ломать текущие звонки и текущий wallet RPC сценарий одним большим рывком; +- сначала перевести ESP32 и wallet-extension на единый `SendSignal`; +- только потом убирать старые call-specific методы; +- не оставлять “особый ESP32-режим” без `client key`; +- проверить, что у расширения/ESP32 есть безопасное хранение `client key` и понятный сценарий восстановления/перепривязки. + +## Откуда продолжать + +- от `TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md`; +- от текущего ESP32 homeserver-скетча: + - `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino` +- от web UI auth/call transport логики: + - `shine-UI/js/services/auth-service.js` + - `shine-UI/js/services/call-service.js` + +## Какие документы потом обновить + +- `TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md`; +- `TODO/README.md`; +- документацию по ESP32 homeserver/wallet flow, если появится отдельный утверждённый transport contract; +- документацию по межсессионным сигналам, если будет зафиксирован единый список `signalType`. diff --git a/VERSION.properties b/VERSION.properties index 6e597b10..4bc97b5c 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.4 -server.version=1.4.6 +client.version=1.5.5 +server.version=1.4.7 diff --git a/docs/API/05_Technical_Requests_API.md b/docs/API/05_Technical_Requests_API.md index d458d04a..84f51356 100644 --- a/docs/API/05_Technical_Requests_API.md +++ b/docs/API/05_Technical_Requests_API.md @@ -297,6 +297,7 @@ Первый целевой сценарий: - `remote AddBlock via homeserver session` +- межсессионная доставка звонковых `call_*` сигналов между устройствами пользователя То есть телефон без локального `blockchain.key` может: @@ -336,6 +337,12 @@ - запрос должен быть подписан и `session key`, и `client key`; - в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой. +Для звонковых сигналов `signalType = call_*` правило строже: + +- `clientSignatureB64` обязателен; +- сервер отклоняет такой `SendSignal`, если `client key` подпись не передана; +- это правило действует и для `single_session`, и для `all_sessions`. + ### Запрос в одну сессию ```json @@ -366,11 +373,20 @@ "ok": true, "payload": { "deliveredCount": 1, - "deliveredSessionIds": ["sess-hs-001"] + "deliveredSessionIds": ["sess-hs-001"], + "deliveredWsSessions": 1, + "deliveredFcmSessions": 0, + "deliveredWebPushSessions": 0 } } ``` +Для `call_invite` с `targetMode = "all_sessions"` сервер может дополнительно отправить offline web-push в те сессии адресата, которые сейчас не подключены по WebSocket. В таком случае: + +- `deliveredWsSessions` — сколько сессий получили `IncomingSignal` по WebSocket; +- `deliveredWebPushSessions` — сколько offline-сессий получили web-push; +- `deliveredFcmSessions` пока дублирует тот же счётчик push-доставки и зарезервирован под отдельные push-каналы. + ### Событие на принимающей стороне ```json @@ -428,6 +444,7 @@ - `404 / USER_NOT_FOUND` — логин адресата не найден. - `400 / BAD_DATA` — сервер не смог обработать `data`. - `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`. +- `400 / CLIENT_SIGNATURE_REQUIRED` — для `call_*` сигнала не передана обязательная подпись `client key`. - `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`. - `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн. - `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий. diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 82e141ee..fe92aee7 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -10,6 +10,7 @@ import { handleIncomingCallInvite, handleIncomingCallPush, handleIncomingCallSignal, + handleIncomingCallSignalViaSendSignal, handleStopCallPush, setCallDebugReporter, startDebugConnectionAsInitiator, @@ -1313,6 +1314,10 @@ async function init() { try { await handleIncomingCallSignal(evt); } catch {} }); + authService.onEvent('IncomingSignal', async (evt) => { + try { await handleIncomingCallSignalViaSendSignal(evt); } catch {} + }); + authService.onEvent('DebugConnectPrepareResponder', async (evt) => { try { const p = evt?.payload || {}; diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index fd1ec31f..432147ce 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -2631,8 +2631,8 @@ export class AuthService { } - async callInviteBroadcast({ toLogin, callId, type = 100 }) { - const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type }); + 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 || {}; } diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index c1865340..6e35c49e 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -8,10 +8,25 @@ const TYPES = { DECLINE_BUSY: 130, TIMEOUT: 140, HANGUP: 150, + CONNECT_START: 170, OFFER: 200, ANSWER: 210, ICE: 220, }; +const SIGNAL_TARGET_SINGLE = 'single_session'; +const SIGNAL_TARGET_ALL = 'all_sessions'; +const CALL_SIGNAL_TYPES = Object.freeze({ + [TYPES.INVITE]: 'call_invite', + [TYPES.RINGING]: 'call_ringing', + [TYPES.ACCEPT]: 'call_accept', + [TYPES.DECLINE_BUSY]: 'call_decline_busy', + [TYPES.TIMEOUT]: 'call_timeout', + [TYPES.HANGUP]: 'call_hangup', + [TYPES.CONNECT_START]: 'call_connect_start', + [TYPES.OFFER]: 'call_offer', + [TYPES.ANSWER]: 'call_answer', + [TYPES.ICE]: 'call_ice', +}); const calls = new Map(); const callStateListeners = new Set(); @@ -71,12 +86,54 @@ function getSignalTypeName(type) { if (normalized === TYPES.DECLINE_BUSY) return 'decline_busy'; if (normalized === TYPES.TIMEOUT) return 'timeout'; if (normalized === TYPES.HANGUP) return 'hangup'; + if (normalized === TYPES.CONNECT_START) return 'connect_start'; if (normalized === TYPES.OFFER) return 'offer'; if (normalized === TYPES.ANSWER) return 'answer'; if (normalized === TYPES.ICE) return 'ice'; return `type_${String(type || '')}`; } +function buildConnectStartData(winnerSessionId) { + return `session=${String(winnerSessionId || '').trim()}`; +} + +function parseConnectStartWinnerSessionId(data = '') { + const raw = String(data || '').trim(); + if (!raw) return ''; + if (raw.startsWith('session=')) return raw.slice('session='.length).trim(); + if (raw.startsWith('winnerSessionId=')) return raw.slice('winnerSessionId='.length).trim(); + return ''; +} + +function getCallSignalType(type) { + return CALL_SIGNAL_TYPES[Number(type)] || ''; +} + +function encodeCallSignalData(callId, type, data = '') { + return JSON.stringify({ + callId: String(callId || '').trim(), + type: Number(type), + data: String(data || ''), + }); +} + +function decodeCallSignalData(raw) { + try { + const parsed = JSON.parse(String(raw || '{}')); + return { + callId: String(parsed?.callId || '').trim(), + type: Number(parsed?.type), + data: String(parsed?.data || ''), + }; + } catch { + return { + callId: '', + type: Number.NaN, + data: '', + }; + } +} + function ensureCallTimeline(call) { if (!call) return; if (!Array.isArray(call.timelineEvents)) call.timelineEvents = []; @@ -1168,13 +1225,7 @@ async function finalizeCall(call, { const dataValue = notifyRemoteHangup ? '' : `setup_failed:${String(localReasonCode || 'error')}:${String(debugReason || '').slice(0, 80)}`; - await authService.callSignalToSession({ - toLogin: call.peerLogin, - targetSessionId: call.remoteSessionId, - callId: call.callId, - type: TYPES.HANGUP, - data: dataValue, - }); + await sendSignal(call, TYPES.HANGUP, dataValue); } catch {} } @@ -1255,13 +1306,22 @@ async function emitDebug(call, level, message, details = '') { async function sendSignal(call, type, data = '') { if (!call.remoteSessionId) return; const signalName = getSignalTypeName(type); + const signalType = getCallSignalType(type); + const login = String(state?.session?.login || '').trim(); + const storagePwd = String(state?.session?.storagePwdInMemory || '').trim(); + if (!login || !storagePwd || !signalType) { + throw new Error('Call SendSignal: missing login/storagePwd/signalType'); + } try { - await authService.callSignalToSession({ + await authService.sendSignal({ toLogin: call.peerLogin, + targetMode: SIGNAL_TARGET_SINGLE, targetSessionId: call.remoteSessionId, - callId: call.callId, - type, - data, + signalType, + signalRequestId: call.callId, + data: encodeCallSignalData(call.callId, type, data), + storagePwd, + includeClientSignature: true, }); recordCallTimeline(call, `signal_out_${signalName}`, `toSession=${call.remoteSessionId}; len=${String(data || '').length}`); await emitDebug(call, 'info', `signal_sent_${type}`, `len=${String(data || '').length}`); @@ -1280,6 +1340,50 @@ async function sendSignal(call, type, data = '') { } } +async function broadcastSignal(call, type, data = '') { + const signalName = getSignalTypeName(type); + const signalType = getCallSignalType(type); + const login = String(state?.session?.login || '').trim(); + const storagePwd = String(state?.session?.storagePwdInMemory || '').trim(); + if (!call?.peerLogin || !login || !storagePwd || !signalType) { + throw new Error('Call SendSignal broadcast: missing peer/login/storagePwd/signalType'); + } + try { + const response = await authService.sendSignal({ + toLogin: call.peerLogin, + targetMode: SIGNAL_TARGET_ALL, + signalType, + signalRequestId: call.callId, + data: encodeCallSignalData(call.callId, type, data), + storagePwd, + includeClientSignature: true, + }); + recordCallTimeline( + call, + `signal_broadcast_${signalName}`, + `ws=${Number(response?.deliveredWsSessions || response?.deliveredCount || 0)}; push=${Number(response?.deliveredWebPushSessions || response?.deliveredFcmSessions || 0)}`, + ); + return response || {}; + } catch (error) { + recordCallTimeline(call, `signal_broadcast_${signalName}_failed`, toErrorText(error), 'warn'); + throw error; + } +} + +async function notifyPeerSessionsAboutConnectStart(call) { + const winnerSessionId = String(call?.remoteSessionId || '').trim(); + if (!call || !winnerSessionId) return; + const data = buildConnectStartData(winnerSessionId); + try { + await broadcastSignal(call, TYPES.CONNECT_START, data); + recordCallTimeline(call, 'connect_start_broadcast_sent', `winnerSession=${winnerSessionId}`); + await emitDebug(call, 'info', 'connect_start_broadcast_sent', `winnerSession=${winnerSessionId}`); + } catch (error) { + recordCallTimeline(call, 'connect_start_broadcast_failed', `winnerSession=${winnerSessionId}; error=${toErrorText(error)}`, 'warn'); + await emitDebug(call, 'warn', 'connect_start_broadcast_failed', toErrorText(error)); + } +} + async function ensurePeerConnection(call) { if (call.pc) return call.pc; @@ -1451,6 +1555,7 @@ async function onAccept(call) { cleanupTimers(call); setStatus(call, 'Соединяем…', 'connecting'); try { + await notifyPeerSessionsAboutConnectStart(call); const pc = await ensurePeerConnection(call); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); @@ -1496,16 +1601,40 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) { const callId = String(payload?.callId || '').trim(); const fromLogin = String(payload?.fromLogin || '').trim(); const fromSessionId = String(payload?.fromSessionId || '').trim(); + const type = Number(payload?.type || TYPES.INVITE); + if (type === TYPES.CONNECT_START) { + const call = getCall(callId); + if (!call || call.direction !== 'in' || call.phase === 'ended') return call; + const winnerSessionId = parseConnectStartWinnerSessionId(payload?.data || ''); + const currentSessionId = String(state?.session?.sessionId || '').trim(); + recordCallTimeline(call, 'connect_start_broadcast_received', `winnerSession=${winnerSessionId || '-'}; currentSession=${currentSessionId || '-'}`); + if (winnerSessionId && currentSessionId && winnerSessionId === currentSessionId) { + await emitDebug(call, 'info', 'connect_start_kept_on_winner_session', winnerSessionId); + return call; + } + dismissCallUiLocally(call); + await finalizeCall(call, { + localReasonCode: 'completed', + debugReason: 'answered_elsewhere_session_selected', + suppressRemoteSignal: true, + suppressReports: true, + suppressSummary: true, + }); + return call; + } if (!callId || !fromLogin || !fromSessionId) return null; if (activeCallId && activeCallId !== callId) { try { - await authService.callSignalToSession({ + await authService.sendSignal({ toLogin: fromLogin, + targetMode: SIGNAL_TARGET_SINGLE, targetSessionId: fromSessionId, - callId, - type: TYPES.DECLINE_BUSY, - data: 'busy', + signalType: getCallSignalType(TYPES.DECLINE_BUSY), + signalRequestId: callId, + data: encodeCallSignalData(callId, TYPES.DECLINE_BUSY, 'busy'), + storagePwd: state?.session?.storagePwdInMemory || '', + includeClientSignature: true, }); } catch {} return null; @@ -1804,7 +1933,7 @@ export async function startOutgoingCall(peerLogin) { }, 35000); try { - call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); + call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE); recordCallTimeline( call, 'invite_broadcast_ok', @@ -1822,7 +1951,7 @@ export async function startOutgoingCall(peerLogin) { const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true }); if (recovered) { try { - call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); + call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE); if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') { setStatus(call, 'Вызываем…', 'ringing'); } @@ -1842,6 +1971,33 @@ export async function handleIncomingCallInvite(evt) { await handleIncomingInvitePayload(evt?.payload || {}, { source: 'ws' }); } +export async function handleIncomingCallSignalViaSendSignal(evt) { + const payload = evt?.payload || {}; + const signalType = String(payload?.signalType || '').trim(); + if (!signalType.startsWith('call_')) return false; + + const decoded = decodeCallSignalData(payload?.data || ''); + if (!Number.isFinite(decoded.type)) return true; + + const normalizedEvent = { + payload: { + callId: decoded.callId || String(payload?.signalRequestId || '').trim(), + fromLogin: payload?.fromLogin, + fromSessionId: payload?.fromSessionId, + type: decoded.type, + data: decoded.data, + }, + }; + + if (decoded.type === TYPES.INVITE || decoded.type === TYPES.CONNECT_START) { + await handleIncomingInvitePayload(normalizedEvent.payload, { source: 'send_signal' }); + return true; + } + + await handleIncomingCallSignal(normalizedEvent); + return true; +} + export async function acceptIncomingCall() { const call = getActiveCall(); if (!call || call.direction !== 'in' || call.phase !== 'incoming') return; @@ -1885,23 +2041,11 @@ export async function handleIncomingCallSignal(evt) { recordCallTimeline(call, `signal_in_${signalName}`, `from=${fromSessionId || '-'}; len=${data.length}`); if (call.direction === 'out') { if (type === TYPES.RINGING) { - if (!call.remoteSessionId && fromSessionId) { - setRemoteSessionId(call, fromSessionId, 'ringing'); - } - if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) { - recordCallTimeline(call, 'signal_in_ringing_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`); - await emitDebug( - call, - 'info', - 'ringing_from_non_selected_session_ignored', - `selected=${call.remoteSessionId}; from=${fromSessionId}`, - ); - return; - } + recordCallTimeline(call, 'ringing_received', `from=${fromSessionId || '-'}`); } else if (type === TYPES.ACCEPT) { if (fromSessionId) { - if (!call.remoteSessionId || !call.initialOfferSent) { - setRemoteSessionId(call, fromSessionId, 'accept_before_offer_lock'); + if (!call.remoteSessionId) { + setRemoteSessionId(call, fromSessionId, 'accept'); } else if (call.remoteSessionId !== fromSessionId) { recordCallTimeline(call, 'signal_in_accept_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`, 'warn'); await emitDebug( @@ -1961,6 +2105,12 @@ export async function handleIncomingCallSignal(evt) { return; } + if (type === TYPES.CONNECT_START) { + const winnerSessionId = parseConnectStartWinnerSessionId(data); + recordCallTimeline(call, 'connect_start_received', `winnerSession=${winnerSessionId || '-'}; currentRemoteSession=${call.remoteSessionId || '-'}`); + return; + } + if (type === TYPES.ACCEPT) { if (call.direction !== 'out') { await emitDebug(call, 'warn', 'accept_ignored_for_non_outgoing_call', `direction=${call.direction || ''}`); From ffd01814b06bd5f9f523631e4a27517bded0c37ea3f292389efcaca71e4684f2 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 9 Aug 2026 20:54:52 +0400 Subject: [PATCH 04/27] =?UTF-8?q?=D0=A3=D1=82=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D1=83=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/app.js | 5 +-- shine-UI/js/pages/chat-view.js | 40 +++++++++++++++-------- shine-UI/js/services/dm-tech-blocks.js | 12 +++---- shine-UI/styles/components.css | 45 ++++++++++++++++++++++---- 5 files changed, 75 insertions(+), 29 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 4bc97b5c..e6ac240d 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.5 +client.version=1.5.6 server.version=1.4.7 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index fe92aee7..70366603 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -1211,13 +1211,14 @@ async function init() { if (added && isIncomingForCurrent && !payload.backlog) { const parsedText = parseDmTechBlocks(text); const displayText = String(parsedText.displayText || ''); + const suppressHiddenNotification = parsedText.callSummary?.status === 'completed'; if (document.visibilityState === 'visible') { void playDmSignal({ extended: false }); - } else if (Notification.permission === 'granted') { + } else if (Notification.permission === 'granted' && !suppressHiddenNotification) { try { void notifyHiddenIncomingMessage(fromLogin, displayText || ''); } catch {} - } else { + } else if (!suppressHiddenNotification) { void playDmSignal({ extended: true }); } } diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 9fbbe469..2e49d714 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -440,12 +440,12 @@ function buildCallStatusText(callSummary) { return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`; } const reason = String(callSummary.reason || '').trim().toLowerCase(); - if (reason === 'offline') return 'Не дозвонился: абонент не в сети'; - if (reason === 'no_answer') return 'Не дозвонился: нет ответа'; - if (reason === 'connect_failed') return 'Не дозвонился: не удалось установить соединение'; - if (reason === 'busy') return 'Не дозвонился: абонент занят'; - if (reason === 'declined') return 'Не дозвонился: звонок отклонён'; - return 'Не дозвонился'; + if (reason === 'offline') return 'Абонент не в сети'; + if (reason === 'no_answer') return 'Нет ответа'; + if (reason === 'connect_failed') return 'Не удалось установить соединение'; + if (reason === 'busy') return 'Абонент занят'; + if (reason === 'declined') return 'Звонок отклонён'; + return 'Звонок не завершён'; } function resolveEffectiveReadState(messages, msg) { @@ -594,20 +594,34 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode const callIcon = document.createElement('span'); callIcon.className = 'bubble-call-icon'; - callIcon.textContent = '☎'; + callIcon.classList.add(msg?.from === 'out' ? 'bubble-call-icon--outgoing' : 'bubble-call-icon--incoming'); + + const callIconPhone = document.createElement('span'); + callIconPhone.className = 'bubble-call-icon-phone'; + callIconPhone.textContent = '✆'; + + const callIconArrow = document.createElement('span'); + callIconArrow.className = 'bubble-call-icon-arrow'; + callIconArrow.textContent = msg?.from === 'out' ? '↑' : '↓'; + + callIcon.append(callIconPhone, callIconArrow); const callTitle = document.createElement('span'); callTitle.className = 'bubble-call-title'; - callTitle.textContent = msg?.from === 'out' ? 'Исходящий звонок' : 'Входящий звонок'; + callTitle.textContent = 'Звонок'; - callHead.append(callIcon, callTitle); - - const callMetaLine = document.createElement('div'); - callMetaLine.className = 'bubble-call-line bubble-call-line--muted'; + const callMetaLine = document.createElement('span'); + callMetaLine.className = 'bubble-call-line'; + callMetaLine.classList.add( + parsedText.callSummary.status === 'completed' + ? 'bubble-call-line--success' + : 'bubble-call-line--plain', + ); const callStatus = buildCallStatusText(parsedText.callSummary); callMetaLine.textContent = callStatus; - callCard.append(callHead, callMetaLine); + callHead.append(callIcon, callTitle, callMetaLine); + callCard.append(callHead); bubble.append(callCard); } diff --git a/shine-UI/js/services/dm-tech-blocks.js b/shine-UI/js/services/dm-tech-blocks.js index 841a2351..2ba76fe3 100644 --- a/shine-UI/js/services/dm-tech-blocks.js +++ b/shine-UI/js/services/dm-tech-blocks.js @@ -47,12 +47,12 @@ function buildCallDisplayText(callSummary) { return `Звонок: ${formatCallDuration(callSummary.durationSec)}`; } const reason = String(callSummary.reason || '').trim().toLowerCase(); - if (reason === 'offline') return 'Звонил, но недозвонился: абонент не в сети'; - if (reason === 'no_answer') return 'Звонил, но недозвонился: нет ответа'; - if (reason === 'connect_failed') return 'Звонил, но недозвонился: не удалось установить соединение'; - if (reason === 'busy') return 'Звонил, но недозвонился: абонент занят'; - if (reason === 'declined') return 'Звонил, но недозвонился: звонок отклонён'; - return 'Звонил, но недозвонился'; + if (reason === 'offline') return 'Абонент не в сети'; + if (reason === 'no_answer') return 'Нет ответа'; + if (reason === 'connect_failed') return 'Не удалось установить соединение'; + if (reason === 'busy') return 'Абонент занят'; + if (reason === 'declined') return 'Звонок отклонён'; + return 'Звонок не завершён'; } export function sanitizeUserDmTextForSend(rawText = '') { diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index f7b74f9e..f1fcb93d 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -1763,7 +1763,6 @@ .bubble-call-card { display: grid; - gap: 4px; min-width: min(280px, 72vw); } @@ -1771,11 +1770,14 @@ display: inline-flex; align-items: center; gap: 8px; + flex-wrap: wrap; } .bubble-call-icon { - display: inline-grid; - place-items: center; + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; width: 28px; height: 28px; border-radius: 999px; @@ -1783,22 +1785,51 @@ border: 1px solid rgba(123, 191, 255, 0.28); color: #d9ecff; font-size: 15px; + flex: 0 0 auto; +} + +.bubble-call-icon-phone { + font-size: 15px; + line-height: 1; + transform: translateX(-1px); +} + +.bubble-call-icon-arrow { + position: absolute; + right: 4px; + bottom: 3px; + font-size: 10px; + line-height: 1; + font-weight: 700; +} + +.bubble-call-icon--incoming .bubble-call-icon-arrow { + color: rgba(167, 225, 255, 0.98); +} + +.bubble-call-icon--outgoing .bubble-call-icon-arrow { + color: rgba(255, 214, 132, 0.98); } .bubble-call-title { font-size: 14px; font-weight: 700; color: #eef6ff; + line-height: 1.2; } .bubble-call-line { font-size: 12px; - line-height: 1.35; - color: rgba(230, 240, 255, 0.94); + line-height: 1.2; + margin-left: 2px; } -.bubble-call-line--muted { - color: rgba(188, 207, 238, 0.78); +.bubble-call-line--success { + color: #7ee19e; +} + +.bubble-call-line--plain { + color: rgba(230, 240, 255, 0.94); } .chat-input { From 60f8a50a97c2c138347dec865c1b7d92fb3f471cf1e1e107cb98528a30e3b582 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 9 Aug 2026 20:57:04 +0400 Subject: [PATCH 05/27] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D1=83?= =?UTF-8?q?=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=B0=20=D0=B2=20=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/styles/components.css | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index e6ac240d..c7b3895e 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.6 +client.version=1.5.7 server.version=1.4.7 diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index f1fcb93d..ff3f690b 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -1780,18 +1780,14 @@ justify-content: center; width: 28px; height: 28px; - border-radius: 999px; - background: rgba(80, 170, 255, 0.18); - border: 1px solid rgba(123, 191, 255, 0.28); color: #d9ecff; - font-size: 15px; flex: 0 0 auto; } .bubble-call-icon-phone { - font-size: 15px; + font-size: 28px; line-height: 1; - transform: translateX(-1px); + transform: translateY(-1px); } .bubble-call-icon-arrow { From 735bb55b78154302a418700913a2f066ed15257a851945485f3fcb77633c8d0e Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 9 Aug 2026 21:02:44 +0400 Subject: [PATCH 06/27] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D1=83=20DM=20=D0=BF?= =?UTF-8?q?=D0=BE=20Enter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/pages/chat-view.js | 19 +------------------ 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index c7b3895e..230d9d6f 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.7 +client.version=1.5.8 server.version=1.4.7 diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 2e49d714..7c6c80aa 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -977,7 +977,7 @@ export function render({ navigate, route }) { - +
@@ -1441,23 +1441,6 @@ export function render({ navigate, route }) { if (!emojiPickerOpen || form.contains(event.target)) return; closeEmojiPicker(); }); - // Enter — отправить; Shift+Enter и Ctrl+Enter — перенос строки. - input?.addEventListener('keydown', async (event) => { - if (event.key !== 'Enter') return; - if (event.shiftKey || event.ctrlKey) { - return; - } - event.preventDefault(); - const text = String(input.value || '').trim(); - if (!text) return; - input.value = ''; - autoResizeComposer(input); - syncComposerLayout(); - closeEmojiPicker(); - await sendTextMessage(text); - focusInputToEnd(); - }); - form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => { event.preventDefault(); try { From 3552e05e4c342eec0ec10e32706717eef8a61c1276a8b3c4d59e486fa512e37e Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 9 Aug 2026 21:26:08 +0400 Subject: [PATCH 07/27] =?UTF-8?q?UI:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=D0=BB=D0=B0=20=D0=B8=20=D1=82=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/pages/channel-thread-view.js | 27 ++++++++++--- shine-UI/js/pages/channel-view.js | 41 +++++++++++++------ shine-UI/js/services/auth-service.js | 5 ++- shine-UI/styles/components.css | 50 +++++++++++++++++++++--- 5 files changed, 99 insertions(+), 26 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 230d9d6f..2e486484 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.8 +client.version=1.5.9 server.version=1.4.7 diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index 65a62d97..d179abce 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -209,6 +209,24 @@ function resolveChannelDisplayName(channelSelector) { return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`; } +function resolveChannelHeadingFromNode(node) { + const info = node?.channelInfo; + const ownerBch = String(info?.ownerBlockchainName || '').trim(); + const rootBlockNumber = Number(info?.channelRoot?.blockNumber); + if (!ownerBch || !Number.isFinite(rootBlockNumber) || rootBlockNumber < 0) return ''; + if (rootBlockNumber === 0) return 'История пользователя'; + + const label = resolveChannelDisplayName({ + ownerBlockchainName: ownerBch, + channelRootBlockNumber: rootBlockNumber, + channelRootBlockHash: '0', + }); + const slashIndex = label.indexOf('/'); + const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : label.trim(); + if (!channelName) return 'Сообщение в канале'; + return `Сообщение в канале ${channelName}`; +} + function extractChannelContextFromThreadPayload(payload) { const focusInfo = payload?.focus?.channelInfo; if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) { @@ -706,7 +724,7 @@ function renderNodeCard(node, heading, handlers, localNumber) { const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(); const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0; const msgSubType = Number(node?.msgSubType || 0); - const repostTarget = msgSubType === 30 ? buildRepostTargetFromNode(node) : null; + const repostTarget = msgSubType === 50 ? buildRepostTargetFromNode(node) : null; const parsedText = parseMessageAttachments(text); const headingText = String(heading || '').trim(); @@ -1326,12 +1344,9 @@ export function render({ navigate, route }) { if (ancestors.length) { ancestorsWrap = document.createElement('div'); ancestorsWrap.className = 'stack thread-block thread-block--ancestors'; - const title = document.createElement('h3'); - title.className = 'section-title'; - title.textContent = 'История выше (на что это ответ)'; - ancestorsWrap.append(title); ancestors.forEach((node, index) => { - ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber())); + const heading = index === 0 ? resolveChannelHeadingFromNode(node) : ''; + ancestorsWrap.append(renderNodeCard(node, heading, handlers, nextNumber())); }); } diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 1c64a2d0..11c7d6cb 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -37,6 +37,7 @@ import { buildArweaveDataUrl } from '../services/arweave-file-service.js'; export const pageMeta = { id: 'channel-view', title: 'Канал' }; const CHANNEL_TYPE_STORIES = 0; const CHANNEL_TYPE_PERSONAL = 100; +const MSG_SUBTYPE_TEXT_REPOST = 50; const pendingReactionActions = new Set(); const pendingScrollByRoute = new Map(); @@ -303,7 +304,19 @@ function createChannelAvatarElement(channel, size = 72) { return wrap; } -function openChannelMetaDetailsModal({ title = 'Данные канала', channel = {}, canEdit = false, onEdit = null } = {}) { +function openChannelMetaDetailsModal({ + title = 'Данные канала', + channel = {}, + canEdit = false, + onEdit = null, + changedAtMs = 0, +} = {}) { + const avatarState = String(channel.avaAr || '').trim() + ? 'Установлен' + : 'Не установлен'; + const changedAtLabel = changedAtMs + ? new Date(Number(changedAtMs)).toLocaleString('ru-RU') + : '—'; const root = document.getElementById('modal-root'); root.innerHTML = `
-

${escapeHtml(channel.displayTitle || channel.displayName || channel.name)}

-

${escapeHtml(`${channel.ownerName || 'автор'} / ${channel.name || 'channel'}`)}

-

${escapeHtml(channel.description || 'Описание не задано.')}

+
+ Дата${escapeHtml(changedAtLabel)} + Владелец${escapeHtml(channel.ownerName || 'автор')} + Системное имя${escapeHtml(channel.name || 'channel')} + Название${escapeHtml(channel.displayTitle || channel.displayName || channel.name || '—')} + Описание${escapeHtml(channel.description || 'Описание не задано.')} + Аватар${escapeHtml(avatarState)} +
@@ -337,6 +355,7 @@ function openAboutChannelModal(channel, options = {}) { channel, canEdit: options.canEdit === true, onEdit: options.onEdit, + changedAtMs: Number(channel?.metaUpdatedAtMs || 0), }); } @@ -1181,15 +1200,14 @@ function renderChannelMetaEventCard(event) { const card = document.createElement('article'); card.className = 'card channel-system-event-card'; const label = event.kind === 'created' - ? `Создан канал ${event.title || event.name || ''}`.trim() - : 'Изменён профиль канала'; + ? 'Создан канал' + : 'Изменено описание канала'; card.innerHTML = ` - ${escapeHtml(label)} - ${escapeHtml(event.timestampMs ? new Date(event.timestampMs).toLocaleString('ru-RU') : '')} + ${escapeHtml(label)} `; card.addEventListener('click', () => { openChannelMetaDetailsModal({ - title: event.kind === 'created' ? 'Канал создан' : 'Профиль канала изменён', + title: label, channel: { name: event.name, displayTitle: event.title, @@ -1199,6 +1217,7 @@ function renderChannelMetaEventCard(event) { avaSize: event.avaSize, ownerName: event.ownerName, }, + changedAtMs: event.timestampMs, }); }); return card; @@ -1377,7 +1396,7 @@ function renderPostCard(post, { }); actions.append(shareButton); - if (post.msgSubType === 30 && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) { + if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) { const originalBtn = document.createElement('button'); originalBtn.type = 'button'; originalBtn.className = 'channel-action-item'; @@ -1797,7 +1816,7 @@ export function render({ navigate, route }) { channelHeaderButton.onclick = (event) => { animatePress(event.currentTarget); openAboutChannelModal(apiData.channel, { - canEdit: apiData?.isOwnChannel === true, + canEdit: apiData?.isOwnChannel === true && !isStoriesChannel(apiData?.channel), onEdit: () => openEditChannelModal({ channel: apiData.channel, onSave: onEditChannelMeta, diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 432147ce..0179c39d 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -53,8 +53,9 @@ 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_REPOST = 30; -const MSG_SUBTYPE_TEXT_CHANNEL_META = 70; +const MSG_SUBTYPE_TEXT_RATING = 30; +const MSG_SUBTYPE_TEXT_REPOST = 50; +const MSG_SUBTYPE_TEXT_CHANNEL_META = 90; const MSG_SUBTYPE_REACTION_LIKE = 1; const MSG_SUBTYPE_REACTION_UNLIKE = 2; const MSG_SUBTYPE_CONNECTION_FOLLOW = 30; diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index ff3f690b..9ef777e4 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -3939,6 +3939,12 @@ textarea.input { gap: 10px; } +.channels-screen--channel .channel-feed { + gap: 2px; + margin-left: -7px; + margin-right: -7px; +} + .channels-screen .channel-message-card { gap: 14px; padding: 20px; @@ -3950,6 +3956,10 @@ textarea.input { box-shadow: 0 0 60px rgba(80, 60, 180, 0.15); } +.channels-screen--channel .channel-message-card { + margin-bottom: 6px; +} + .channel-message-top { display: flex; align-items: center; @@ -4037,19 +4047,41 @@ textarea.input { .channel-system-event-card { display: flex; align-items: center; - justify-content: space-between; + justify-content: center; gap: 10px; - padding: 9px 11px; + width: fit-content; + max-width: min(88%, 460px); + margin: 0 auto 4px; + padding: 8px 16px; border-color: rgba(255, 210, 130, 0.22); - background: rgba(89, 67, 31, 0.22); + border-radius: 999px; + background: rgba(89, 67, 31, 0.26); color: rgba(255, 235, 188, 0.92); cursor: pointer; + text-align: center; } -.channel-system-event-card small { - flex: 0 0 auto; +.channel-system-event-card__label { + display: block; + font-size: 13px; + line-height: 1.2; +} + +.channel-meta-details-grid { + display: grid; + grid-template-columns: minmax(96px, 120px) 1fr; + gap: 10px 14px; + align-items: start; + color: rgba(255, 255, 255, 0.92); +} + +.channel-meta-details-grid > span:nth-child(odd) { color: rgba(255, 255, 255, 0.48); - font-size: 11px; +} + +.channel-meta-details-grid code { + color: rgba(255, 214, 117, 0.92); + word-break: break-word; } .channel-profile-card { @@ -5331,6 +5363,7 @@ textarea.input { } #about-channel-modal.modal, +#edit-channel-modal.modal, #reply-modal.modal, #channel-message-modal.modal, #channel-edit-description-modal.modal, @@ -5342,6 +5375,7 @@ textarea.input { } #about-channel-modal .modal-card, +#edit-channel-modal .modal-card, #reply-modal .modal-card, #channel-message-modal .modal-card, #channel-edit-description-modal .modal-card, @@ -5356,6 +5390,7 @@ textarea.input { } #about-channel-modal .modal-title, +#edit-channel-modal .modal-title, #reply-modal .modal-title, #channel-message-modal .modal-title, #channel-edit-description-modal .modal-title, @@ -5368,6 +5403,7 @@ textarea.input { } #about-channel-modal .input, +#edit-channel-modal .input, #reply-modal .input, #channel-message-modal .input, #channel-edit-description-modal .input, @@ -5390,6 +5426,7 @@ textarea.input { } #about-channel-modal .secondary-btn, +#edit-channel-modal .secondary-btn, #reply-modal .secondary-btn, #channel-message-modal .secondary-btn, #channel-edit-description-modal .secondary-btn, @@ -5403,6 +5440,7 @@ textarea.input { } #about-channel-modal .primary-btn, +#edit-channel-modal .primary-btn, #reply-modal .primary-btn, #channel-message-modal .primary-btn, #channel-edit-description-modal .primary-btn, From ee185cf20ada3618e2b938131dfacd40f20203b9a4a1d09432f19ab765c4eede Mon Sep 17 00:00:00 2001 From: AidarKC Date: Mon, 10 Aug 2026 01:16:10 +0400 Subject: [PATCH 08/27] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=80=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3=20?= =?UTF-8?q?=D0=B8=20=D1=82=D0=B8=D0=BF=D1=8B=20=D0=BC=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D0=B0=D0=BB=D0=BE=D0=B2=20=D0=B2=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channels/ChannelsReadSupport.java | 31 ++++- .../Net_GetChannelMessages_Handler.java | 1 + .../Net_GetMessageThread_Handler.java | 25 ++-- .../Net_GetChannelMessages_Response.java | 4 + VERSION.properties | 4 +- docs/API/06_Channels_Read_API.md | 8 ++ docs/Blockchain/CHANGELOG.md | 7 + shine-UI/js/pages/channel-thread-view.js | 97 +++++++++++-- shine-UI/js/pages/channel-view.js | 129 ++++++++++++++++-- shine-UI/js/services/auth-service.js | 78 ++++++++++- shine-UI/styles/components.css | 65 +++++++++ 11 files changed, 409 insertions(+), 40 deletions(-) diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index a3a2d560..0c59bfd1 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -309,15 +309,42 @@ final class ChannelsReadSupport { static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException { String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1"; + int likesCount = 0; + int repliesCount = 0; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, bch); ps.setInt(2, blockNumber); ps.setBytes(3, blockHash); try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return new int[] {0, 0}; - return new int[] {rs.getInt("likes_count"), rs.getInt("replies_count")}; + if (rs.next()) { + likesCount = rs.getInt("likes_count"); + repliesCount = rs.getInt("replies_count"); + } } } + String ratingsSql = """ + SELECT COUNT(*) + FROM blocks + WHERE msg_type = ? + AND msg_sub_type = ? + AND to_bch_name = ? + AND to_block_number = ? + AND to_block_hash = ? + """; + int ratingsCount = 0; + try (PreparedStatement ps = c.prepareStatement(ratingsSql)) { + ps.setInt(1, MSG_TYPE_TEXT); + ps.setInt(2, MsgSubType.TEXT_RATING); + ps.setString(3, bch); + ps.setInt(4, blockNumber); + ps.setBytes(5, blockHash); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + ratingsCount = rs.getInt(1); + } + } + } + return new int[] {likesCount, repliesCount, ratingsCount}; } static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index 882ad706..c3b063cd 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -167,6 +167,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash); item.setLikesCount(stats[0]); item.setRepliesCount(stats[1]); + item.setRatingsCount(stats[2]); item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash)); items.add(item); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java index 83b045b1..fe10d183 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java @@ -17,6 +17,7 @@ import shine.db.DbController; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.util.Comparator; import java.util.ArrayList; import java.util.Base64; import java.util.List; @@ -89,7 +90,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { private List loadChildren(Connection c, PostRow parent, int depthDown, int childLimit, String viewerLogin) throws Exception { if (depthDown <= 0) return List.of(); - List replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit); + List replies = findRepliesAndRatings(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit); List out = new ArrayList<>(); for (PostRow row : replies) { Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree(); @@ -100,24 +101,29 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { return out; } - private List findReplies(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception { + private List findRepliesAndRatings(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception { String sql = """ SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number FROM blocks - WHERE msg_type=1 AND msg_sub_type=? + WHERE msg_type=1 AND msg_sub_type IN (?, ?) AND to_bch_name=? AND to_block_number=? AND to_block_hash=? - ORDER BY block_number ASC - LIMIT ? """; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setInt(1, MsgSubType.TEXT_REPLY); - ps.setString(2, toBchName); - ps.setInt(3, toBlockNumber); - ps.setBytes(4, toBlockHash); - ps.setInt(5, limit); + ps.setInt(2, MsgSubType.TEXT_RATING); + ps.setString(3, toBchName); + ps.setInt(4, toBlockNumber); + ps.setBytes(5, toBlockHash); try (ResultSet rs = ps.executeQuery()) { List out = new ArrayList<>(); while (rs.next()) out.add(mapRow(rs)); + out.sort(Comparator + .comparingLong((PostRow row) -> ChannelsReadSupport.parseTextAndTime(row.blockBytes).createdAtMs) + .thenComparing(row -> String.valueOf(row.bchName)) + .thenComparingInt(row -> row.blockNumber)); + if (out.size() > limit) { + return new ArrayList<>(out.subList(0, limit)); + } return out; } } @@ -233,6 +239,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash); node.setLikesCount(stats[0]); node.setRepliesCount(stats[1]); + node.setRatingsCount(stats[2]); node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash)); if (row.lineCode != null && row.lineCode >= 0) { Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java index 9c1a80ff..35116490 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java @@ -132,6 +132,7 @@ public class Net_GetChannelMessages_Response extends Net_Response { private int likesCount; private boolean likedByMe; private int repliesCount; + private int ratingsCount; private int versionsTotal; private List versions = new ArrayList<>(); @@ -173,6 +174,9 @@ public class Net_GetChannelMessages_Response extends Net_Response { public int getRepliesCount() { return repliesCount; } public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; } + public int getRatingsCount() { return ratingsCount; } + public void setRatingsCount(int ratingsCount) { this.ratingsCount = ratingsCount; } + public int getVersionsTotal() { return versionsTotal; } public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; } diff --git a/VERSION.properties b/VERSION.properties index 2e486484..3ec10981 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.9 -server.version=1.4.7 +client.version=1.5.10 +server.version=1.4.8 diff --git a/docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md index ad6c30d3..5526458d 100644 --- a/docs/API/06_Channels_Read_API.md +++ b/docs/API/06_Channels_Read_API.md @@ -192,6 +192,7 @@ "text": "текущая версия", "likesCount": 12, "repliesCount": 3, + "ratingsCount": 2, "versionsTotal": 4, "versions": [ { "versionIndex": 1, "blockNumber": 140, "blockHash": "...", "text": "v1", "createdAtMs": 1760000000000 }, @@ -248,6 +249,13 @@ - `rawBlockB64` — сырой `block_bytes` текущего блока в Base64. - Поле `rawBlockB64` присутствует у узлов во всех частях ответа `GetMessageThread`: `focus`, `ancestors[]`, `descendants[]`. - В `GetChannelMessages` поле `rawBlockB64` **не добавляется** (лента канала без сырого блока, чтобы не раздувать ответ). +- И в `GetChannelMessages`, и в `GetMessageThread` каждое сообщение теперь содержит: + - `repliesCount` — число дочерних сообщений типа `TEXT_REPLY`; + - `ratingsCount` — число дочерних сообщений типа `TEXT_RATING`. +- В `descendants[]` операции `GetMessageThread` возвращаются оба типа дочерних текстовых сообщений: + - `TEXT_REPLY`; + - `TEXT_RATING`. + Они идут в одной общей ветке обсуждения и сортируются по времени создания. --- diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index fe70c227..77694b8c 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,5 +1,12 @@ # История изменений документации блокчейна +## 2026-08-09 19:40:00 +0400 +- Базовый коммит-ориентир: `3552e05`. +- Уточнено серверное чтение каналов и тредов для `TEXT_RATING`: + - `GetChannelMessages` и `GetMessageThread` теперь отдают отдельное поле `ratingsCount`; + - `GetMessageThread` включает `TEXT_RATING` в общее дерево потомков вместе с `TEXT_REPLY`; + - в `docs/API/06_Channels_Read_API.md` зафиксировано, что потомки треда возвращаются вперемешку по времени создания. + ## 2026-08-09 18:55:16 +0400 - Базовый коммит-ориентир: `43f54c9`. - Для первой итерации новых контентных типов обновлена карта `TEXT`-подтипов: diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index d179abce..1a4c63fd 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -24,6 +24,11 @@ import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js'; export const pageMeta = { id: 'channel-thread-view', title: 'Тред' }; +const MSG_SUBTYPE_TEXT_RATING = 30; +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 pendingReactionActions = new Set(); const pendingThreadScroll = new Map(); @@ -227,6 +232,21 @@ function resolveChannelHeadingFromNode(node) { return `Сообщение в канале ${channelName}`; } +function getChannelMessageTypeMeta(msgSubType) { + switch (Number(msgSubType || 0)) { + case MSG_SUBTYPE_TEXT_EXERCISE: + return { label: 'Упражнение' }; + case MSG_SUBTYPE_TEXT_SERVICE: + return { label: 'Процедура' }; + case MSG_SUBTYPE_TEXT_COURSE: + return { label: 'Курс' }; + case MSG_SUBTYPE_TEXT_ENTRYPOINT: + return { label: 'Оглавление' }; + default: + return null; + } +} + function extractChannelContextFromThreadPayload(payload) { const focusInfo = payload?.focus?.channelInfo; if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) { @@ -461,13 +481,22 @@ function renderDraftAttachments(container, attachments) { }); } -function openReplyModal({ onSubmit, navigate }) { +function openReplyModal({ onSubmit, navigate, mode = 'reply' }) { + const isRating = mode === 'rating'; + const title = isRating ? 'Оценка' : 'Ответ'; + const placeholder = isRating ? 'Текст оценки' : 'Текст ответа'; + const emptyError = isRating + ? 'Введите текст оценки или добавьте вложение.' + : 'Введите текст ответа или добавьте вложение.'; + const submitError = isRating + ? 'Не удалось отправить оценку.' + : 'Не удалось отправить ответ.'; const root = document.getElementById('modal-root'); root.innerHTML = `