Files
Campus-25/static/Servers/CampusNet/Services/ChatService.cs
T
2026-10-14 06:59:51 +02:00

254 lines
11 KiB
C#

using CampusNet.Protocol;
namespace CampusNet.Services;
/// <summary>Photon Chat operation / parameter / event codes (the public Photon Chat SDK's values -
/// the game's copy of the SDK has these constants compiled away).</summary>
public static class ChatOp
{
public const byte Subscribe = 0, Unsubscribe = 1, Publish = 2, SendPrivate = 3, ChannelHistory = 4,
UpdateStatus = 5, AddFriends = 6, RemoveFriends = 7, GetProperties = 8, SetProperties = 9;
public static bool Is(byte op) => op <= SetProperties;
}
public static class ChatP
{
public const byte Channels = 0, Channel = 1, Messages = 2, Message = 3, Senders = 4, Sender = 5,
ChannelUserCount = 6, MsgId = 8, MsgIds = 9, Status = 10, Friends = 11, SkipMessage = 12,
HistoryLength = 14, SubscribeResults = 15, Properties = 22, ChannelSubscribers = 23,
UserId = 225;
}
public static class ChatEv
{
public const byte ChatMessages = 0, Users = 1, PrivateMessage = 2, FriendsList = 3, StatusUpdate = 4,
Subscribe = 5, Unsubscribe = 6, PropertiesChanged = 7, UserSubscribed = 8, UserUnsubscribed = 9;
}
/// <summary>
/// A Photon Chat frontend, riding on the MASTER port. Chat's operation codes (0-9) never collide
/// with LoadBalancing's (217+), and the name server already hands every client the master's
/// address, so a chat client lands here with no new port and no firewall change.
///
/// What uses it: event BROADCASTING (RecRoom.Broadcasting.BroadcastingManager). The broadcaster's
/// client publishes its movement, voice, room chat, desktop screen share and CV2 events to a chat
/// CHANNEL; players in the event's other instances subscribe to that channel and draw the
/// broadcaster from what arrives. So the job here is a faithful fan-out: every Publish reaches
/// every subscriber of the channel (sender included, as Photon does), payload byte for byte.
/// </summary>
public static class ChatService
{
private sealed class Channel
{
public readonly string Name;
public readonly List<Connection> Subs = new();
public readonly List<(int id, string sender, RawValue msg)> History = new();
public int NextMsgId;
public bool PublishSubscribers;
public int MaxSubscribers;
public long Published, Delivered;
public Channel(string name) { Name = name; }
}
private static readonly object Sync = new();
private static readonly Dictionary<string, Channel> Channels = new(StringComparer.Ordinal);
private static readonly Dictionary<Connection, HashSet<string>> Membership = new();
private const int HistoryKept = 20; // per channel, for a subscriber that asks for history
private const int MaxChannelsPerUser = 64;
private const int MaxChannelNameLength = 256;
private const byte PropMaxSubscribers = 255, PropPublishSubscribers = 254; // ChannelWellKnownProperties
public static void Handle(Connection c, GpBinary16.OperationRequest op)
{
switch (op.Code)
{
case ChatOp.Subscribe: Subscribe(c, op); return;
case ChatOp.Unsubscribe: Unsubscribe(c, op); return;
case ChatOp.Publish: Publish(c, op); return;
case ChatOp.SendPrivate: SendPrivate(c, op); return;
default:
// History, status, friends and properties: acknowledged. Nothing in the broadcast
// path depends on them; an error reply would only put a warning in the client log.
Log.Info($"{c.Tag}: chat op {op.Code} acknowledged {LoadBalancingApp.DescribeParams(op.Params)}");
c.SendResponse(op.Code, new());
return;
}
}
private static void Subscribe(Connection c, GpBinary16.OperationRequest op)
{
var names = (op.Params.GetValueOrDefault(ChatP.Channels) as string[]) ?? Array.Empty<string>();
int historyLength = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(ChatP.HistoryLength));
var opts = op.Params.GetValueOrDefault(ChatP.Properties) as System.Collections.Hashtable;
c.SendResponse(op.Code, new());
var results = new bool[names.Length];
var joined = new List<Channel>();
lock (Sync)
{
var mine = Membership.TryGetValue(c, out var m) ? m : Membership[c] = new();
for (int i = 0; i < names.Length; i++)
{
string name = names[i] ?? "";
if (name.Length == 0 || name.Length > MaxChannelNameLength) continue;
if (!mine.Contains(name) && mine.Count >= MaxChannelsPerUser) continue;
if (!Channels.TryGetValue(name, out var ch))
{
ch = Channels[name] = new Channel(name);
// Creation options are honoured by the subscriber that creates the channel.
if (opts != null)
{
ch.PublishSubscribers = opts[PropPublishSubscribers] is bool ps && ps;
ch.MaxSubscribers = LoadBalancingApp.ToInt(opts[PropMaxSubscribers]);
}
}
if (!ch.Subs.Contains(c))
{
if (ch.MaxSubscribers > 0 && ch.Subs.Count >= ch.MaxSubscribers) continue;
ch.Subs.Add(c);
}
mine.Add(name);
results[i] = true;
joined.Add(ch);
}
}
var ev = new Dictionary<byte, object?> { [ChatP.Channels] = names, [ChatP.SubscribeResults] = results };
if (names.Length == 1 && joined.Count == 1 && joined[0].PublishSubscribers)
{
string[] subs; lock (Sync) subs = joined[0].Subs.Select(s => s.UserId ?? "").ToArray();
ev[ChatP.ChannelSubscribers] = subs;
}
c.SendEvent(ChatEv.Subscribe, ev);
foreach (var ch in joined)
{
List<Connection> others; int count;
lock (Sync) { others = ch.Subs.Where(s => s != c).ToList(); count = ch.Subs.Count; }
Log.Info($"{c.Tag}: chat subscribed '{ch.Name}' ({count} subscriber(s){(ch.PublishSubscribers ? ", published" : "")})");
if (ch.PublishSubscribers)
foreach (var o in others)
o.SendEvent(ChatEv.UserSubscribed, new() { [ChatP.Channel] = ch.Name, [ChatP.UserId] = c.UserId ?? "" });
// History: -1 = all kept, N = the last N, 0 = none.
if (historyLength != 0)
{
List<(int id, string sender, RawValue msg)> h;
lock (Sync) h = historyLength < 0 ? ch.History.ToList() : ch.History.TakeLast(historyLength).ToList();
if (h.Count > 0)
c.SendEvent(ChatEv.ChatMessages, new()
{
[ChatP.Channel] = ch.Name,
[ChatP.Senders] = h.Select(x => x.sender).ToArray(),
[ChatP.Messages] = h.Select(x => (object?)x.msg).ToArray(),
[ChatP.MsgId] = h[^1].id
});
}
}
var refused = names.Where((n, i) => !results[i]).ToList();
if (refused.Count > 0) Log.Warn($"{c.Tag}: chat subscribe refused for [{string.Join(", ", refused)}]");
}
private static void Unsubscribe(Connection c, GpBinary16.OperationRequest op)
{
var names = (op.Params.GetValueOrDefault(ChatP.Channels) as string[]) ?? Array.Empty<string>();
c.SendResponse(op.Code, new());
foreach (var n in names) Leave(c, n);
c.SendEvent(ChatEv.Unsubscribe, new() { [ChatP.Channels] = names });
Log.Info($"{c.Tag}: chat unsubscribed [{string.Join(", ", names)}]");
}
private static void Leave(Connection c, string name)
{
Channel? ch; List<Connection> others = new();
lock (Sync)
{
if (Membership.TryGetValue(c, out var mine)) mine.Remove(name);
if (!Channels.TryGetValue(name, out ch)) return;
ch.Subs.Remove(c);
if (ch.PublishSubscribers) others = ch.Subs.ToList();
if (ch.Subs.Count == 0) Channels.Remove(name);
}
foreach (var o in others)
o.SendEvent(ChatEv.UserUnsubscribed, new() { [ChatP.Channel] = name, [ChatP.UserId] = c.UserId ?? "" });
}
private static void Publish(Connection c, GpBinary16.OperationRequest op)
{
string name = op.Params.GetValueOrDefault(ChatP.Channel) as string ?? "";
if (op.Raw == null || !op.Raw.TryGetValue(ChatP.Message, out var raw))
{
c.SendError(op.Code, Rc.InvalidOperation, "No message");
return;
}
if (!c.Charge(raw.Length, "chat publish")) return;
Channel? ch; List<Connection> targets; int id;
var msg = new RawValue(raw);
string sender = c.UserId ?? "";
lock (Sync)
{
if (!Channels.TryGetValue(name, out ch) || !ch.Subs.Contains(c))
{
// Photon only lets a subscriber publish to a channel.
c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not subscribed to that channel");
return;
}
id = ++ch.NextMsgId;
ch.History.Add((id, sender, msg));
if (ch.History.Count > HistoryKept) ch.History.RemoveAt(0);
targets = ch.Subs.ToList();
ch.Published++; ch.Delivered += targets.Count;
if (ch.Published == 1 || ch.Published % 1000 == 0)
Log.Info($"{c.Tag}: chat '{name}' published {ch.Published} message(s), {ch.Delivered} delivered, {targets.Count} subscriber(s) (last {raw.Length}B)");
}
c.SendResponse(op.Code, new());
var bytes = GpBinary18.EventMessage(ChatEv.ChatMessages, new Dictionary<byte, object?>
{
[ChatP.Channel] = name,
[ChatP.Senders] = new[] { sender },
[ChatP.Messages] = new object?[] { msg },
[ChatP.MsgId] = id
});
foreach (var t in targets) t.Send(bytes);
}
private static void SendPrivate(Connection c, GpBinary16.OperationRequest op)
{
string to = op.Params.GetValueOrDefault(ChatP.UserId) as string ?? "";
if (op.Raw == null || !op.Raw.TryGetValue(ChatP.Message, out var raw) || to.Length == 0)
{
c.SendError(op.Code, Rc.InvalidOperation, "No message or target");
return;
}
if (!c.Charge(raw.Length, "chat private")) return;
List<Connection> targets;
lock (Sync) targets = Membership.Keys.Where(k => k.UserId == to || k == c).ToList();
c.SendResponse(op.Code, new());
var p = new Dictionary<byte, object?>
{
[ChatP.Sender] = c.UserId ?? "",
[ChatP.Message] = new RawValue(raw),
[ChatP.UserId] = to,
[ChatP.MsgId] = 0
};
foreach (var t in targets) t.SendEvent(ChatEv.PrivateMessage, p);
}
/// <summary>The connection is gone: out of every channel it was in.</summary>
public static void OnDisconnect(Connection c)
{
List<string> names;
lock (Sync)
{
if (!Membership.TryGetValue(c, out var mine)) return;
names = mine.ToList();
Membership.Remove(c);
}
foreach (var n in names) Leave(c, n);
if (names.Count > 0) Log.Info($"{c.Tag}: chat disconnected, left [{string.Join(", ", names)}]");
}
}