746 lines
40 KiB
C#
746 lines
40 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<ulong> Ids(string name = "ids")
|
|
{
|
|
var raw = new List<string>();
|
|
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<string> 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;
|
|
}
|
|
|
|
internal 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
|
|
};
|
|
}
|
|
|
|
internal 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()
|
|
};
|
|
}
|
|
|
|
internal 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.
|
|
internal static string CleanContents(string contents)
|
|
{
|
|
if (ConfigCampus.AllowSwearsInChatThreads) return contents;
|
|
try
|
|
{
|
|
var mj = JsonConvert.DeserializeObject<MessageJson>(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<ChatPrivacy>(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;
|
|
}
|
|
|
|
internal 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 };
|
|
}
|
|
|
|
internal static bool AreFriends(ulong a, ulong b)
|
|
{
|
|
if (a == b) return true;
|
|
try
|
|
{
|
|
return (FriendsDB.GetRelationships(a) ?? new List<RelationshipDetail>())
|
|
.Any(r => r.PlayerID == b && r.RelationshipType == RelationshipType.Friend);
|
|
}
|
|
catch { return false; }
|
|
}
|
|
|
|
internal static bool IsFavoriteOf(ulong owner, ulong other)
|
|
{
|
|
try
|
|
{
|
|
return (FriendsDB.GetRelationships(owner) ?? new List<RelationshipDetail>())
|
|
.Any(r => r.PlayerID == other && r.RelationshipType == RelationshipType.Friend
|
|
&& (r.Favorited == ReciprocalStatus.Local || r.Favorited == ReciprocalStatus.Mutual));
|
|
}
|
|
catch { return false; }
|
|
}
|
|
|
|
/// <summary>May `sender` open a DM (group=false) with / add to a group (group=true) `target`, per the target's privacy setting? Devs always may.</summary>
|
|
internal 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 `{}` when they're not in one (the reference's answer: a
|
|
// thread with every field at its default, which the client reads as "no party"). JSON
|
|
// null failed the client's reader - "Response was empty" in Player.log on every login
|
|
// and every room change, twice with its retries.
|
|
[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("{}");
|
|
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
|
|
// a player who opens it when they're in NO party. Without this the second player got a 404,
|
|
// the Party tab listed no members and party chat never opened.
|
|
//
|
|
// Only when they're in no party, and not straight back into one they just left. This used
|
|
// to move them out of their current party too, posting "left" there - and that notice is
|
|
// pushed to the leaver, whose client opens the old thread and moved back, posting "left"
|
|
// in the new one... Players flipped between two parties dozens of times a second, ending
|
|
// up on whichever won and merging parties together, so one player changing room pulled
|
|
// people who had never partied with them into it. Switching party is the explicit
|
|
// joinpartychat below.
|
|
private static readonly TimeSpan RejoinAfterLeaving = TimeSpan.FromMinutes(2);
|
|
|
|
private static ChatThread AdmitToParty(ChatThread thread, ulong playerId)
|
|
{
|
|
if (thread.ChatThreadType != ChatDB.ThreadTypeParty || thread.PlayerIds.Contains(playerId)) return thread;
|
|
if (ChatDB.GetPartyThreadFor(playerId) != null) return thread;
|
|
if (ChatDB.LeftPartyRecently(playerId, thread.ChatThreadId, RejoinAfterLeaving)) return thread;
|
|
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<ContentResult> 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<ulong> { 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. (Membership is dropped
|
|
// BEFORE the notice, so the leaver isn't pushed the old party's "left" message.)
|
|
foreach (var oldId in ChatDB.PartyThreadIdsFor(account.Id).Where(t => t != threadId))
|
|
{
|
|
ChatDB.RemoveMember(oldId, account.Id);
|
|
ChatDB.SystemNotice(oldId, $"Player <@U{account.Id}> left");
|
|
}
|
|
ChatDB.ForgetPartyLeave(account.Id, threadId);
|
|
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<ulong> { 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<ulong> { 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<ChatMessage> 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<ContentResult> SendMessageToThread(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 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 async Task<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);
|
|
|
|
// What the client sends for the star was never captured, and reading only a form field "favorite"
|
|
// meant any other spelling (a JSON body, a bare true/false, another name) read as "false" - the star
|
|
// un-favourited on every press and the thread list and the open thread disagreed. Look in every
|
|
// place, and when nothing says on/off TOGGLE the current state; log the raw call either way.
|
|
string raw = await RawBody(400);
|
|
bool? want = null;
|
|
foreach (var n in new[] { "favorite", "favorited", "isFavorited", "isFavorite", "isFavourite", "favourite", "value" })
|
|
{
|
|
var v = Field(n);
|
|
if (!string.IsNullOrWhiteSpace(v)) { want = FormBool(v); break; }
|
|
}
|
|
if (want == null && !string.IsNullOrWhiteSpace(raw))
|
|
{
|
|
string t = raw.Trim();
|
|
if (t.StartsWith("{"))
|
|
{
|
|
try
|
|
{
|
|
foreach (var pr in Newtonsoft.Json.Linq.JObject.Parse(t).Properties())
|
|
if (pr.Name.Contains("avo", StringComparison.OrdinalIgnoreCase) || pr.Name.Equals("value", StringComparison.OrdinalIgnoreCase)) { want = FormBool(pr.Value.ToString()); break; }
|
|
}
|
|
catch { }
|
|
}
|
|
else if (t.Equals("true", StringComparison.OrdinalIgnoreCase) || t.Equals("false", StringComparison.OrdinalIgnoreCase) || t == "1" || t == "0")
|
|
want = FormBool(t);
|
|
}
|
|
var thread = ChatDB.GetThread(id);
|
|
bool current = thread != null && ChatDB.StateFor(thread, account.Id).IsFavorited;
|
|
bool on = want ?? !current;
|
|
ChatDB.UpdateState(id, account.Id, s => s.IsFavorited = on);
|
|
Console.WriteLine($"[chat] {account.Id} {Request.Method} thread/{id}/favorite ct='{Request.ContentType}' query='{Request.QueryString}' body='{raw}' -> " +
|
|
$"{(want == null ? "no flag sent, toggled" : "flag " + want)} {current} -> {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;
|
|
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;
|
|
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<ContentResult> 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);
|
|
}
|
|
}
|
|
}
|