using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes;
using Campus_live_rewrite.api.server.Classes.db_class;
using CampusLiveSource.api.admin;
using static Campus_live_rewrite.api.server.Classes.db_class.ChatDBClasses;
namespace Campus_live_rewrite.Controllers
{
///
/// The chat service (chat.rec.net): DMs, group chats and party threads. Contracts follow the
/// reference chat app (apps/chat) - a reverse-engineered protocol, so the shapes matter:
///
/// GET thread the caller's threads, each with its latestMessage
/// POST thread create with ids + messageContents -> { chatThread, chatResult }
/// POST thread/withmembers FETCH-OR-CREATE the thread with exactly these members -> thread + messages
/// GET thread/{id} thread + a page of messages (object)
/// GET thread/{id}/message a page of messages (bare array)
/// POST thread/{id} | /message send -> { ChatMessage, ChatResult, chatResult, chatThread }
/// POST thread/{id}/member/{pid} add someone to a group (bare ChatResult)
/// POST thread/{id}/leave | rename | snooze | favorite | read | message/{mid}/read (bare ChatResult)
/// GET/POST thread/party the caller's party thread / create one
///
/// Read pointer, snooze and favorite are PER MEMBER. Every message (and every join/leave/rename
/// notice) is pushed to the members as ChatMessageReceived over the hub, which is what draws
/// the in-room bubble and updates an open conversation.
///
[Route("chat")]
public class ChatController : ControllerBase
{
// ---------------- helpers ----------------
private static ContentResult Json(object o, int status = 200) => new()
{
Content = JsonConvert.SerializeObject(o),
ContentType = "application/json",
StatusCode = status
};
private static ContentResult Raw(string body, int status = 200) => new() { Content = body, ContentType = "application/json", StatusCode = status };
private static ContentResult Result(ChatResults r) => Raw(((int)r).ToString());
// The ChatResult values beyond the local enum that the client branches on.
private const int ResultCannotMessageDueToRemotePrivacy = 16;
private const int ResultCannotAddDueToPrivacy = 18;
private string? Field(string name)
{
if (Request.HasFormContentType)
foreach (var k in Request.Form.Keys)
if (string.Equals(k, name, StringComparison.OrdinalIgnoreCase)) return Request.Form[k].ToString();
foreach (var k in Request.Query.Keys)
if (string.Equals(k, name, StringComparison.OrdinalIgnoreCase)) return Request.Query[k].ToString();
return null;
}
private List Ids(string name = "ids")
{
var raw = new List();
if (Request.HasFormContentType)
foreach (var k in Request.Form.Keys)
if (string.Equals(k, name, StringComparison.OrdinalIgnoreCase)) raw.AddRange(Request.Form[k].ToArray()!);
foreach (var k in Request.Query.Keys)
if (string.Equals(k, name, StringComparison.OrdinalIgnoreCase)) raw.AddRange(Request.Query[k].ToArray()!);
return raw.SelectMany(v => (v ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries))
.Select(v => ulong.TryParse(v.Trim(), out var id) ? id : 0UL)
.Where(v => v != 0).Distinct().ToList();
}
private static bool FormBool(string? v) =>
v != null && (v.Equals("true", StringComparison.OrdinalIgnoreCase) || v == "1" || v.Equals("yes", StringComparison.OrdinalIgnoreCase));
// The raw body (any content type), for logging what an unknown call actually sends.
private async Task RawBody(int max = 2048)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var sr = new StreamReader(Request.Body, leaveOpen: true);
string body = await sr.ReadToEndAsync();
Request.Body.Position = 0;
return body.Length > max ? body.Substring(0, max) + "..." : body;
}
catch { return "(unreadable)"; }
}
private int Count(string name, int fallback, int cap = 100)
{
if (int.TryParse(Field(name), out var n) && n > 0) return Math.Min(n, cap);
return fallback;
}
private static object ThreadListDto(ChatThread t, ulong viewer)
{
var st = ChatDB.StateFor(t, viewer);
var latest = t.Messages != null && t.Messages.Count > 0 ? t.Messages.OrderByDescending(m => m.ChatMessageId).First() : null;
return new
{
latestMessage = latest != null ? ChatDB.MessageDto(latest) : null,
chatThreadId = t.ChatThreadId,
playerIds = t.PlayerIds.OrderBy(x => x).ToList(),
lastReadMessageId = st.LastReadMessageId,
chatThreadName = t.ChatThreadName ?? "",
chatThreadType = t.ChatThreadType,
snoozedUntil = st.SnoozedUntil,
isFavorited = st.IsFavorited
};
}
private static object ThreadWithMessagesDto(ChatThread t, ulong viewer, int count)
{
var st = ChatDB.StateFor(t, viewer);
return new
{
chatThreadId = t.ChatThreadId,
playerIds = t.PlayerIds.OrderBy(x => x).ToList(),
lastReadMessageId = st.LastReadMessageId,
chatThreadName = t.ChatThreadName ?? "",
chatThreadType = t.ChatThreadType,
snoozedUntil = st.SnoozedUntil,
isFavorited = st.IsFavorited,
messages = (t.Messages ?? new()).OrderByDescending(m => m.ChatMessageId).Take(count).Select(ChatDB.MessageDto).ToList()
};
}
private static object SentMessageDto(ChatMessage m) => new
{
ChatMessageId = m.ChatMessageId,
ChatThreadId = m.ChatThreadId,
SenderPlayerId = m.SenderPlayerId,
TimeSent = m.TimeSent.ToUniversalTime(),
Contents = m.Contents,
ModerationState = m.ModerationState
};
// The stored envelope, with profanity masked in a Text message when the server is set to.
private static string CleanContents(string contents)
{
if (ConfigCampus.AllowSwearsInChatThreads) return contents;
try
{
var mj = JsonConvert.DeserializeObject(contents);
if (mj != null && mj.Type == MessageContentType.Text && mj.Data != null)
{
mj.Data = Sanitize.SanitizeMessage(mj.Data);
return JsonConvert.SerializeObject(mj);
}
}
catch { /* not an envelope - store as sent */ }
return contents;
}
// ---------------- privacy ----------------
private const string DmPrivacyKey = "directMessagePrivacySetting";
private const string GroupPrivacyKey = "groupChatPrivacySetting";
private static ChatPrivacy? ParseChatPrivacy(string? v)
{
if (string.IsNullOrWhiteSpace(v)) return null;
if (Enum.TryParse(v.Trim(), ignoreCase: true, out var byName)) return byName;
if (int.TryParse(v.Trim(), out var n) && Enum.IsDefined(typeof(ChatPrivacy), n)) return (ChatPrivacy)n;
return null;
}
private static ChatPrivacySettings ReadChatPrivacy(ulong playerId)
{
var dm = ParseChatPrivacy(SettingsDB.GetPlayerSettingValue(DmPrivacyKey, playerId)) ?? ChatPrivacy.Friends;
var grp = ParseChatPrivacy(SettingsDB.GetPlayerSettingValue(GroupPrivacyKey, playerId)) ?? ChatPrivacy.Friends;
return new ChatPrivacySettings { playerId = playerId, directMessagePrivacySetting = dm, groupChatPrivacySetting = grp };
}
private static bool AreFriends(ulong a, ulong b)
{
if (a == b) return true;
try
{
return (FriendsDB.GetRelationships(a) ?? new List())
.Any(r => r.PlayerID == b && r.RelationshipType == RelationshipType.Friend);
}
catch { return false; }
}
private static bool IsFavoriteOf(ulong owner, ulong other)
{
try
{
return (FriendsDB.GetRelationships(owner) ?? new List())
.Any(r => r.PlayerID == other && r.RelationshipType == RelationshipType.Friend
&& (r.Favorited == ReciprocalStatus.Local || r.Favorited == ReciprocalStatus.Mutual));
}
catch { return false; }
}
/// May `sender` open a DM (group=false) with / add to a group (group=true) `target`, per the target's privacy setting? Devs always may.
private static bool PrivacyAllows(ulong sender, ulong target, bool group)
{
if (sender == target) return true;
if (PlayerDB.CheckDevFlag(sender)) return true;
var p = ReadChatPrivacy(target);
var setting = group ? p.groupChatPrivacySetting : p.directMessagePrivacySetting;
return setting switch
{
ChatPrivacy.NoOne => false,
ChatPrivacy.Favorites => IsFavoriteOf(target, sender),
_ => AreFriends(sender, target),
};
}
[HttpGet("thread/chatPrivacySetting")]
public IActionResult GetChatPrivacySetting()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
return Ok(ReadChatPrivacy(account.Id));
}
// One field per call, by name ("Favorites") or ordinal; an absent field is left as it was.
[HttpPut("thread/chatPrivacySetting")]
public IActionResult SetChatPrivacySetting()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var dm = ParseChatPrivacy(Field(DmPrivacyKey));
var grp = ParseChatPrivacy(Field(GroupPrivacyKey));
if (dm != null) SettingsDB.SetPlayerSetting(DmPrivacyKey, ((int)dm.Value).ToString(), account.Id);
if (grp != null) SettingsDB.SetPlayerSetting(GroupPrivacyKey, ((int)grp.Value).ToString(), account.Id);
if (dm != null || grp != null)
Console.WriteLine($"[chat] {account.Id} set privacy dm={dm?.ToString() ?? "(unchanged)"} group={grp?.ToString() ?? "(unchanged)"}");
return Ok(ReadChatPrivacy(account.Id));
}
// Asked before the client opens a new DM (receivingPlayerId=...). Bare ChatResult.
[HttpGet("thread/checkCanSendDirectMessageWithPrivacySetting")]
public IActionResult CheckCanSendDirectMessage()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
ulong target = ulong.TryParse(Field("receivingPlayerId") ?? Field("playerId"), out var t) ? t : 0;
if (target == 0) return Result(ChatResults.Success);
return Raw(PrivacyAllows(account.Id, target, group: false) ? "0" : ResultCannotMessageDueToRemotePrivacy.ToString());
}
// How long a party invite link lives (bare single-key object, reference value).
[HttpGet("settings/partyinvite")]
public IActionResult PartyInviteSettings() => Json(new { InviteLinkLifetimeInMinutes = 60 });
// ---------------- listing ----------------
[HttpGet("thread")]
public ContentResult GetThreads([FromQuery] int maxCount = 50, int mode = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var threads = ChatDB.GetThreadsForPlayer(account.Id, Math.Clamp(maxCount, 1, 200));
return Json(threads.Select(t => ThreadListDto(t, account.Id)).ToList());
}
// The caller's party thread, or JSON null when they're not in one.
[HttpGet("thread/party")]
public ContentResult GetPartyThread([FromQuery] int maxCount = 1, [FromQuery] int mode = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var party = ChatDB.GetPartyThreadFor(account.Id);
if (party == null) return Raw("null");
return Json(PartyThreadDto(party, account.Id));
}
// A party's chat membership follows the party itself: the party forms between the clients
// over Photon (a shared PlayerPartyID), then each member's client simply opens the party
// thread BY ID (GET /chat/thread/{id}) - it never asks to be added. So a party thread admits
// any player who opens it: they're put on it (leaving any other party thread first) and the
// members are told. Without this the second player got a 404, the Party tab listed no
// members and party chat never opened.
private static ChatThread AdmitToParty(ChatThread thread, ulong playerId)
{
if (thread.ChatThreadType != ChatDB.ThreadTypeParty || thread.PlayerIds.Contains(playerId)) return thread;
var old = ChatDB.GetPartyThreadFor(playerId);
if (old != null && old.ChatThreadId != thread.ChatThreadId)
{
ChatDB.SystemNotice(old.ChatThreadId, $"Player <@U{playerId}> left");
ChatDB.RemoveMember(old.ChatThreadId, playerId);
}
ChatDB.AddMember(thread.ChatThreadId, playerId);
ChatDB.SystemNotice(thread.ChatThreadId, $"Player <@U{playerId}> joined the party");
Console.WriteLine($"[chat] {playerId} joined party thread {thread.ChatThreadId} (opened it)");
return ChatDB.GetThread(thread.ChatThreadId)!;
}
// Party thread DTO: the list shape (latestMessage) AND a messages page in one object, so
// whichever way the client parses a thread it finds what it needs.
private static object PartyThreadDto(ChatThread t, ulong viewer)
{
var st = ChatDB.StateFor(t, viewer);
var ordered = (t.Messages ?? new()).OrderByDescending(m => m.ChatMessageId).ToList();
return new
{
latestMessage = ordered.Count > 0 ? ChatDB.MessageDto(ordered[0]) : null,
chatThreadId = t.ChatThreadId,
playerIds = t.PlayerIds.OrderBy(x => x).ToList(),
lastReadMessageId = st.LastReadMessageId,
chatThreadName = t.ChatThreadName ?? "",
chatThreadType = t.ChatThreadType,
snoozedUntil = st.SnoozedUntil,
isFavorited = st.IsFavorited,
messages = ordered.Take(50).Select(ChatDB.MessageDto).ToList()
};
}
// POST /chat/thread/party - "make me a party thread" (the client sends NO body: the thread
// id it gets back is the party id it then shares with the players it parties up with over
// Photon). Answers the THREAD itself; the create envelope this used to answer was not what
// the client parses, so every Join Party click re-posted and no party ever formed. A caller
// already in a party thread gets that one back. Optional ids seed members, optional
// messageContents posts a first message.
[HttpPost("thread/party")]
public async Task CreatePartyThread()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
Console.WriteLine($"[chat] {account.Id} POST thread/party ct='{Request.ContentType}' fields=[{(Request.HasFormContentType ? string.Join(",", Request.Form.Keys) : "")}] query={Request.QueryString} body={await RawBody()} headers=[{string.Join("; ", Request.Headers.Where(h => !string.Equals(h.Key, "Authorization", StringComparison.OrdinalIgnoreCase)).Select(h => h.Key + "=" + h.Value))}]");
var party = ChatDB.GetPartyThreadFor(account.Id);
if (party == null)
{
var members = new List { account.Id };
members.AddRange(Ids().Where(id => id != account.Id));
party = ChatDB.CreateThread(members, account.Id, "", ChatDB.ThreadTypeParty);
Console.WriteLine($"[chat] {account.Id} created party thread {party.ChatThreadId} with [{string.Join(",", members)}]");
}
ChatMessage? first = null;
string? contents = Field("messageContents");
if (!string.IsNullOrWhiteSpace(contents))
{
first = ChatDB.AddMessage(party.ChatThreadId, (int)account.Id, CleanContents(contents));
party = ChatDB.GetThread(party.ChatThreadId)!;
if (first != null) ChatDB.Push(party, first);
}
// The client's create-party call is its CreateThread(messageContents) with nothing to
// post (BestHTTP sends an empty form for that). It only proceeds on chatResult 0, so an
// empty first message must NOT report InvalidArguments here (the reference's rule for a
// plain /thread create) - that "1" is what left every Join Party click re-creating.
return Json(new
{
ChatMessage = first != null ? SentMessageDto(first) : null,
ChatResult = 0,
chatResult = 0,
chatThread = PartyThreadDto(party, account.Id)
});
}
// Join another player's party thread (the "Join Party" side): /chat/thread/party/{threadId}
// and /chat/thread/party/join?... are both accepted. Adds the caller, tells the members.
// `thread/{id}/joinpartychat` is the spelling THIS client uses (it posted it 148 times and
// got a 404 every time, which is why the party list flickered between 0 and 2 people and
// the thread filled with joined/left notices - each refusal made the client retry from
// scratch). The other two spellings are kept for older clients.
[HttpPost("thread/{threadId:long}/joinpartychat")]
[HttpPut("thread/{threadId:long}/joinpartychat")]
[HttpPost("thread/party/{threadId:long}")]
[HttpPut("thread/party/{threadId:long}")]
public ContentResult JoinPartyThread(long threadId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var party = ChatDB.GetThread(threadId);
if (party == null || party.ChatThreadType != ChatDB.ThreadTypeParty) return Result(ChatResults.ThreadNotFound);
if (!party.PlayerIds.Contains(account.Id))
{
// Leave any previous party first - one party at a time.
var old = ChatDB.GetPartyThreadFor(account.Id);
if (old != null && old.ChatThreadId != threadId) { ChatDB.SystemNotice(old.ChatThreadId, $"Player <@U{account.Id}> left"); ChatDB.RemoveMember(old.ChatThreadId, account.Id); }
ChatDB.AddMember(threadId, account.Id);
// One notice per real join. A client that re-posts the same join (reconnect, room
// change, a retry after a 404) lands in the `already a member` branch above and
// says nothing - that repetition is what filled the thread with joined/left pairs.
ChatDB.SystemNotice(threadId, $"Player <@U{account.Id}> joined the party");
Console.WriteLine($"[chat] {account.Id} joined party thread {threadId}");
}
return Json(PartyThreadDto(ChatDB.GetThread(threadId)!, account.Id));
}
// Leave the party (drop out of the party thread).
[HttpPost("thread/party/leave")]
[HttpDelete("thread/party")]
public ContentResult LeavePartyThread()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var party = ChatDB.GetPartyThreadFor(account.Id);
if (party == null) return Result(ChatResults.MembershipNotFound);
ChatDB.SystemNotice(party.ChatThreadId, $"Player <@U{account.Id}> left");
ChatDB.RemoveMember(party.ChatThreadId, account.Id);
Console.WriteLine($"[chat] {account.Id} left party thread {party.ChatThreadId}");
return Result(ChatResults.Success);
}
// ---------------- creating / opening ----------------
// POST /chat/thread: ids (repeated or comma-joined, the caller is added) + messageContents.
// Reuses the thread with exactly these members if one exists (one conversation per
// pair/group) and posts the first message. Answers the reference's CreateThreadResponse.
[HttpPost("thread")]
public ContentResult CreateThread()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var members = Ids().Where(id => id != account.Id).ToList();
if (members.Count == 0 || members.Count + 1 > ChatDB.MaxThreadMembers) return Raw("", 400);
bool group = members.Count > 1;
var blocked = members.Where(m => !PrivacyAllows(account.Id, m, group)).ToList();
if (blocked.Count == members.Count)
return Json(new { ChatMessage = (object?)null, ChatResult = group ? ResultCannotAddDueToPrivacy : ResultCannotMessageDueToRemotePrivacy,
chatResult = group ? ResultCannotAddDueToPrivacy : ResultCannotMessageDueToRemotePrivacy, chatThread = (object?)null });
members = members.Except(blocked).ToList();
var all = new List { account.Id }; all.AddRange(members);
var thread = ChatDB.GetOrCreateThreadWithMembers(all, account.Id);
ChatMessage? first = null;
string? contents = Field("messageContents");
if (!string.IsNullOrWhiteSpace(contents))
{
first = ChatDB.AddMessage(thread.ChatThreadId, (int)account.Id, CleanContents(contents));
thread = ChatDB.GetThread(thread.ChatThreadId)!;
if (first != null) ChatDB.Push(thread, first);
}
int result = first != null ? (blocked.Count > 0 ? 17 : 0) : 1;
Console.WriteLine($"[chat] {account.Id} opened thread {thread.ChatThreadId} with [{string.Join(",", members)}] (msg: {(first != null ? "yes" : "no")})");
return Json(new { ChatMessage = first != null ? SentMessageDto(first) : null, ChatResult = result, chatResult = result, chatThread = ThreadListDto(thread, account.Id) });
}
// POST /chat/thread/withmembers: the client's GetChatBetweenPlayers - FETCH-OR-CREATE the
// thread with exactly these members and answer it WITH its messages (an object, not the
// create envelope). Minting a fresh thread on every call is what produced a new empty
// conversation each time you opened someone's DM.
[HttpPost("thread/withmembers")]
public ContentResult ThreadWithMembers()
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var members = Ids().Where(id => id != account.Id).ToList();
if (members.Count == 0 || members.Count + 1 > ChatDB.MaxThreadMembers) return Raw("", 400);
var all = new List { account.Id }; all.AddRange(members);
var existing = ChatDB.FindThreadWithExactMembers(all);
if (existing == null)
{
bool group = members.Count > 1;
if (members.Any(m => !PrivacyAllows(account.Id, m, group)))
return Raw((group ? ResultCannotAddDueToPrivacy : ResultCannotMessageDueToRemotePrivacy).ToString());
existing = ChatDB.CreateThread(all, account.Id);
var notice = existing.Messages.LastOrDefault();
if (notice != null) ChatDB.Push(existing, notice);
Console.WriteLine($"[chat] {account.Id} opened thread {existing.ChatThreadId} with [{string.Join(",", members)}]");
}
int count = Count("messageCount", 50);
return Json(ThreadWithMessagesDto(existing, account.Id, count));
}
[HttpGet("thread/{id:long}")]
public ContentResult GetThreadById(long id, [FromQuery] int maxCount = 50, [FromQuery] int messageCount = 0, [FromQuery] int mode = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread != null) thread = AdmitToParty(thread, account.Id);
if (thread == null || !thread.PlayerIds.Contains(account.Id)) return Raw("", 404);
int count = messageCount > 0 ? Math.Min(messageCount, 100) : Math.Clamp(maxCount, 1, 100);
if (thread.ChatThreadType == ChatDB.ThreadTypeParty) return Json(PartyThreadDto(thread, account.Id));
return Json(ThreadWithMessagesDto(thread, account.Id, count));
}
// A page of messages - a bare array. Latest / newer-than / older-than a reference id.
[HttpGet("thread/{id:long}/message")]
public ContentResult GetThreadMessages(long id, [FromQuery] int messageCount = 50, [FromQuery] int mode = 0, [FromQuery] long referenceMessageId = 0)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread != null) thread = AdmitToParty(thread, account.Id);
if (thread == null || !thread.PlayerIds.Contains(account.Id)) return Raw("", 404);
int count = Math.Clamp(messageCount, 1, 200);
IEnumerable messages = thread.Messages;
messages = (QueryMode)mode switch
{
QueryMode.NewerThan => messages.Where(m => m.ChatMessageId > referenceMessageId).OrderBy(m => m.ChatMessageId).Take(count).OrderByDescending(m => m.ChatMessageId),
QueryMode.OlderThan => messages.Where(m => m.ChatMessageId < referenceMessageId).OrderByDescending(m => m.ChatMessageId).Take(count),
_ => messages.OrderByDescending(m => m.ChatMessageId).Take(count),
};
return Json(messages.Select(ChatDB.MessageDto).ToList());
}
// ---------------- sending ----------------
[HttpPost("thread/{id:long}")]
[HttpPost("thread/{id:long}/message")]
public async Task SendMessageToThread(long id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread != null) thread = AdmitToParty(thread, account.Id);
if (thread == null || !thread.PlayerIds.Contains(account.Id))
return Json(new { ChatMessage = (object?)null, ChatResult = 3, chatResult = 3, chatThread = (object?)null });
// Members can be added in the same call (ids=...): a group grows as you message it.
var extra = Ids().Where(pid => !thread.PlayerIds.Contains(pid)).ToList();
foreach (var pid in extra)
{
if (!PrivacyAllows(account.Id, pid, group: true)) continue;
ChatDB.AddMember(id, pid);
ChatDB.SystemNotice(id, $"Player <@U{account.Id}> added <@U{pid}>");
}
if (extra.Count > 0) thread = ChatDB.GetThread(id)!;
string? contents = Field("messageContents");
if (string.IsNullOrWhiteSpace(contents))
return Json(new { ChatMessage = (object?)null, ChatResult = 1, chatResult = 1, chatThread = ThreadWithMessagesDto(thread, account.Id, Count("messageCount", 50)) });
string actualMessage = "";
try { actualMessage = JObject.Parse(contents)?["Data"]?.ToString() ?? ""; } catch { }
var msg = ChatDB.AddMessage(id, (int)account.Id, CleanContents(contents));
if (msg == null)
return Json(new { ChatMessage = (object?)null, ChatResult = 5, chatResult = 5, chatThread = (object?)null });
thread = ChatDB.GetThread(id)!;
// Everyone on the thread, sender included (the client doesn't fold its own send in).
await Notifications.SendToPlayers(thread.PlayerIds.ToList(), JsonConvert.SerializeObject(new { Id = "ChatMessageReceived", Msg = ChatDB.MessageDto(msg) }));
// Dev chat commands ("<=>cmd") answered by Coach (1) when a dev talks to Coach.
if (actualMessage.StartsWith("<=>") && PlayerDB.CheckDevFlag(account.Id) && thread.PlayerIds.Contains(1))
{
_ = Task.Run(async () =>
{
try
{
await Task.Delay(250);
string? result = DevChatCommands.ParseCommand(actualMessage.Substring(3), "ChatThread", account.Id);
if (string.IsNullOrWhiteSpace(result)) return;
var bot = ChatDB.AddMessage(id, 1, JsonConvert.SerializeObject(new { Type = 0, Version = 2, Data = "<=>" + result }));
if (bot != null) ChatDB.Push(ChatDB.GetThread(id)!, bot);
}
catch (Exception ex) { Console.WriteLine($"DevChat error: {ex}"); }
});
}
return Json(new
{
ChatMessage = SentMessageDto(msg),
ChatResult = 0,
chatResult = 0,
chatThread = ThreadWithMessagesDto(thread, account.Id, Count("messageCount", 50))
});
}
// ---------------- membership ----------------
// Add a player to a thread you're on. Bare ChatResult: 0 ok, 3 not a member, 4 already on it, 18 their privacy forbids it.
[HttpPost("thread/{id:long}/member/{playerId:long}")]
[HttpPut("thread/{id:long}/member/{playerId:long}")]
public ContentResult AddMember(long id, long playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread == null || !thread.PlayerIds.Contains(account.Id)) return Result(ChatResults.MembershipNotFound);
ulong pid = (ulong)playerId;
if (thread.PlayerIds.Contains(pid)) return Result(ChatResults.PlayerAlreadyOnThread);
if (!PrivacyAllows(account.Id, pid, group: true)) return Raw(ResultCannotAddDueToPrivacy.ToString());
if (thread.PlayerIds.Count + 1 > ChatDB.MaxThreadMembers) return Result(ChatResults.ThreadTooLarge);
ChatDB.AddMember(id, pid);
ChatDB.SystemNotice(id, $"Player <@U{account.Id}> added <@U{pid}>");
Console.WriteLine($"[chat] {account.Id} added {pid} to thread {id}");
return Result(ChatResults.Success);
}
// Remove a player (yourself, or anyone if you started the thread).
[HttpDelete("thread/{id:long}/member/{playerId:long}")]
public ContentResult RemoveMember(long id, long playerId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread == null || !thread.PlayerIds.Contains(account.Id)) return Result(ChatResults.MembershipNotFound);
ulong pid = (ulong)playerId;
if (!thread.PlayerIds.Contains(pid)) return Result(ChatResults.MembershipNotFound);
if (pid != account.Id && thread.CreatorPlayerId != account.Id) return Raw("9"); // InsufficientPermission
ChatDB.SystemNotice(id, pid == account.Id ? $"Player <@U{pid}> left" : $"Player <@U{account.Id}> removed <@U{pid}>");
ChatDB.RemoveMember(id, pid);
return Result(ChatResults.Success);
}
// Leave: notice first (the leaver still gets the push that tells their client the thread
// is gone), then drop the membership. The thread survives for the others.
[HttpPost("thread/{id:long}/leave")]
[HttpDelete("thread/{id:long}/leave")]
public ContentResult LeaveThread(long id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread == null || !thread.PlayerIds.Contains(account.Id)) return Result(ChatResults.MembershipNotFound);
var notice = ChatDB.AddMessage(id, ChatDB.SystemSenderId,
JsonConvert.SerializeObject(new MessageJson { Type = MessageContentType.Text, Version = 1, Data = $"Player <@U{account.Id}> left" }));
if (notice != null)
{
var t = ChatDB.GetThread(id)!;
ChatDB.Push(t, notice);
ChatDB.Push(t, notice, "PlayerLeftChat");
}
ChatDB.RemoveMember(id, account.Id);
Console.WriteLine($"[chat] {account.Id} left thread {id}");
return Result(ChatResults.Success);
}
[HttpPost("thread/{id:long}/rename")]
[HttpPut("thread/{id:long}/rename")]
public ContentResult RenameThread(long id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
var thread = ChatDB.GetThread(id);
if (thread == null || !thread.PlayerIds.Contains(account.Id)) return Result(ChatResults.MembershipNotFound);
string name = Uri.UnescapeDataString(Field("name") ?? "").Trim();
if (name.Length > 128) name = name.Substring(0, 128);
ChatDB.SetName(id, name);
ChatDB.SystemNotice(id, $"Player <@U{account.Id}> renamed the chat");
return Result(ChatResults.Success);
}
// ---------------- per-member state ----------------
[HttpPost("thread/{id:long}/snooze")]
[HttpPut("thread/{id:long}/snooze")]
public ContentResult SnoozeThread(long id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (!ChatDB.IsMember(id, account.Id)) return Result(ChatResults.MembershipNotFound);
bool on = FormBool(Field("snooze"));
ChatDB.UpdateState(id, account.Id, s => s.SnoozedUntil = on ? new DateTime(9999, 12, 31, 23, 59, 59, DateTimeKind.Utc) : null);
return Result(ChatResults.Success);
}
[HttpPost("thread/{id:long}/favorite")]
[HttpPut("thread/{id:long}/favorite")]
public ContentResult FavoriteThread(long id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
if (!ChatDB.IsMember(id, account.Id)) return Result(ChatResults.MembershipNotFound);
bool on = FormBool(Field("favorite"));
ChatDB.UpdateState(id, account.Id, s => s.IsFavorited = on);
return Result(ChatResults.Success);
}
[HttpPost("thread/{id:long}/read")]
[HttpPut("thread/{id:long}/read")]
public ContentResult MarkThreadRead(long id)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
{ var t = ChatDB.GetThread(id); if (t != null) AdmitToParty(t, account.Id); }
if (!ChatDB.IsMember(id, account.Id)) return Raw("", 404);
ChatDB.MarkRead(id, account.Id);
return Result(ChatResults.Success);
}
[HttpPost("thread/{id:long}/message/{messageId:long}/read")]
[HttpPut("thread/{id:long}/message/{messageId:long}/read")]
public ContentResult MarkMessageRead(long id, long messageId)
{
var (account, error) = AuthHelper.ValidateToken(HttpContext);
if (error != null) return error;
{ var t = ChatDB.GetThread(id); if (t != null) AdmitToParty(t, account.Id); }
if (!ChatDB.IsMember(id, account.Id)) return Raw("", 404);
ChatDB.MarkRead(id, account.Id, messageId);
return Result(ChatResults.Success);
}
// ---------------- anything else the client asks the chat service ----------------
// Logged so a route this build calls that isn't implemented yet names itself (method,
// path, query, form keys) instead of vanishing as a silent 404.
[Route("{*rest}")]
[AcceptVerbs("GET", "POST", "PUT", "DELETE", "PATCH")]
public async Task Unhandled(string? rest)
{
string fields = Request.HasFormContentType ? string.Join(",", Request.Form.Keys) : "";
Console.WriteLine($"[chat] UNHANDLED {Request.Method} /chat/{rest}{Request.QueryString} ct='{Request.ContentType}' fields=[{fields}] body={await RawBody()}");
return Raw("", 404);
}
}
}