Bump protocol to v9 and carry talk-circle ids on the presence frame.

Names stay off the wire; a person not in a circle writes an empty member list and topic.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 10:42:11 +03:00
co-authored by Cursor
parent 52a4104f68
commit 1a21a64b4b
7 changed files with 321 additions and 10 deletions
+13 -2
View File
@@ -1,4 +1,4 @@
# Wire protocol v8 # Wire protocol v9
The client talks to the server two ways: The client talks to the server two ways:
@@ -308,6 +308,9 @@ grid fetches `GET .../timetable?classId=` with it.
`activity` is the ActionDef name currently in progress, or `null` when idle. `activityLabel` `activity` is the ActionDef name currently in progress, or `null` when idle. `activityLabel`
is that def in the request locale. HTTP JSON is additive — no protocol version bump. is that def in the request locale. HTTP JSON is additive — no protocol version bump.
`talkCircleMemberIds` is the live circle (including self, sorted) or `[]`; `talkTopicId` is the
topic def id or `null` when not talking. Same ids as the presence frame; names are not repeated
here either — the Now tab uses the directory and locale, like the location panel.
`skills` lists only keys the person has, not every `SkillDef` in the catalog. A first-year has `skills` lists only keys the person has, not every `SkillDef` in the catalog. A first-year has
no Chemistry; a related tongue from the name set may sit beside the native at a low value. no Chemistry; a related tongue from the name set may sit beside the native at a low value.
@@ -347,6 +350,8 @@ The client does not compute thresholds. There is no school-wide opinions endpoin
"needs": [{ "id": "Sleep", "label": "Сон", "value": 1 }], "needs": [{ "id": "Sleep", "label": "Сон", "value": 1 }],
"activity": null, "activity": null,
"activityLabel": null, "activityLabel": null,
"talkCircleMemberIds": [],
"talkTopicId": null,
"family": { "family": {
"parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }], "parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }],
"children": [], "children": [],
@@ -972,6 +977,12 @@ Each person:
| string | person id | | string | person id |
| string | node id they occupy | | string | node id they occupy |
| `u8` | `1` here, `2` walking | | `u8` | `1` here, `2` walking |
| `u8` | talk-circle member count, then that many person-id strings |
| string | topic id (empty when not in a circle) |
Member ids are the live circle, including self, sorted by id. Count `0` and an empty topic mean
the person is not talking — the same id/node/state as before the circle fields. Names are not
on this frame; the client builds «говорит с Машей о футболе» from the HTTP directory and locale.
## Guarantees and limits ## Guarantees and limits
@@ -986,7 +997,7 @@ Each person:
the oldest, because a stale clock is worthless once a newer one exists. the oldest, because a stale clock is worthless once a newer one exists.
- The map snapshot and presence use a separate reliable queue so ticks cannot crowd them out. - The map snapshot and presence use a separate reliable queue so ticks cannot crowd them out.
## Not in v8 yet ## Not in v9 yet
Authentication, Sit orders, an event log, walk animation, and `OpenLocation` on the server — Authentication, Sit orders, an event log, walk animation, and `OpenLocation` on the server —
the tree and the location panel are filtered on the client from the snapshot plus presence. the tree and the location panel are filtered on the client from the snapshot plus presence.
+124 -2
View File
@@ -262,7 +262,9 @@ describe('decodeServerMessage', () => {
+ 2 + 2
+ 2 + personId.length + 2 + personId.length
+ 2 + personNode.length + 2 + personNode.length
+ 1, + 1
+ 1
+ 2,
); );
const view = new DataView(buffer); const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerPresence); view.setUint8(0, MessageType.ServerPresence);
@@ -296,6 +298,10 @@ describe('decodeServerMessage', () => {
new Uint8Array(buffer).set(personNode, offset); new Uint8Array(buffer).set(personNode, offset);
offset += personNode.length; offset += personNode.length;
view.setUint8(offset, PresenceState.Here); view.setUint8(offset, PresenceState.Here);
offset += 1;
view.setUint8(offset, 0);
offset += 1;
view.setUint16(offset, 0, true);
expect(decodeServerMessage(buffer)).toEqual({ expect(decodeServerMessage(buffer)).toEqual({
type: 'presence', type: 'presence',
@@ -308,7 +314,123 @@ describe('decodeServerMessage', () => {
activityClass: '5А', activityClass: '5А',
}, },
], ],
people: [{ id: 'f0.c0', nodeId: 'classroom-101', state: PresenceState.Here }], people: [
{
id: 'f0.c0',
nodeId: 'classroom-101',
state: PresenceState.Here,
talkMemberIds: [],
talkTopicId: '',
},
],
});
});
it('reads a presence person with talk-circle member ids and topic id', () => {
const encoder = new TextEncoder();
const putString = (target: DataView, at: number, text: Uint8Array): number => {
target.setUint16(at, text.length, true);
new Uint8Array(target.buffer).set(text, at + 2);
return at + 2 + text.length;
};
const corridor = encoder.encode('corridor-1');
const selfId = encoder.encode('f0.c0');
const partnerId = encoder.encode('f0.c1');
const topic = encoder.encode('TopicSport');
const buffer = new ArrayBuffer(
7
+ 2
+ 2
+ 2 + selfId.length
+ 2 + corridor.length
+ 1
+ 1
+ 2 + selfId.length
+ 2 + partnerId.length
+ 2 + topic.length,
);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerPresence);
view.setInt32(1, 1, true);
view.setUint16(5, 0, true);
view.setUint16(7, 1, true);
let offset = 9;
offset = putString(view, offset, selfId);
offset = putString(view, offset, corridor);
view.setUint8(offset, PresenceState.Here);
offset += 1;
view.setUint8(offset, 2);
offset += 1;
offset = putString(view, offset, selfId);
offset = putString(view, offset, partnerId);
putString(view, offset, topic);
expect(decodeServerMessage(buffer)).toEqual({
type: 'presence',
schoolId: 1,
nodes: [],
people: [
{
id: 'f0.c0',
nodeId: 'corridor-1',
state: PresenceState.Here,
talkMemberIds: ['f0.c0', 'f0.c1'],
talkTopicId: 'TopicSport',
},
],
});
});
it('does not carry a person display name in a presence frame', () => {
const encoder = new TextEncoder();
const putString = (target: DataView, at: number, text: Uint8Array): number => {
target.setUint16(at, text.length, true);
new Uint8Array(target.buffer).set(text, at + 2);
return at + 2 + text.length;
};
const corridor = encoder.encode('corridor-1');
const selfId = encoder.encode('f0.c0');
const topic = encoder.encode('TopicSport');
const buffer = new ArrayBuffer(
7 + 2 + 2 + selfId.length + 2 + corridor.length + 1 + 1 + 2 + selfId.length + 2 + topic.length,
);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerPresence);
view.setInt32(1, 1, true);
view.setUint16(5, 0, true);
view.setUint16(7, 1, true);
let offset = 9;
offset = putString(view, offset, selfId);
offset = putString(view, offset, corridor);
view.setUint8(offset, PresenceState.Here);
offset += 1;
view.setUint8(offset, 1);
offset += 1;
offset = putString(view, offset, selfId);
putString(view, offset, topic);
const bytes = new Uint8Array(buffer);
const text = new TextDecoder().decode(bytes);
expect(text).not.toContain('Мария');
expect(text).not.toContain('Иванова');
expect(text).not.toContain('Маша');
expect(text).toContain('f0.c0');
expect(text).toContain('TopicSport');
expect(decodeServerMessage(buffer)).toEqual({
type: 'presence',
schoolId: 1,
nodes: [],
people: [
{
id: 'f0.c0',
nodeId: 'corridor-1',
state: PresenceState.Here,
talkMemberIds: ['f0.c0'],
talkTopicId: 'TopicSport',
},
],
}); });
}); });
+21 -2
View File
@@ -5,7 +5,7 @@
* changed together and documented in `docs/protocol.md`. All numbers are little-endian. * changed together and documented in `docs/protocol.md`. All numbers are little-endian.
*/ */
export const PROTOCOL_VERSION = 8; export const PROTOCOL_VERSION = 9;
export const MessageType = { export const MessageType = {
ClientHello: 0x01, ClientHello: 0x01,
@@ -122,6 +122,8 @@ export interface PresencePerson {
readonly id: string; readonly id: string;
readonly nodeId: string; readonly nodeId: string;
readonly state: number; readonly state: number;
readonly talkMemberIds: readonly string[];
readonly talkTopicId: string;
} }
export interface PresenceMessage { export interface PresenceMessage {
@@ -375,7 +377,24 @@ function decodePresence(view: DataView): PresenceMessage {
offset = nodeId.next; offset = nodeId.next;
const state = readU8(view, offset); const state = readU8(view, offset);
offset += 1; offset += 1;
people.push({ id: id.text, nodeId: nodeId.text, state }); const memberCount = readU8(view, offset);
offset += 1;
const talkMemberIds: string[] = [];
for (let member = 0; member < memberCount; member++) {
const memberId = readString(view, offset);
offset = memberId.next;
talkMemberIds.push(memberId.text);
}
const topic = readString(view, offset);
offset = topic.next;
people.push({
id: id.text,
nodeId: nodeId.text,
state,
talkMemberIds,
talkTopicId: topic.text,
});
} }
return { type: 'presence', schoolId, nodes, people }; return { type: 'presence', schoolId, nodes, people };
+16 -2
View File
@@ -98,8 +98,22 @@ public sealed record PresenceNode(
string ActivitySubject = "", string ActivitySubject = "",
string ActivityClass = ""); string ActivityClass = "");
/// <summary>One on-campus person. Names are resolved over HTTP, not on this frame.</summary> /// <summary>
public sealed record PresencePerson(string Id, string NodeId, byte State); /// One on-campus person. Names are resolved over HTTP, not on this frame. Talk members and
/// topic are ids; an empty list means the person is not in a circle.
/// </summary>
public sealed record PresencePerson(
string Id,
string NodeId,
byte State,
IReadOnlyList<string> TalkMemberIds,
string TalkTopicId)
{
public PresencePerson(string id, string nodeId, byte state)
: this(id, nodeId, state, [], "")
{
}
}
/// <summary> /// <summary>
/// Live occupancy of an open school, about twice a second. Counts and people cover the whole /// Live occupancy of an open school, about twice a second. Counts and people cover the whole
+33 -1
View File
@@ -202,6 +202,14 @@ public static class ProtocolCodec
foreach (var person in message.People) foreach (var person in message.People)
{ {
size += StringSize(person.Id) + StringSize(person.NodeId) + sizeof(byte); size += StringSize(person.Id) + StringSize(person.NodeId) + sizeof(byte);
var members = person.TalkMemberIds ?? [];
size += sizeof(byte);
foreach (var memberId in members)
{
size += StringSize(memberId);
}
size += StringSize(person.TalkTopicId ?? "");
} }
return size; return size;
@@ -242,9 +250,22 @@ public static class ProtocolCodec
writer.WriteUInt16((ushort)message.People.Count); writer.WriteUInt16((ushort)message.People.Count);
foreach (var person in message.People) foreach (var person in message.People)
{ {
var members = person.TalkMemberIds ?? [];
if (members.Count > byte.MaxValue)
{
throw new ProtocolException($"Presence person {person.Id} has {members.Count} talk members; u8 count cannot hold it.");
}
writer.WriteString(person.Id); writer.WriteString(person.Id);
writer.WriteString(person.NodeId); writer.WriteString(person.NodeId);
writer.WriteByte(person.State); writer.WriteByte(person.State);
writer.WriteByte((byte)members.Count);
foreach (var memberId in members)
{
writer.WriteString(memberId);
}
writer.WriteString(person.TalkTopicId ?? "");
} }
return writer.Position; return writer.Position;
@@ -403,7 +424,18 @@ public static class ProtocolCodec
var people = new PresencePerson[personCount]; var people = new PresencePerson[personCount];
for (var i = 0; i < personCount; i++) for (var i = 0; i < personCount; i++)
{ {
people[i] = new PresencePerson(reader.ReadString(), reader.ReadString(), reader.ReadByte()); var id = reader.ReadString();
var nodeId = reader.ReadString();
var state = reader.ReadByte();
var memberCount = reader.ReadByte();
var members = new string[memberCount];
for (var member = 0; member < memberCount; member++)
{
members[member] = reader.ReadString();
}
var topicId = reader.ReadString();
people[i] = new PresencePerson(id, nodeId, state, members, topicId);
} }
return new ServerPresenceMessage(schoolId, nodes, people); return new ServerPresenceMessage(schoolId, nodes, people);
+1 -1
View File
@@ -4,7 +4,7 @@ namespace HSchool.Protocol;
public static class ProtocolConstants public static class ProtocolConstants
{ {
/// <summary>Bumped on every breaking change to the binary layout.</summary> /// <summary>Bumped on every breaking change to the binary layout.</summary>
public const byte Version = 8; public const byte Version = 9;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary> /// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 8 * 1024; public const int MaxMessageSize = 8 * 1024;
@@ -246,7 +246,120 @@ public class ProtocolCodecTests
Assert.Equal("f0.c0", read.People[0].Id); Assert.Equal("f0.c0", read.People[0].Id);
Assert.Equal("classroom-101", read.People[0].NodeId); Assert.Equal("classroom-101", read.People[0].NodeId);
Assert.Equal(PresenceState.Here, read.People[0].State); Assert.Equal(PresenceState.Here, read.People[0].State);
Assert.Empty(read.People[0].TalkMemberIds);
Assert.Equal("", read.People[0].TalkTopicId);
Assert.Equal(PresenceState.Walking, read.People[1].State); Assert.Equal(PresenceState.Walking, read.People[1].State);
Assert.Empty(read.People[1].TalkMemberIds);
}
[Fact]
public void Presence_PersonWithoutCircle_WritesEmptyMemberListAndEmptyTopic()
{
var message = new ServerPresenceMessage(
1,
[],
[new PresencePerson("f0.c0", "corridor-1", PresenceState.Here)]);
var buffer = new byte[ProtocolCodec.PresenceSize(message)];
var length = ProtocolCodec.WritePresence(buffer, message);
Assert.Equal((byte)MessageType.ServerPresence, buffer[0]);
Assert.Equal(1, BitConverter.ToInt32(buffer.AsSpan(1, 4)));
Assert.Equal((ushort)0, BitConverter.ToUInt16(buffer.AsSpan(5, 2)));
Assert.Equal((ushort)1, BitConverter.ToUInt16(buffer.AsSpan(7, 2)));
var offset = 9;
offset = AssertWireString(buffer, offset, "f0.c0");
offset = AssertWireString(buffer, offset, "corridor-1");
Assert.Equal(PresenceState.Here, buffer[offset]);
offset += 1;
Assert.Equal(0, buffer[offset]);
offset += 1;
offset = AssertWireString(buffer, offset, "");
Assert.Equal(length, offset);
Assert.Equal(length, ProtocolCodec.PresenceSize(message));
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
Assert.Empty(read.People[0].TalkMemberIds);
Assert.Equal("", read.People[0].TalkTopicId);
Assert.Equal(message.People[0].Id, read.People[0].Id);
Assert.Equal(message.People[0].NodeId, read.People[0].NodeId);
Assert.Equal(message.People[0].State, read.People[0].State);
}
[Fact]
public void Presence_PersonWithCircle_RoundTripsMemberIdsAndTopic()
{
var members = new[] { "f0.c0", "f0.c1" };
var message = new ServerPresenceMessage(
1,
[new PresenceNode("corridor-1", 2)],
[new PresencePerson("f0.c0", "corridor-1", PresenceState.Here, members, "TopicSport")]);
var buffer = new byte[ProtocolCodec.PresenceSize(message)];
var length = ProtocolCodec.WritePresence(buffer, message);
Assert.Equal((byte)MessageType.ServerPresence, buffer[0]);
Assert.Equal(1, BitConverter.ToInt32(buffer.AsSpan(1, 4)));
Assert.Equal((ushort)1, BitConverter.ToUInt16(buffer.AsSpan(5, 2)));
var offset = 7;
offset = AssertWireString(buffer, offset, "corridor-1");
Assert.Equal((ushort)2, BitConverter.ToUInt16(buffer.AsSpan(offset, 2)));
offset += 2;
Assert.Equal(0, buffer[offset]);
offset += 1;
Assert.Equal((ushort)1, BitConverter.ToUInt16(buffer.AsSpan(offset, 2)));
offset += 2;
offset = AssertWireString(buffer, offset, "f0.c0");
offset = AssertWireString(buffer, offset, "corridor-1");
Assert.Equal(PresenceState.Here, buffer[offset]);
offset += 1;
Assert.Equal(2, buffer[offset]);
offset += 1;
offset = AssertWireString(buffer, offset, "f0.c0");
offset = AssertWireString(buffer, offset, "f0.c1");
offset = AssertWireString(buffer, offset, "TopicSport");
Assert.Equal(length, offset);
Assert.Equal(length, ProtocolCodec.PresenceSize(message));
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
Assert.Equal(["f0.c0", "f0.c1"], read.People[0].TalkMemberIds);
Assert.Equal("TopicSport", read.People[0].TalkTopicId);
Assert.Equal(message.SchoolId, read.SchoolId);
Assert.Equal(message.People[0].Id, read.People[0].Id);
}
[Fact]
public void Presence_DoesNotContainPersonDisplayName()
{
var message = new ServerPresenceMessage(
1,
[],
[new PresencePerson(
"f0.c0",
"corridor-1",
PresenceState.Here,
["f0.c0", "f0.c1"],
"TopicSport")]);
var buffer = new byte[ProtocolCodec.PresenceSize(message)];
ProtocolCodec.WritePresence(buffer, message);
var text = System.Text.Encoding.UTF8.GetString(buffer);
Assert.DoesNotContain("Мария", text);
Assert.DoesNotContain("Иванова", text);
Assert.DoesNotContain("Маша", text);
Assert.Contains("f0.c0", text);
Assert.Contains("TopicSport", text);
}
private static int AssertWireString(byte[] buffer, int offset, string expected)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(expected);
Assert.Equal((ushort)bytes.Length, BitConverter.ToUInt16(buffer.AsSpan(offset, 2)));
Assert.Equal(bytes, buffer.AsSpan(offset + 2, bytes.Length).ToArray());
return offset + 2 + bytes.Length;
} }
[Fact] [Fact]