SHA256
Compare commits
6
Commits
43f54c90d2
...
735bb55b78
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
735bb55b78 | ||
|
|
60f8a50a97 | ||
|
|
ffd01814b0 | ||
|
|
c511910b8f | ||
|
|
5c38f8f0d8 | ||
|
|
d512442325 |
@@ -36,6 +36,10 @@ public final class BodyRecordParser {
|
|||||||
case TextBody.KEY -> {
|
case TextBody.KEY -> {
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_EDIT_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_REPOST & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||||
yield new TextLineBody(subType, version, bodyBytes);
|
yield new TextLineBody(subType, version, bodyBytes);
|
||||||
@@ -46,12 +50,17 @@ public final class BodyRecordParser {
|
|||||||
yield new TextReplyBody(subType, version, bodyBytes);
|
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);
|
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
|
||||||
}
|
}
|
||||||
|
|
||||||
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
|
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
|
||||||
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
|
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
|
||||||
case UserParamBody.KEY -> new UserParamBody(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(
|
default -> throw new IllegalArgumentException(String.format(
|
||||||
"Unknown body type/version from header: type=%d ver=%d subType=%d",
|
"Unknown body type/version from header: type=%d ver=%d subType=%d",
|
||||||
|
|||||||
@@ -64,17 +64,29 @@ public final class MsgSubType {
|
|||||||
*/
|
*/
|
||||||
public static final short TEXT_EDIT_REPLY = 21;
|
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) + текст комментария.
|
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
||||||
*/
|
*/
|
||||||
public static final short TEXT_REPOST = 30;
|
public static final short TEXT_REPOST = 50;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CHANNEL_META — скрытый технический снимок профиля канала.
|
* CHANNEL_META — скрытый технический снимок профиля канала.
|
||||||
* Имеет hasLine, использует body как POST: line-поля + UTF-8 текст.
|
* Имеет 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) ===================== */
|
/* ===================== REACTION (msg_type=2) ===================== */
|
||||||
|
|
||||||
@@ -144,4 +156,15 @@ public final class MsgSubType {
|
|||||||
|
|
||||||
/** Параметр профиля key/value (обе строки). */
|
/** Параметр профиля key/value (обе строки). */
|
||||||
public static final short USER_PARAM_TEXT_TEXT = 1;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+165
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-10
@@ -16,12 +16,16 @@ import java.util.Objects;
|
|||||||
* subType:
|
* subType:
|
||||||
* - POST (10)
|
* - POST (10)
|
||||||
* - EDIT_POST (11)
|
* - EDIT_POST (11)
|
||||||
* - REPOST (30)
|
* - REPOST (50)
|
||||||
* - CHANNEL_META (70)
|
* - CHANNEL_META (90)
|
||||||
|
* - ENTRYPOINT (100)
|
||||||
|
* - EXERCISE (110)
|
||||||
|
* - SERVICE (120)
|
||||||
|
* - COURSE (130)
|
||||||
*
|
*
|
||||||
* Формат bodyBytes (BigEndian):
|
* Формат bodyBytes (BigEndian):
|
||||||
*
|
*
|
||||||
* POST / CHANNEL_META:
|
* POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
|
||||||
* [4] lineCode
|
* [4] lineCode
|
||||||
* [4] prevLineNumber
|
* [4] prevLineNumber
|
||||||
* [32] prevLineHash32
|
* [32] prevLineHash32
|
||||||
@@ -91,7 +95,11 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 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);
|
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)
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 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");
|
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
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");
|
throw new IllegalArgumentException("message is blank");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +223,11 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 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);
|
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
@@ -238,8 +254,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
} else {
|
} else {
|
||||||
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||||
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
|
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");
|
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)
|
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||||
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
|
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)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
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");
|
throw new IllegalArgumentException("Text payload is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
int cap;
|
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;
|
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
// EDIT_POST
|
// EDIT_POST
|
||||||
@@ -318,6 +341,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
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) {
|
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) {
|
if (len == 0) {
|
||||||
|
|||||||
+157
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,8 +32,13 @@ public final class DatabaseInitializer {
|
|||||||
public static final short TEXT_EDIT_POST = 11;
|
public static final short TEXT_EDIT_POST = 11;
|
||||||
public static final short TEXT_REPLY = 20;
|
public static final short TEXT_REPLY = 20;
|
||||||
public static final short TEXT_EDIT_REPLY = 21;
|
public static final short TEXT_EDIT_REPLY = 21;
|
||||||
public static final short TEXT_REPOST = 30;
|
public static final short TEXT_RATING = 30;
|
||||||
public static final short TEXT_CHANNEL_META = 70;
|
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_LIKE = 1;
|
||||||
public static final short REACTION_UNLIKE = 2;
|
public static final short REACTION_UNLIKE = 2;
|
||||||
|
|||||||
@@ -30,11 +30,23 @@ public final class MsgSubType {
|
|||||||
/** EDIT_REPLY — редактирование исходного ответа. */
|
/** EDIT_REPLY — редактирование исходного ответа. */
|
||||||
public static final short TEXT_EDIT_REPLY = 21;
|
public static final short TEXT_EDIT_REPLY = 21;
|
||||||
|
|
||||||
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */
|
/** RATING — target-based отзыв на конкретный блок. */
|
||||||
public static final short TEXT_REPOST = 30;
|
public static final short TEXT_RATING = 30;
|
||||||
|
|
||||||
|
/** REPOST — отложенная будущая заготовка репоста сообщения в линии канала. */
|
||||||
|
public static final short TEXT_REPOST = 50;
|
||||||
|
|
||||||
/** CHANNEL_META — скрытый технический снимок профиля канала. */
|
/** 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) ===================== */
|
/* ===================== REACTION (msg_type=2) ===================== */
|
||||||
|
|
||||||
@@ -123,6 +135,17 @@ public final class MsgSubType {
|
|||||||
/** Параметр профиля key/value (обе строки). */
|
/** Параметр профиля key/value (обе строки). */
|
||||||
public static final short USER_PARAM_TEXT_TEXT = 1;
|
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 — лучше добавить новые значения,
|
// Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения,
|
||||||
// не трогая уже занятые коды.
|
// не трогая уже занятые коды.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import java.util.List;
|
|||||||
* Возвращает по каждой активной подписке (FOLLOW) + "сам на себя":
|
* Возвращает по каждой активной подписке (FOLLOW) + "сам на себя":
|
||||||
* - login цели (channelLogin)
|
* - login цели (channelLogin)
|
||||||
* - blockchainName цели (channelBchName)
|
* - blockchainName цели (channelBchName)
|
||||||
* - count публикаций (TEXT_POST)
|
* - count публикаций (видимые line-based TEXT-сообщения канала)
|
||||||
* - last publication: bytes оригинального блока (для timestamp)
|
* - last publication: bytes оригинального блока (для timestamp)
|
||||||
* - last publication: bytes актуального блока (edit или orig) — для текста превью
|
* - last publication: bytes актуального блока (edit или orig) — для текста превью
|
||||||
*
|
*
|
||||||
@@ -92,7 +92,7 @@ public final class SubscriptionsDAO {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой:
|
* Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой:
|
||||||
* - count публикаций (TEXT_POST)
|
* - count публикаций (видимые line-based TEXT-сообщения канала)
|
||||||
* - последнюю публикацию (orig bytes) + её edit (если есть)
|
* - последнюю публикацию (orig bytes) + её edit (если есть)
|
||||||
*
|
*
|
||||||
* Поведение при 0 публикаций:
|
* Поведение при 0 публикаций:
|
||||||
@@ -131,7 +131,7 @@ public final class SubscriptionsDAO {
|
|||||||
ON s.channel_login = b.login
|
ON s.channel_login = b.login
|
||||||
AND s.channel_bch_name = b.bch_name
|
AND s.channel_bch_name = b.bch_name
|
||||||
WHERE b.msg_type = ?
|
WHERE b.msg_type = ?
|
||||||
AND b.msg_sub_type IN (?, ?)
|
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
|
||||||
GROUP BY b.login, b.bch_name
|
GROUP BY b.login, b.bch_name
|
||||||
),
|
),
|
||||||
last_pub AS (
|
last_pub AS (
|
||||||
@@ -144,7 +144,7 @@ public final class SubscriptionsDAO {
|
|||||||
ON s.channel_login = b.login
|
ON s.channel_login = b.login
|
||||||
AND s.channel_bch_name = b.bch_name
|
AND s.channel_bch_name = b.bch_name
|
||||||
WHERE b.msg_type = ?
|
WHERE b.msg_type = ?
|
||||||
AND b.msg_sub_type IN (?, ?)
|
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
|
||||||
GROUP BY b.login, b.bch_name
|
GROUP BY b.login, b.bch_name
|
||||||
),
|
),
|
||||||
last_pub_block AS (
|
last_pub_block AS (
|
||||||
@@ -209,11 +209,19 @@ public final class SubscriptionsDAO {
|
|||||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||||
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
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
|
// last_pub
|
||||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||||
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
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()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
|
|||||||
+67
@@ -6,6 +6,7 @@ import blockchain.MsgSubType;
|
|||||||
import blockchain.body.BodyHasLine;
|
import blockchain.body.BodyHasLine;
|
||||||
import blockchain.body.BodyHasTarget;
|
import blockchain.body.BodyHasTarget;
|
||||||
import blockchain.body.CreateChannelBody;
|
import blockchain.body.CreateChannelBody;
|
||||||
|
import blockchain.body.StatusActionBody;
|
||||||
import blockchain.body.TextLineBody;
|
import blockchain.body.TextLineBody;
|
||||||
import blockchain.body.UserParamBody;
|
import blockchain.body.UserParamBody;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -167,6 +168,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
||||||
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
||||||
case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
|
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 "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||||
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||||
default -> "Ошибка: " + code;
|
default -> "Ошибка: " + code;
|
||||||
@@ -388,6 +391,34 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
|
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
|
// 4.2) запрет дырок: blockNumber строго last+1
|
||||||
int expectedBlockNumber = serverLastNum + 1;
|
int expectedBlockNumber = serverLastNum + 1;
|
||||||
if (block.blockNumber != expectedBlockNumber) {
|
if (block.blockNumber != expectedBlockNumber) {
|
||||||
@@ -605,6 +636,42 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
String slug;
|
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 {
|
private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) throws Exception {
|
||||||
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||||
PreparedStatement ps = c.prepareStatement("""
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
|||||||
+40
-7
@@ -22,6 +22,7 @@ final class ChannelsReadSupport {
|
|||||||
static final int MSG_TYPE_TEXT = 1;
|
static final int MSG_TYPE_TEXT = 1;
|
||||||
static final int MSG_TYPE_REACTION = 2;
|
static final int MSG_TYPE_REACTION = 2;
|
||||||
static final int MSG_TYPE_TECH = 0;
|
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 STORIES_CHANNEL_NAME = "stories";
|
||||||
static final String COMMAND_ADD = "add";
|
static final String COMMAND_ADD = "add";
|
||||||
static final String COMMAND_REMOVE = "remove";
|
static final String COMMAND_REMOVE = "remove";
|
||||||
@@ -126,13 +127,17 @@ final class ChannelsReadSupport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static int countPosts(Connection c, String ownerBch, int lineCode) throws SQLException {
|
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)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, ownerBch);
|
ps.setString(1, ownerBch);
|
||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
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()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
return rs.next() ? rs.getInt("cnt") : 0;
|
return rs.next() ? rs.getInt("cnt") : 0;
|
||||||
}
|
}
|
||||||
@@ -143,7 +148,7 @@ final class ChannelsReadSupport {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||||
FROM blocks
|
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
|
ORDER BY block_number DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""";
|
||||||
@@ -152,7 +157,11 @@ final class ChannelsReadSupport {
|
|||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
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()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (!rs.next()) return null;
|
if (!rs.next()) return null;
|
||||||
PostBlock pb = new PostBlock();
|
PostBlock pb = new PostBlock();
|
||||||
@@ -204,6 +213,10 @@ final class ChannelsReadSupport {
|
|||||||
ti.text = tlb.message;
|
ti.text = tlb.message;
|
||||||
} else if (e.body instanceof TextReplyBody trb) {
|
} else if (e.body instanceof TextReplyBody trb) {
|
||||||
ti.text = trb.message;
|
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) {
|
} else if (e.body instanceof TextBody tb) {
|
||||||
ti.text = tb.message;
|
ti.text = tb.message;
|
||||||
}
|
}
|
||||||
@@ -218,7 +231,7 @@ final class ChannelsReadSupport {
|
|||||||
String sql = """
|
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
|
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
|
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 BY block_number
|
||||||
""" + order + " LIMIT ?";
|
""" + order + " LIMIT ?";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
@@ -226,8 +239,12 @@ final class ChannelsReadSupport {
|
|||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
||||||
ps.setInt(5, lineCode);
|
ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT);
|
||||||
ps.setInt(6, limit);
|
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()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
List<PostBlock> out = new ArrayList<>();
|
List<PostBlock> out = new ArrayList<>();
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
@@ -589,6 +606,22 @@ final class ChannelsReadSupport {
|
|||||||
return sb.toString();
|
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 {
|
static final class PostBlock {
|
||||||
String login;
|
String login;
|
||||||
String bchName;
|
String bchName;
|
||||||
|
|||||||
+1
-1
@@ -143,7 +143,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
v1.setCreatedAtMs(postText.createdAtMs);
|
v1.setCreatedAtMs(postText.createdAtMs);
|
||||||
versionsOut.add(v1);
|
versionsOut.add(v1);
|
||||||
|
|
||||||
if (post.msgSubType == MsgSubType.TEXT_POST) {
|
if (ChannelsReadSupport.supportsEditPostVersions(post.msgSubType)) {
|
||||||
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash);
|
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash);
|
||||||
for (ChannelsReadSupport.PostBlock edit : edits) {
|
for (ChannelsReadSupport.PostBlock edit : edits) {
|
||||||
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
||||||
|
|||||||
+1
-1
@@ -208,7 +208,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
first.setCreatedAtMs(base.createdAtMs);
|
first.setCreatedAtMs(base.createdAtMs);
|
||||||
versions.add(first);
|
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;
|
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)) {
|
for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) {
|
||||||
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
||||||
|
|||||||
+7
-2
@@ -58,7 +58,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
|
|||||||
AND block_number = ?
|
AND block_number = ?
|
||||||
AND block_hash = ?
|
AND block_hash = ?
|
||||||
AND msg_type = ?
|
AND msg_type = ?
|
||||||
AND msg_sub_type = ?
|
AND msg_sub_type IN (?, ?, ?, ?, ?, ?)
|
||||||
%s
|
%s
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""".formatted(strictChannelMatch ? "AND line_code = ?" : "");
|
""".formatted(strictChannelMatch ? "AND line_code = ?" : "");
|
||||||
@@ -98,8 +98,13 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
|
|||||||
existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex));
|
existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex));
|
||||||
existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT);
|
existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT);
|
||||||
existsPs.setInt(5, MsgSubType.TEXT_POST);
|
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) {
|
if (strictChannelMatch) {
|
||||||
existsPs.setInt(6, expectedRoot);
|
existsPs.setInt(11, expectedRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean exists;
|
boolean exists;
|
||||||
|
|||||||
+22
-3
@@ -26,6 +26,7 @@ import java.util.Set;
|
|||||||
public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private static final int TYPE_INVITE = 100;
|
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;
|
private static final long PUSH_CALL_TTL_MS = 10_000L;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -38,8 +39,11 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
|||||||
String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim();
|
String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim();
|
||||||
String callId = req.getCallId() == null ? "" : req.getCallId().trim();
|
String callId = req.getCallId() == null ? "" : req.getCallId().trim();
|
||||||
int type = req.getType() == null ? TYPE_INVITE : req.getType();
|
int type = req.getType() == null ? TYPE_INVITE : req.getType();
|
||||||
if (toRequest.isBlank() || callId.isBlank() || type != TYPE_INVITE) {
|
String data = req.getData() == null ? "" : req.getData().trim();
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100 обязательны");
|
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);
|
CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest);
|
||||||
@@ -69,13 +73,28 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
|||||||
payload.put("fromSessionId", ctx.getSessionId());
|
payload.put("fromSessionId", ctx.getSessionId());
|
||||||
payload.put("toLogin", to);
|
payload.put("toLogin", to);
|
||||||
payload.put("callId", callId);
|
payload.put("callId", callId);
|
||||||
payload.put("type", TYPE_INVITE);
|
payload.put("type", type);
|
||||||
payload.put("timeMs", timeMs);
|
payload.put("timeMs", timeMs);
|
||||||
|
if (!data.isBlank()) {
|
||||||
|
payload.put("data", data);
|
||||||
|
}
|
||||||
|
|
||||||
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingCallInvite", eventId, payload);
|
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingCallInvite", eventId, payload);
|
||||||
if (sent) wsDelivered++;
|
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) {
|
for (ActiveSessionEntry session : allTargetSessions) {
|
||||||
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim();
|
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim();
|
||||||
if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) {
|
if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) {
|
||||||
|
|||||||
+204
-3
@@ -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.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
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.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.push.WsEventSender;
|
||||||
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.ActiveSessionsDAO;
|
||||||
import shine.db.dao.CurrentUsersDAO;
|
import shine.db.dao.CurrentUsersDAO;
|
||||||
import shine.db.entities.ActiveSessionEntry;
|
import shine.db.entities.ActiveSessionEntry;
|
||||||
import shine.db.entities.CurrentUserEntry;
|
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_SINGLE = "single_session";
|
||||||
private static final String TARGET_MODE_ALL = "all_sessions";
|
private static final String TARGET_MODE_ALL = "all_sessions";
|
||||||
private static final long ALLOWED_SKEW_MS = 30_000L;
|
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
|
@Override
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
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");
|
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()) {
|
if (!clientSignatureB64.isBlank()) {
|
||||||
String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64);
|
String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64);
|
||||||
if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) {
|
if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) {
|
||||||
@@ -107,7 +120,13 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<ConnectionContext> targets = resolveTargets(targetMode, toLogin, targetSessionId);
|
List<ConnectionContext> 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 code = TARGET_MODE_SINGLE.equals(targetMode) ? "SESSION_NOT_FOUND" : "NO_TARGET_SESSIONS";
|
||||||
String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала";
|
String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала";
|
||||||
return NetExceptionResponseFactory.error(req, 404, code, msg);
|
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", "Не удалось доставить сигнал ни в одну целевую сессию");
|
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();
|
Net_SendSignal_Response resp = new Net_SendSignal_Response();
|
||||||
resp.setOp(req.getOp());
|
resp.setOp(req.getOp());
|
||||||
resp.setRequestId(req.getRequestId());
|
resp.setRequestId(req.getRequestId());
|
||||||
resp.setStatus(WireCodes.Status.OK);
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
resp.setDeliveredCount(deliveredSessionIds.size());
|
resp.setDeliveredCount(deliveredSessionIds.size() + webPushDelivered);
|
||||||
resp.setDeliveredSessionIds(deliveredSessionIds);
|
resp.setDeliveredSessionIds(deliveredSessionIds);
|
||||||
|
resp.setDeliveredWsSessions(deliveredSessionIds.size());
|
||||||
|
resp.setDeliveredFcmSessions(webPushDelivered);
|
||||||
|
resp.setDeliveredWebPushSessions(webPushDelivered);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +255,19 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
|||||||
+ dataSha256B64;
|
+ 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 {
|
private static boolean verifySignature(String publicKeyValue, String preimage, String signatureB64, String fieldName) throws Exception {
|
||||||
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName);
|
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName);
|
||||||
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
|
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
|
||||||
@@ -214,7 +292,130 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
|||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int sendIncomingCallPushToOfflineSessions(
|
||||||
|
String toLogin,
|
||||||
|
String fromLogin,
|
||||||
|
String fromSessionId,
|
||||||
|
String callId,
|
||||||
|
List<String> deliveredOnlineSessionIds
|
||||||
|
) throws Exception {
|
||||||
|
List<ActiveSessionEntry> 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<String> onlineSessionIds = new java.util.HashSet<>();
|
||||||
|
Set<ConnectionContext> 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<ActiveSessionEntry> 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) {
|
private static String safe(String value) {
|
||||||
return value == null ? "" : value.trim();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -6,6 +6,7 @@ public class Net_CallInviteBroadcast_Request extends Net_Request {
|
|||||||
private String toLogin;
|
private String toLogin;
|
||||||
private String callId;
|
private String callId;
|
||||||
private Integer type;
|
private Integer type;
|
||||||
|
private String data;
|
||||||
|
|
||||||
public String getToLogin() { return toLogin; }
|
public String getToLogin() { return toLogin; }
|
||||||
public void setToLogin(String toLogin) { this.toLogin = 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 Integer getType() { return type; }
|
||||||
public void setType(Integer type) { this.type = type; }
|
public void setType(Integer type) { this.type = type; }
|
||||||
|
|
||||||
|
public String getData() { return data; }
|
||||||
|
public void setData(String data) { this.data = data; }
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -8,6 +8,9 @@ import java.util.List;
|
|||||||
public class Net_SendSignal_Response extends Net_Response {
|
public class Net_SendSignal_Response extends Net_Response {
|
||||||
private int deliveredCount;
|
private int deliveredCount;
|
||||||
private List<String> deliveredSessionIds = new ArrayList<>();
|
private List<String> deliveredSessionIds = new ArrayList<>();
|
||||||
|
private int deliveredWsSessions;
|
||||||
|
private int deliveredFcmSessions;
|
||||||
|
private int deliveredWebPushSessions;
|
||||||
|
|
||||||
public int getDeliveredCount() {
|
public int getDeliveredCount() {
|
||||||
return deliveredCount;
|
return deliveredCount;
|
||||||
@@ -24,4 +27,28 @@ public class Net_SendSignal_Response extends Net_Response {
|
|||||||
public void setDeliveredSessionIds(List<String> deliveredSessionIds) {
|
public void setDeliveredSessionIds(List<String> deliveredSessionIds) {
|
||||||
this.deliveredSessionIds = 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
||||||
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
|
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
|
||||||
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
- `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`.
|
- `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - завершить зачистку хвостов после перевода серверной БД с `SQLite` на `PostgreSQL`.
|
||||||
|
|
||||||
### dao_запуск
|
### dao_запуск
|
||||||
|
|||||||
@@ -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`.
|
||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.5.4
|
client.version=1.5.8
|
||||||
server.version=1.4.5
|
server.version=1.4.7
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
backup.schema.version=1
|
backup.schema.version=2
|
||||||
backup.full.version=2
|
backup.full.version=3
|
||||||
last.full.backup.date=2026-07-10
|
last.full.backup.date=2026-08-08
|
||||||
|
|||||||
@@ -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
|
Filesystem Size Used Avail Use% Mounted on
|
||||||
tmpfs 392M 1.2M 391M 1% /run
|
tmpfs 197M 1.2M 196M 1% /run
|
||||||
/dev/vda2 32G 14G 17G 46% /
|
/dev/sda1 40G 6.7G 31G 18% /
|
||||||
tmpfs 2.0G 0 2.0G 0% /dev/shm
|
tmpfs 982M 0 982M 0% /dev/shm
|
||||||
tmpfs 5.0M 0 5.0M 0% /run/lock
|
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
|
NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS
|
||||||
sr0 iso9660 Joliet Extension cidata 2026-06-02-12-22-36-00
|
sda
|
||||||
sr1
|
└─sda1 ext4 1.0 42e8f44f-2dd9-4fc6-8485-6581d6528df5 30.6G 17% /
|
||||||
vda
|
sr0
|
||||||
├─vda1
|
|
||||||
└─vda2 ext4 1.0 c422dce2-e6a3-4ce4-a9c1-17ab14e9193a 16.1G 44% /
|
|
||||||
|
|||||||
@@ -1,13 +1,48 @@
|
|||||||
UNIT FILE STATE PRESET
|
UNIT FILE STATE VENDOR PRESET
|
||||||
agent-memory.service enabled enabled
|
apparmor.service enabled enabled
|
||||||
caddy.service enabled enabled
|
blk-availability.service enabled enabled
|
||||||
containerd.service enabled enabled
|
caddy.service enabled enabled
|
||||||
coturn.service enabled enabled
|
console-setup.service enabled enabled
|
||||||
docker.service enabled enabled
|
containerd.service enabled enabled
|
||||||
elaira-agent.service enabled enabled
|
coturn.service enabled enabled
|
||||||
hermes-dashboard.service enabled enabled
|
cron.service enabled enabled
|
||||||
hermes-gateway.service enabled enabled
|
dmesg.service enabled enabled
|
||||||
shine-server.service enabled enabled
|
docker.service enabled enabled
|
||||||
ubuntu-fan.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.
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
UNIT LOAD ACTIVE SUB DESCRIPTION
|
UNIT LOAD ACTIVE SUB DESCRIPTION
|
||||||
agent-memory.service loaded active running agent-memory service
|
|
||||||
caddy.service loaded active running Caddy
|
caddy.service loaded active running Caddy
|
||||||
containerd.service loaded active running containerd container runtime
|
containerd.service loaded active running containerd container runtime
|
||||||
coturn.service loaded active running coTURN STUN/TURN Server
|
coturn.service loaded active running coTURN STUN/TURN Server
|
||||||
cron.service loaded active running Regular background program processing daemon
|
cron.service loaded active running Regular background program processing daemon
|
||||||
dbus.service loaded active running D-Bus System Message Bus
|
dbus.service loaded active running D-Bus System Message Bus
|
||||||
docker.service loaded active running Docker Application Container Engine
|
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
|
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
|
ModemManager.service loaded active running Modem Manager
|
||||||
multipathd.service loaded active running Device-Mapper Multipath Device Controller
|
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
|
polkit.service loaded active running Authorization Manager
|
||||||
qemu-guest-agent.service loaded active running QEMU Guest Agent
|
qemu-guest-agent.service loaded active running QEMU Guest Agent
|
||||||
rsyslog.service loaded active running System Logging Service
|
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
|
ssh.service loaded active running OpenBSD Secure Shell server
|
||||||
systemd-hostnamed.service loaded active running Hostname Service
|
systemd-hostnamed.service loaded active running Hostname Service
|
||||||
systemd-journald.service loaded active running Journal Service
|
systemd-journald.service loaded active running Journal Service
|
||||||
@@ -26,10 +26,9 @@
|
|||||||
udisks2.service loaded active running Disk Manager
|
udisks2.service loaded active running Disk Manager
|
||||||
unattended-upgrades.service loaded active running Unattended Upgrades Shutdown
|
unattended-upgrades.service loaded active running Unattended Upgrades Shutdown
|
||||||
upower.service loaded active running Daemon for power management
|
upower.service loaded active running Daemon for power management
|
||||||
user@1002.service loaded active running User Manager for UID 1002
|
user@1000.service loaded active running User Manager for UID 1000
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
|
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.
|
28 loaded units listed.
|
||||||
|
|||||||
@@ -1,32 +1,12 @@
|
|||||||
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
|
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 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=839,fd=3))
|
||||||
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=37))
|
LISTEN 0 1024 0.0.0.0:3478 0.0.0.0:* users:(("turnserver",pid=522,fd=34))
|
||||||
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=15))
|
LISTEN 0 1024 0.0.0.0:3478 0.0.0.0:* users:(("turnserver",pid=522,fd=33))
|
||||||
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=34))
|
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=804,fd=4))
|
||||||
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=13))
|
LISTEN 0 4096 127.0.0.1:42113 0.0.0.0:* users:(("containerd",pid=536,fd=16))
|
||||||
LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=58))
|
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=466,fd=14))
|
||||||
LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=21))
|
LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("docker-proxy",pid=1240,fd=7))
|
||||||
LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=60))
|
LISTEN 0 4096 *:80 *:* users:(("caddy",pid=804,fd=9))
|
||||||
LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=23))
|
LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=839,fd=4))
|
||||||
LISTEN 0 2048 0.0.0.0:8000 0.0.0.0:* users:(("uvicorn",pid=2056697,fd=6))
|
LISTEN 0 4096 *:443 *:* users:(("caddy",pid=804,fd=7))
|
||||||
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=8096,fd=14))
|
LISTEN 0 50 *:7070 *:* users:(("java",pid=1289,fd=19))
|
||||||
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))
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
NAMES IMAGE STATUS PORTS
|
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
|
shine-postgres postgres:18 Up 19 hours 127.0.0.1:5432->5432/tcp
|
||||||
|
|||||||
@@ -1,8 +1 @@
|
|||||||
4.0K /home/player/hosts.codex.test
|
85M /home/player/SHiNE
|
||||||
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
|
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
4.0K /var/local
|
4.0K /var/local
|
||||||
4.0K /var/mail
|
4.0K /var/mail
|
||||||
4.0K /var/opt
|
4.0K /var/opt
|
||||||
4.0K /var/snap
|
|
||||||
16K /var/spool
|
16K /var/spool
|
||||||
88K /var/tmp
|
72K /var/tmp
|
||||||
3.2M /var/backups
|
1.9M /var/backups
|
||||||
143M /var/cache
|
319M /var/cache
|
||||||
1.2G /var/lib
|
1.1G /var/log
|
||||||
1.5G /var/log
|
1.5G /var/lib
|
||||||
2.8G /var
|
2.8G /var
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026-07-10T08:27:36Z
|
2026-08-08T20:05:20Z
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
openmindsoft.io, www.openmindsoft.io {
|
{
|
||||||
encode zstd gzip
|
auto_https disable_redirects
|
||||||
root * /home/player/sites/OpenMindSoft.io
|
|
||||||
try_files {path} /index.html
|
|
||||||
file_server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
shineup.me {
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
missing: /etc/systemd/system/agent-memory.service
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=SHiNE Server
|
Description=SHiNE Server (shineup.me)
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@@ -7,7 +7,7 @@ Type=simple
|
|||||||
User=player
|
User=player
|
||||||
Group=player
|
Group=player
|
||||||
WorkingDirectory=/home/player/SHiNE/shine-server
|
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
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
|
|||||||
@@ -1,709 +1,17 @@
|
|||||||
# Coturn TURN SERVER configuration file
|
listening-port=3478
|
||||||
#
|
fingerprint
|
||||||
# Boolean values note: where boolean value is supposed to be used,
|
lt-cred-mech
|
||||||
# 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 <ip>" 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 <public-ip/private-ip>", 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.
|
|
||||||
#
|
|
||||||
use-auth-secret
|
use-auth-secret
|
||||||
|
static-auth-secret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106
|
||||||
# 'Static' authentication secret value (a string) for TURN REST API only.
|
realm=turn1.shineup.me
|
||||||
# If not set, then the turn server
|
total-quota=200
|
||||||
# will try to use the 'dynamic' value in turn_secret table
|
stale-nonce=600
|
||||||
# in user database (if present). The database-stored value can be changed on-the-fly
|
no-multicast-peers
|
||||||
# by a separate program, so this is why that other mode is 'dynamic'.
|
no-loopback-peers
|
||||||
#
|
no-cli
|
||||||
#static-auth-secret=north
|
simple-log
|
||||||
|
external-ip=178.208.64.62
|
||||||
# Server name used for
|
listening-ip=0.0.0.0
|
||||||
# the oAuth authentication purposes.
|
relay-ip=178.208.64.62
|
||||||
# The default value is the realm name.
|
min-port=49000
|
||||||
#
|
max-port=53999
|
||||||
#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=<host> dbname=<database-name> user=<database-user> password=<database-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=<host> dbname=<database-name> user=<database-user> password=<database-user-password> port=<port> connect_timeout=<seconds> read_timeout=<seconds>"
|
|
||||||
|
|
||||||
# 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=<ip-address> dbname=<database-number> password=<database-user-password> port=<port> connect_timeout=<seconds>"
|
|
||||||
|
|
||||||
# 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=<ip-address> dbname=<database-number> password=<database-user-password> port=<port> connect_timeout=<seconds>"
|
|
||||||
|
|
||||||
# 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=<DH-PEM-file-name>
|
|
||||||
|
|
||||||
# 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
|
|
||||||
# <ip>[:<port>]. 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
|
|
||||||
# <ip>:<port>. 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=<user-name>
|
|
||||||
|
|
||||||
# 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=<group-name>
|
|
||||||
|
|
||||||
# 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=<secret-on-server-only>
|
|
||||||
|
|||||||
@@ -5,9 +5,21 @@ REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
|
|||||||
SCHEME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
SCHEME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
CAP_DIR="${SCHEME_DIR}/captures"
|
CAP_DIR="${SCHEME_DIR}/captures"
|
||||||
CFG_DIR="${SCHEME_DIR}/configs"
|
CFG_DIR="${SCHEME_DIR}/configs"
|
||||||
|
RSYNC_REMOTE_SUDO=(--rsync-path="sudo -n rsync")
|
||||||
|
|
||||||
mkdir -p "${CAP_DIR}" "${CFG_DIR}/systemd"
|
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] Обновляю инвентарь"
|
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}" '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"
|
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"
|
ssh "${REMOTE_HOST}" 'sudo -n du -xhd1 /var 2>/dev/null | sort -h' > "${CAP_DIR}/07_var_sizes.txt"
|
||||||
|
|
||||||
echo "[2/3] Обновляю ключевые конфиги"
|
echo "[2/3] Обновляю ключевые конфиги"
|
||||||
rsync -a "${REMOTE_HOST}:/home/player/SHiNE/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
|
if ssh "${REMOTE_HOST}" 'test -f /etc/caddy/Caddyfile'; then
|
||||||
rsync -a "${REMOTE_HOST}:/etc/turnserver.conf" "${CFG_DIR}/turnserver.conf"
|
rsync -a "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
|
||||||
rsync -a "${REMOTE_HOST}:/etc/systemd/system/shine-server.service" "${CFG_DIR}/systemd/"
|
else
|
||||||
rsync -a "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${CFG_DIR}/systemd/"
|
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] Метка обновления"
|
echo "[3/3] Метка обновления"
|
||||||
date -u +%Y-%m-%dT%H:%M:%SZ > "${CAP_DIR}/UPDATED_AT_UTC.txt"
|
date -u +%Y-%m-%dT%H:%M:%SZ > "${CAP_DIR}/UPDATED_AT_UTC.txt"
|
||||||
|
|||||||
@@ -88,6 +88,8 @@
|
|||||||
- `limit_exceeded`
|
- `limit_exceeded`
|
||||||
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
|
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
|
||||||
- `repost_disabled` — репосты временно отключены до будущей реализации
|
- `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`
|
- `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META`
|
||||||
- `internal_error`
|
- `internal_error`
|
||||||
|
|
||||||
@@ -104,8 +106,13 @@
|
|||||||
- `TEXT_EDIT_POST (11)`
|
- `TEXT_EDIT_POST (11)`
|
||||||
- `TEXT_REPLY (20)`
|
- `TEXT_REPLY (20)`
|
||||||
- `TEXT_EDIT_REPLY (21)`
|
- `TEXT_EDIT_REPLY (21)`
|
||||||
- `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
|
- `TEXT_RATING (30)` — target-based отзыв на конкретный блок
|
||||||
- `TEXT_CHANNEL_META (70)` — скрытый технический снимок профиля канала
|
- `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)**
|
3. **REACTION (type=2)**
|
||||||
- `REACTION_LIKE (1)`
|
- `REACTION_LIKE (1)`
|
||||||
@@ -135,6 +142,16 @@
|
|||||||
5. **USER_PARAM (type=4)**
|
5. **USER_PARAM (type=4)**
|
||||||
- `USER_PARAM_TEXT_TEXT (1)`
|
- `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-форматы для каналов и вложений
|
## 6. Практические payload-форматы для каналов и вложений
|
||||||
|
|
||||||
`AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата.
|
`AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата.
|
||||||
@@ -173,7 +190,7 @@
|
|||||||
|
|
||||||
### Изменение профиля канала
|
### Изменение профиля канала
|
||||||
|
|
||||||
Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=70)`.
|
Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=90)`.
|
||||||
|
|
||||||
Текстовое содержимое body использует тот же формат полного снимка профиля:
|
Текстовое содержимое body использует тот же формат полного снимка профиля:
|
||||||
|
|
||||||
|
|||||||
@@ -297,6 +297,7 @@
|
|||||||
Первый целевой сценарий:
|
Первый целевой сценарий:
|
||||||
|
|
||||||
- `remote AddBlock via homeserver session`
|
- `remote AddBlock via homeserver session`
|
||||||
|
- межсессионная доставка звонковых `call_*` сигналов между устройствами пользователя
|
||||||
|
|
||||||
То есть телефон без локального `blockchain.key` может:
|
То есть телефон без локального `blockchain.key` может:
|
||||||
|
|
||||||
@@ -336,6 +337,12 @@
|
|||||||
- запрос должен быть подписан и `session key`, и `client key`;
|
- запрос должен быть подписан и `session key`, и `client key`;
|
||||||
- в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой.
|
- в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой.
|
||||||
|
|
||||||
|
Для звонковых сигналов `signalType = call_*` правило строже:
|
||||||
|
|
||||||
|
- `clientSignatureB64` обязателен;
|
||||||
|
- сервер отклоняет такой `SendSignal`, если `client key` подпись не передана;
|
||||||
|
- это правило действует и для `single_session`, и для `all_sessions`.
|
||||||
|
|
||||||
### Запрос в одну сессию
|
### Запрос в одну сессию
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -366,11 +373,20 @@
|
|||||||
"ok": true,
|
"ok": true,
|
||||||
"payload": {
|
"payload": {
|
||||||
"deliveredCount": 1,
|
"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
|
```json
|
||||||
@@ -428,6 +444,7 @@
|
|||||||
- `404 / USER_NOT_FOUND` — логин адресата не найден.
|
- `404 / USER_NOT_FOUND` — логин адресата не найден.
|
||||||
- `400 / BAD_DATA` — сервер не смог обработать `data`.
|
- `400 / BAD_DATA` — сервер не смог обработать `data`.
|
||||||
- `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`.
|
- `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`.
|
||||||
|
- `400 / CLIENT_SIGNATURE_REQUIRED` — для `call_*` сигнала не передана обязательная подпись `client key`.
|
||||||
- `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`.
|
- `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`.
|
||||||
- `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн.
|
- `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн.
|
||||||
- `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий.
|
- `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий.
|
||||||
|
|||||||
@@ -11,14 +11,16 @@
|
|||||||
- `12_REACTION_Blocks.md` — реакции (`type=2`).
|
- `12_REACTION_Blocks.md` — реакции (`type=2`).
|
||||||
- `13_CONNECTION_Blocks.md` — связи/подписки (`type=3`).
|
- `13_CONNECTION_Blocks.md` — связи/подписки (`type=3`).
|
||||||
- `14_USER_PARAM_Blocks.md` — пользовательские параметры (`type=4`).
|
- `14_USER_PARAM_Blocks.md` — пользовательские параметры (`type=4`).
|
||||||
|
- `15_STATUS_ACTION_Blocks.md` — статусные действия (`type=5`).
|
||||||
|
|
||||||
## Быстрая карта типов
|
## Быстрая карта типов
|
||||||
|
|
||||||
- `type=0` — TECH: HEADER, CREATE_CHANNEL.
|
- `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=2` — REACTION: LIKE/UNLIKE.
|
||||||
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
|
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
|
||||||
- `type=4` — USER_PARAM: key/value-параметры пользователя.
|
- `type=4` — USER_PARAM: key/value-параметры пользователя.
|
||||||
|
- `type=5` — STATUS_ACTION: DONE_ONCE/INTERESTED/STARTED/IN_STUDY/COMPLETED/ABANDONED/LEARNED/CONFIRMED.
|
||||||
|
|
||||||
## Примечание
|
## Примечание
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок:
|
Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок:
|
||||||
|
|
||||||
- `msg_type=1`
|
- `msg_type=1`
|
||||||
- `subType=70`
|
- `subType=90`
|
||||||
- `TEXT_CHANNEL_META`
|
- `TEXT_CHANNEL_META`
|
||||||
|
|
||||||
Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
|
Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# TEXT блоки (`type=1`, `version=1`)
|
# TEXT блоки (`type=1`, `version=1`)
|
||||||
|
|
||||||
TEXT-тип хранит сообщения и редактирования.
|
TEXT-тип хранит сообщения, материалы и редактирования.
|
||||||
|
|
||||||
## Подтипы
|
## Подтипы
|
||||||
|
|
||||||
@@ -21,24 +21,52 @@ TEXT-тип хранит сообщения и редактирования.
|
|||||||
- target на исходный REPLY + новый текст.
|
- target на исходный REPLY + новый текст.
|
||||||
- допускается пустой `text` для логического удаления сообщения (без физического удаления блока).
|
- допускается пустой `text` для логического удаления сообщения (без физического удаления блока).
|
||||||
|
|
||||||
5. `subType=30` — `TEXT_REPOST`
|
5. `subType=30` — `TEXT_RATING`
|
||||||
|
- target-based отзыв на конкретный блок;
|
||||||
|
- содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
|
||||||
|
- не является сообщением линии канала.
|
||||||
|
|
||||||
|
6. `subType=50` — `TEXT_REPOST`
|
||||||
- репост сообщения в линию канала;
|
- репост сообщения в линию канала;
|
||||||
- содержит line-поля + target на оригинальное сообщение + текст комментария;
|
- содержит line-поля + target на оригинальное сообщение + текст комментария;
|
||||||
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
|
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
|
||||||
- временно отключён для записи через `AddBlock` до будущей реализации репостов.
|
- временно отключён для записи через `AddBlock` до будущей реализации репостов.
|
||||||
|
|
||||||
6. `subType=70` — `TEXT_CHANNEL_META`
|
7. `subType=90` — `TEXT_CHANNEL_META`
|
||||||
- скрытый технический снимок профиля канала;
|
- скрытый технический снимок профиля канала;
|
||||||
- содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием;
|
- содержит 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).
|
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
|
||||||
|
|
||||||
## Правило для edit
|
## Правило для edit
|
||||||
|
|
||||||
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий 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
|
||||||
|
|
||||||
- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`.
|
- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`.
|
||||||
|
|||||||
@@ -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` нет.
|
||||||
|
- Если нужно изменить смысл статуса, пишется новое статусное событие.
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
## Блок
|
## Блок
|
||||||
|
|
||||||
- `msg_type = 1`
|
- `msg_type = 1`
|
||||||
- `subType = 70`
|
- `subType = 90`
|
||||||
- `msgVersion = 1`
|
- `msgVersion = 1`
|
||||||
- body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст.
|
- body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## 2026-08-04 12:00:00 +0400
|
||||||
- Базовый коммит-ориентир: `391b18a`.
|
- Базовый коммит-ориентир: `391b18a`.
|
||||||
- Формат вложений расширен до `SHiNE:attach v=2` для опционального второго preview-файла: добавлены поля `previewAr` и `previewSha256` при сохранении совместимости со старыми `v=1`.
|
- Формат вложений расширен до `SHiNE:attach v=2` для опционального второго preview-файла: добавлены поля `previewAr` и `previewSha256` при сохранении совместимости со старыми `v=1`.
|
||||||
|
|||||||
@@ -17,15 +17,17 @@
|
|||||||
Социальные связи (`msg_type=3`).
|
Социальные связи (`msg_type=3`).
|
||||||
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
|
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
|
||||||
Параметры пользователя (`msg_type=4`).
|
Параметры пользователя (`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` для видео и крупных изображений.
|
Вложения в 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` для профиля канала.
|
Скрытый `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`.
|
Типы каналов и формат `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)
|
||||||
Журнал изменений документации.
|
Журнал изменений документации.
|
||||||
|
|
||||||
## Смежная документация
|
## Смежная документация
|
||||||
|
|||||||
+8
-2
@@ -10,6 +10,7 @@ import {
|
|||||||
handleIncomingCallInvite,
|
handleIncomingCallInvite,
|
||||||
handleIncomingCallPush,
|
handleIncomingCallPush,
|
||||||
handleIncomingCallSignal,
|
handleIncomingCallSignal,
|
||||||
|
handleIncomingCallSignalViaSendSignal,
|
||||||
handleStopCallPush,
|
handleStopCallPush,
|
||||||
setCallDebugReporter,
|
setCallDebugReporter,
|
||||||
startDebugConnectionAsInitiator,
|
startDebugConnectionAsInitiator,
|
||||||
@@ -1210,13 +1211,14 @@ async function init() {
|
|||||||
if (added && isIncomingForCurrent && !payload.backlog) {
|
if (added && isIncomingForCurrent && !payload.backlog) {
|
||||||
const parsedText = parseDmTechBlocks(text);
|
const parsedText = parseDmTechBlocks(text);
|
||||||
const displayText = String(parsedText.displayText || '');
|
const displayText = String(parsedText.displayText || '');
|
||||||
|
const suppressHiddenNotification = parsedText.callSummary?.status === 'completed';
|
||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible') {
|
||||||
void playDmSignal({ extended: false });
|
void playDmSignal({ extended: false });
|
||||||
} else if (Notification.permission === 'granted') {
|
} else if (Notification.permission === 'granted' && !suppressHiddenNotification) {
|
||||||
try {
|
try {
|
||||||
void notifyHiddenIncomingMessage(fromLogin, displayText || '');
|
void notifyHiddenIncomingMessage(fromLogin, displayText || '');
|
||||||
} catch {}
|
} catch {}
|
||||||
} else {
|
} else if (!suppressHiddenNotification) {
|
||||||
void playDmSignal({ extended: true });
|
void playDmSignal({ extended: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1313,6 +1315,10 @@ async function init() {
|
|||||||
try { await handleIncomingCallSignal(evt); } catch {}
|
try { await handleIncomingCallSignal(evt); } catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
authService.onEvent('IncomingSignal', async (evt) => {
|
||||||
|
try { await handleIncomingCallSignalViaSendSignal(evt); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
authService.onEvent('DebugConnectPrepareResponder', async (evt) => {
|
authService.onEvent('DebugConnectPrepareResponder', async (evt) => {
|
||||||
try {
|
try {
|
||||||
const p = evt?.payload || {};
|
const p = evt?.payload || {};
|
||||||
|
|||||||
@@ -440,12 +440,12 @@ function buildCallStatusText(callSummary) {
|
|||||||
return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`;
|
return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`;
|
||||||
}
|
}
|
||||||
const reason = String(callSummary.reason || '').trim().toLowerCase();
|
const reason = String(callSummary.reason || '').trim().toLowerCase();
|
||||||
if (reason === 'offline') return 'Не дозвонился: абонент не в сети';
|
if (reason === 'offline') return 'Абонент не в сети';
|
||||||
if (reason === 'no_answer') return 'Не дозвонился: нет ответа';
|
if (reason === 'no_answer') return 'Нет ответа';
|
||||||
if (reason === 'connect_failed') return 'Не дозвонился: не удалось установить соединение';
|
if (reason === 'connect_failed') return 'Не удалось установить соединение';
|
||||||
if (reason === 'busy') return 'Не дозвонился: абонент занят';
|
if (reason === 'busy') return 'Абонент занят';
|
||||||
if (reason === 'declined') return 'Не дозвонился: звонок отклонён';
|
if (reason === 'declined') return 'Звонок отклонён';
|
||||||
return 'Не дозвонился';
|
return 'Звонок не завершён';
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveEffectiveReadState(messages, msg) {
|
function resolveEffectiveReadState(messages, msg) {
|
||||||
@@ -594,20 +594,34 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
|
|||||||
|
|
||||||
const callIcon = document.createElement('span');
|
const callIcon = document.createElement('span');
|
||||||
callIcon.className = 'bubble-call-icon';
|
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');
|
const callTitle = document.createElement('span');
|
||||||
callTitle.className = 'bubble-call-title';
|
callTitle.className = 'bubble-call-title';
|
||||||
callTitle.textContent = msg?.from === 'out' ? 'Исходящий звонок' : 'Входящий звонок';
|
callTitle.textContent = 'Звонок';
|
||||||
|
|
||||||
callHead.append(callIcon, callTitle);
|
const callMetaLine = document.createElement('span');
|
||||||
|
callMetaLine.className = 'bubble-call-line';
|
||||||
const callMetaLine = document.createElement('div');
|
callMetaLine.classList.add(
|
||||||
callMetaLine.className = 'bubble-call-line bubble-call-line--muted';
|
parsedText.callSummary.status === 'completed'
|
||||||
|
? 'bubble-call-line--success'
|
||||||
|
: 'bubble-call-line--plain',
|
||||||
|
);
|
||||||
const callStatus = buildCallStatusText(parsedText.callSummary);
|
const callStatus = buildCallStatusText(parsedText.callSummary);
|
||||||
callMetaLine.textContent = callStatus;
|
callMetaLine.textContent = callStatus;
|
||||||
|
|
||||||
callCard.append(callHead, callMetaLine);
|
callHead.append(callIcon, callTitle, callMetaLine);
|
||||||
|
callCard.append(callHead);
|
||||||
bubble.append(callCard);
|
bubble.append(callCard);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -963,7 +977,7 @@ export function render({ navigate, route }) {
|
|||||||
<button class="ghost-btn dm-edit-banner__close" type="button" id="chat-edit-cancel" title="Отменить редактирование">✕</button>
|
<button class="ghost-btn dm-edit-banner__close" type="button" id="chat-edit-cancel" title="Отменить редактирование">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></div>
|
<div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></div>
|
||||||
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
|
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000" enterkeyhint="enter"></textarea>
|
||||||
<div class="dm-actions-col">
|
<div class="dm-actions-col">
|
||||||
<button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи"><span class="dm-emoji-btn__glyph">☺</span></button>
|
<button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи"><span class="dm-emoji-btn__glyph">☺</span></button>
|
||||||
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
|
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
|
||||||
@@ -1427,23 +1441,6 @@ export function render({ navigate, route }) {
|
|||||||
if (!emojiPickerOpen || form.contains(event.target)) return;
|
if (!emojiPickerOpen || form.contains(event.target)) return;
|
||||||
closeEmojiPicker();
|
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) => {
|
form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2631,8 +2631,8 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async callInviteBroadcast({ toLogin, callId, type = 100 }) {
|
async callInviteBroadcast({ toLogin, callId, type = 100, data = '' }) {
|
||||||
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type });
|
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type, data });
|
||||||
if (response.status !== 200) throw opError('CallInviteBroadcast', response);
|
if (response.status !== 200) throw opError('CallInviteBroadcast', response);
|
||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,25 @@ const TYPES = {
|
|||||||
DECLINE_BUSY: 130,
|
DECLINE_BUSY: 130,
|
||||||
TIMEOUT: 140,
|
TIMEOUT: 140,
|
||||||
HANGUP: 150,
|
HANGUP: 150,
|
||||||
|
CONNECT_START: 170,
|
||||||
OFFER: 200,
|
OFFER: 200,
|
||||||
ANSWER: 210,
|
ANSWER: 210,
|
||||||
ICE: 220,
|
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 calls = new Map();
|
||||||
const callStateListeners = new Set();
|
const callStateListeners = new Set();
|
||||||
@@ -71,12 +86,54 @@ function getSignalTypeName(type) {
|
|||||||
if (normalized === TYPES.DECLINE_BUSY) return 'decline_busy';
|
if (normalized === TYPES.DECLINE_BUSY) return 'decline_busy';
|
||||||
if (normalized === TYPES.TIMEOUT) return 'timeout';
|
if (normalized === TYPES.TIMEOUT) return 'timeout';
|
||||||
if (normalized === TYPES.HANGUP) return 'hangup';
|
if (normalized === TYPES.HANGUP) return 'hangup';
|
||||||
|
if (normalized === TYPES.CONNECT_START) return 'connect_start';
|
||||||
if (normalized === TYPES.OFFER) return 'offer';
|
if (normalized === TYPES.OFFER) return 'offer';
|
||||||
if (normalized === TYPES.ANSWER) return 'answer';
|
if (normalized === TYPES.ANSWER) return 'answer';
|
||||||
if (normalized === TYPES.ICE) return 'ice';
|
if (normalized === TYPES.ICE) return 'ice';
|
||||||
return `type_${String(type || '')}`;
|
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) {
|
function ensureCallTimeline(call) {
|
||||||
if (!call) return;
|
if (!call) return;
|
||||||
if (!Array.isArray(call.timelineEvents)) call.timelineEvents = [];
|
if (!Array.isArray(call.timelineEvents)) call.timelineEvents = [];
|
||||||
@@ -1168,13 +1225,7 @@ async function finalizeCall(call, {
|
|||||||
const dataValue = notifyRemoteHangup
|
const dataValue = notifyRemoteHangup
|
||||||
? ''
|
? ''
|
||||||
: `setup_failed:${String(localReasonCode || 'error')}:${String(debugReason || '').slice(0, 80)}`;
|
: `setup_failed:${String(localReasonCode || 'error')}:${String(debugReason || '').slice(0, 80)}`;
|
||||||
await authService.callSignalToSession({
|
await sendSignal(call, TYPES.HANGUP, dataValue);
|
||||||
toLogin: call.peerLogin,
|
|
||||||
targetSessionId: call.remoteSessionId,
|
|
||||||
callId: call.callId,
|
|
||||||
type: TYPES.HANGUP,
|
|
||||||
data: dataValue,
|
|
||||||
});
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1255,13 +1306,22 @@ async function emitDebug(call, level, message, details = '') {
|
|||||||
async function sendSignal(call, type, data = '') {
|
async function sendSignal(call, type, data = '') {
|
||||||
if (!call.remoteSessionId) return;
|
if (!call.remoteSessionId) return;
|
||||||
const signalName = getSignalTypeName(type);
|
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 {
|
try {
|
||||||
await authService.callSignalToSession({
|
await authService.sendSignal({
|
||||||
toLogin: call.peerLogin,
|
toLogin: call.peerLogin,
|
||||||
|
targetMode: SIGNAL_TARGET_SINGLE,
|
||||||
targetSessionId: call.remoteSessionId,
|
targetSessionId: call.remoteSessionId,
|
||||||
callId: call.callId,
|
signalType,
|
||||||
type,
|
signalRequestId: call.callId,
|
||||||
data,
|
data: encodeCallSignalData(call.callId, type, data),
|
||||||
|
storagePwd,
|
||||||
|
includeClientSignature: true,
|
||||||
});
|
});
|
||||||
recordCallTimeline(call, `signal_out_${signalName}`, `toSession=${call.remoteSessionId}; len=${String(data || '').length}`);
|
recordCallTimeline(call, `signal_out_${signalName}`, `toSession=${call.remoteSessionId}; len=${String(data || '').length}`);
|
||||||
await emitDebug(call, 'info', `signal_sent_${type}`, `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) {
|
async function ensurePeerConnection(call) {
|
||||||
if (call.pc) return call.pc;
|
if (call.pc) return call.pc;
|
||||||
|
|
||||||
@@ -1451,6 +1555,7 @@ async function onAccept(call) {
|
|||||||
cleanupTimers(call);
|
cleanupTimers(call);
|
||||||
setStatus(call, 'Соединяем…', 'connecting');
|
setStatus(call, 'Соединяем…', 'connecting');
|
||||||
try {
|
try {
|
||||||
|
await notifyPeerSessionsAboutConnectStart(call);
|
||||||
const pc = await ensurePeerConnection(call);
|
const pc = await ensurePeerConnection(call);
|
||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
await pc.setLocalDescription(offer);
|
await pc.setLocalDescription(offer);
|
||||||
@@ -1496,16 +1601,40 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
|||||||
const callId = String(payload?.callId || '').trim();
|
const callId = String(payload?.callId || '').trim();
|
||||||
const fromLogin = String(payload?.fromLogin || '').trim();
|
const fromLogin = String(payload?.fromLogin || '').trim();
|
||||||
const fromSessionId = String(payload?.fromSessionId || '').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 (!callId || !fromLogin || !fromSessionId) return null;
|
||||||
|
|
||||||
if (activeCallId && activeCallId !== callId) {
|
if (activeCallId && activeCallId !== callId) {
|
||||||
try {
|
try {
|
||||||
await authService.callSignalToSession({
|
await authService.sendSignal({
|
||||||
toLogin: fromLogin,
|
toLogin: fromLogin,
|
||||||
|
targetMode: SIGNAL_TARGET_SINGLE,
|
||||||
targetSessionId: fromSessionId,
|
targetSessionId: fromSessionId,
|
||||||
callId,
|
signalType: getCallSignalType(TYPES.DECLINE_BUSY),
|
||||||
type: TYPES.DECLINE_BUSY,
|
signalRequestId: callId,
|
||||||
data: 'busy',
|
data: encodeCallSignalData(callId, TYPES.DECLINE_BUSY, 'busy'),
|
||||||
|
storagePwd: state?.session?.storagePwdInMemory || '',
|
||||||
|
includeClientSignature: true,
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
return null;
|
return null;
|
||||||
@@ -1804,7 +1933,7 @@ export async function startOutgoingCall(peerLogin) {
|
|||||||
}, 35000);
|
}, 35000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
|
call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE);
|
||||||
recordCallTimeline(
|
recordCallTimeline(
|
||||||
call,
|
call,
|
||||||
'invite_broadcast_ok',
|
'invite_broadcast_ok',
|
||||||
@@ -1822,7 +1951,7 @@ export async function startOutgoingCall(peerLogin) {
|
|||||||
const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true });
|
const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true });
|
||||||
if (recovered) {
|
if (recovered) {
|
||||||
try {
|
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') {
|
if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') {
|
||||||
setStatus(call, 'Вызываем…', 'ringing');
|
setStatus(call, 'Вызываем…', 'ringing');
|
||||||
}
|
}
|
||||||
@@ -1842,6 +1971,33 @@ export async function handleIncomingCallInvite(evt) {
|
|||||||
await handleIncomingInvitePayload(evt?.payload || {}, { source: 'ws' });
|
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() {
|
export async function acceptIncomingCall() {
|
||||||
const call = getActiveCall();
|
const call = getActiveCall();
|
||||||
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
|
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}`);
|
recordCallTimeline(call, `signal_in_${signalName}`, `from=${fromSessionId || '-'}; len=${data.length}`);
|
||||||
if (call.direction === 'out') {
|
if (call.direction === 'out') {
|
||||||
if (type === TYPES.RINGING) {
|
if (type === TYPES.RINGING) {
|
||||||
if (!call.remoteSessionId && fromSessionId) {
|
recordCallTimeline(call, 'ringing_received', `from=${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;
|
|
||||||
}
|
|
||||||
} else if (type === TYPES.ACCEPT) {
|
} else if (type === TYPES.ACCEPT) {
|
||||||
if (fromSessionId) {
|
if (fromSessionId) {
|
||||||
if (!call.remoteSessionId || !call.initialOfferSent) {
|
if (!call.remoteSessionId) {
|
||||||
setRemoteSessionId(call, fromSessionId, 'accept_before_offer_lock');
|
setRemoteSessionId(call, fromSessionId, 'accept');
|
||||||
} else if (call.remoteSessionId !== fromSessionId) {
|
} else if (call.remoteSessionId !== fromSessionId) {
|
||||||
recordCallTimeline(call, 'signal_in_accept_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`, 'warn');
|
recordCallTimeline(call, 'signal_in_accept_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`, 'warn');
|
||||||
await emitDebug(
|
await emitDebug(
|
||||||
@@ -1961,6 +2105,12 @@ export async function handleIncomingCallSignal(evt) {
|
|||||||
return;
|
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 (type === TYPES.ACCEPT) {
|
||||||
if (call.direction !== 'out') {
|
if (call.direction !== 'out') {
|
||||||
await emitDebug(call, 'warn', 'accept_ignored_for_non_outgoing_call', `direction=${call.direction || ''}`);
|
await emitDebug(call, 'warn', 'accept_ignored_for_non_outgoing_call', `direction=${call.direction || ''}`);
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ function buildCallDisplayText(callSummary) {
|
|||||||
return `Звонок: ${formatCallDuration(callSummary.durationSec)}`;
|
return `Звонок: ${formatCallDuration(callSummary.durationSec)}`;
|
||||||
}
|
}
|
||||||
const reason = String(callSummary.reason || '').trim().toLowerCase();
|
const reason = String(callSummary.reason || '').trim().toLowerCase();
|
||||||
if (reason === 'offline') return 'Звонил, но недозвонился: абонент не в сети';
|
if (reason === 'offline') return 'Абонент не в сети';
|
||||||
if (reason === 'no_answer') return 'Звонил, но недозвонился: нет ответа';
|
if (reason === 'no_answer') return 'Нет ответа';
|
||||||
if (reason === 'connect_failed') return 'Звонил, но недозвонился: не удалось установить соединение';
|
if (reason === 'connect_failed') return 'Не удалось установить соединение';
|
||||||
if (reason === 'busy') return 'Звонил, но недозвонился: абонент занят';
|
if (reason === 'busy') return 'Абонент занят';
|
||||||
if (reason === 'declined') return 'Звонил, но недозвонился: звонок отклонён';
|
if (reason === 'declined') return 'Звонок отклонён';
|
||||||
return 'Звонил, но недозвонился';
|
return 'Звонок не завершён';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeUserDmTextForSend(rawText = '') {
|
export function sanitizeUserDmTextForSend(rawText = '') {
|
||||||
|
|||||||
@@ -1763,7 +1763,6 @@
|
|||||||
|
|
||||||
.bubble-call-card {
|
.bubble-call-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
|
||||||
min-width: min(280px, 72vw);
|
min-width: min(280px, 72vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1771,34 +1770,62 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-call-icon {
|
.bubble-call-icon {
|
||||||
display: inline-grid;
|
position: relative;
|
||||||
place-items: center;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(80, 170, 255, 0.18);
|
|
||||||
border: 1px solid rgba(123, 191, 255, 0.28);
|
|
||||||
color: #d9ecff;
|
color: #d9ecff;
|
||||||
font-size: 15px;
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-call-icon-phone {
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-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 {
|
.bubble-call-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #eef6ff;
|
color: #eef6ff;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-call-line {
|
.bubble-call-line {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.35;
|
line-height: 1.2;
|
||||||
color: rgba(230, 240, 255, 0.94);
|
margin-left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-call-line--muted {
|
.bubble-call-line--success {
|
||||||
color: rgba(188, 207, 238, 0.78);
|
color: #7ee19e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-call-line--plain {
|
||||||
|
color: rgba(230, 240, 255, 0.94);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input {
|
.chat-input {
|
||||||
|
|||||||
@@ -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-логике каналов.
|
||||||
@@ -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/<login>` открывает именно этот последний `entrypoint`.
|
||||||
|
|
||||||
|
Если `entrypoint` в канале `0` нет, тогда:
|
||||||
|
|
||||||
|
- `SHiNE/<login>` открывает обычную ленту публикаций канала `0`.
|
||||||
|
|
||||||
|
## Правила открытия каналов и ссылок
|
||||||
|
|
||||||
|
Согласована следующая логика:
|
||||||
|
|
||||||
|
- `SHiNE/<login>`
|
||||||
|
- главная страница пользователя, то есть канал `0`;
|
||||||
|
- `SHiNE/<login>/<channel>`
|
||||||
|
- основная ссылка канала.
|
||||||
|
|
||||||
|
### Как открывается `SHiNE/<login>/<channel>`
|
||||||
|
|
||||||
|
Если у канала есть хотя бы один `TEXT_ENTRYPOINT`, тогда:
|
||||||
|
|
||||||
|
- по умолчанию открывается последний актуальный `entrypoint`;
|
||||||
|
- UI показывает, сколько после него было новых сообщений;
|
||||||
|
- UI показывает кнопку перехода в конец канала.
|
||||||
|
|
||||||
|
Если `entrypoint` нет:
|
||||||
|
|
||||||
|
- открывается обычная лента канала.
|
||||||
|
|
||||||
|
Для подписанного пользователя:
|
||||||
|
|
||||||
|
- логичнее вести его в место новых непрочитанных сообщений;
|
||||||
|
- а не всегда принудительно открывать entrypoint;
|
||||||
|
- при этом сверху можно показывать кнопку `Открыть entrypoint`.
|
||||||
|
|
||||||
|
### Ссылки на конкретные сообщения
|
||||||
|
|
||||||
|
Остаются обычные формы:
|
||||||
|
|
||||||
|
- `SHiNE/<login>/<channel>/<messageNumber>`
|
||||||
|
- `SHiNE/<login>/<channel>/<messageNumber>/<hash>`
|
||||||
|
|
||||||
|
Если нужно показать старую конкретную версию `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` отложен в отдельный черновик и в первую итерацию не входит.
|
||||||
Reference in New Issue
Block a user