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:
@@ -262,7 +262,9 @@ describe('decodeServerMessage', () => {
|
||||
+ 2
|
||||
+ 2 + personId.length
|
||||
+ 2 + personNode.length
|
||||
+ 1,
|
||||
+ 1
|
||||
+ 1
|
||||
+ 2,
|
||||
);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerPresence);
|
||||
@@ -296,6 +298,10 @@ describe('decodeServerMessage', () => {
|
||||
new Uint8Array(buffer).set(personNode, offset);
|
||||
offset += personNode.length;
|
||||
view.setUint8(offset, PresenceState.Here);
|
||||
offset += 1;
|
||||
view.setUint8(offset, 0);
|
||||
offset += 1;
|
||||
view.setUint16(offset, 0, true);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'presence',
|
||||
@@ -308,7 +314,123 @@ describe('decodeServerMessage', () => {
|
||||
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',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 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 = {
|
||||
ClientHello: 0x01,
|
||||
@@ -122,6 +122,8 @@ export interface PresencePerson {
|
||||
readonly id: string;
|
||||
readonly nodeId: string;
|
||||
readonly state: number;
|
||||
readonly talkMemberIds: readonly string[];
|
||||
readonly talkTopicId: string;
|
||||
}
|
||||
|
||||
export interface PresenceMessage {
|
||||
@@ -375,7 +377,24 @@ function decodePresence(view: DataView): PresenceMessage {
|
||||
offset = nodeId.next;
|
||||
const state = readU8(view, offset);
|
||||
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 };
|
||||
|
||||
@@ -98,8 +98,22 @@ public sealed record PresenceNode(
|
||||
string ActivitySubject = "",
|
||||
string ActivityClass = "");
|
||||
|
||||
/// <summary>One on-campus person. Names are resolved over HTTP, not on this frame.</summary>
|
||||
public sealed record PresencePerson(string Id, string NodeId, byte State);
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// Live occupancy of an open school, about twice a second. Counts and people cover the whole
|
||||
|
||||
@@ -202,6 +202,14 @@ public static class ProtocolCodec
|
||||
foreach (var person in message.People)
|
||||
{
|
||||
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;
|
||||
@@ -242,9 +250,22 @@ public static class ProtocolCodec
|
||||
writer.WriteUInt16((ushort)message.People.Count);
|
||||
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.NodeId);
|
||||
writer.WriteByte(person.State);
|
||||
writer.WriteByte((byte)members.Count);
|
||||
foreach (var memberId in members)
|
||||
{
|
||||
writer.WriteString(memberId);
|
||||
}
|
||||
|
||||
writer.WriteString(person.TalkTopicId ?? "");
|
||||
}
|
||||
|
||||
return writer.Position;
|
||||
@@ -403,7 +424,18 @@ public static class ProtocolCodec
|
||||
var people = new PresencePerson[personCount];
|
||||
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);
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace HSchool.Protocol;
|
||||
public static class ProtocolConstants
|
||||
{
|
||||
/// <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>
|
||||
public const int MaxMessageSize = 8 * 1024;
|
||||
|
||||
Reference in New Issue
Block a user