Skip to content

Commit

Permalink
Implement in-game and website notifications (#932)
Browse files Browse the repository at this point in the history
* Implement notifications logic, basic calls, and admin command

* Remove unnecessary code

* Add ability to stack notifications and return manually created XML

* Remove test that is no longer needed and is causing failures

* Apply suggestions from code review

* Merge notifications with existing announcements page

* Order notifications by descending ID instead of ascending ID

* Move notification send task to moderation options under user

Also restyles the buttons to line up next to each other like in the slot pages.

* Style/position fixes with granted slots/notification partials

* Fix incorrect form POST route

* Prevent notification text area from breaking out of container

* Actually use builder result for notification text

* Minor restructuring of the notifications page

* Add notifications for team picks, publish issues, and moderation

* Mark notifications as dismissed instead of deleting them

* Add XMLdoc to SendNotification method

* Fix incorrect URL in announcements webhook

* Remove unnecessary inline style from granted slots partial

* Apply suggestions from code review

* Apply first batch of suggestions from code review

* Apply second batch of suggestions from code review

* Change notification icon depending on if user has unread notifications

* Show unread notification icon if there is an announcement posted

* Remove "potential" wording from definitive fixes in error docs

* Remove "Error code:" from publish notifications

* Send notification if user tries to unlock a mod-locked level

* Change notification timestamp format to include date

* Add clarification to level mod-lock notification message

* Change team pick notifications to moderation notifications

Apparently the MMPick type doesn't show a visual notification.

* Apply suggestions from code review

* Add obsolete to notification types that display nothing in-game

* Remove unused imports and remove icon switch case in favor of bell icon

* Last minute fixes

* Send notification upon earth wipe and clarify moderation case notifications

* Add check for empty/too long notification text
  • Loading branch information
sudokoko authored Oct 29, 2023
1 parent 98b370b commit aea66b4
Show file tree
Hide file tree
Showing 33 changed files with 708 additions and 195 deletions.
30 changes: 30 additions & 0 deletions Documentation/Errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@

# Errors

Here's a list of error codes, as well as their explanations and potential fixes, that are displayed within in-game and
website notifications to indicate what went wrong.

## Level Publishing

- `LH-PUB-0001`: The level failed to publish because the slot is null.
- **Note:** The slot name will not be displayed in the notification if this error occurs.
- `LH-PUB-0002`: The level failed to publish because the slot does not include a `rootLevel`.
- `LH-PUB-0003`: The level failed to publish because the resource list is null.
- `LH-PUB-0004`: The level failed to publish because the level name is too long.
- **Fix:** Shorten the level name to something below 64 characters.
- `LH-PUB-0005`: The level failed to publish because the level description is too long.
- **Fix:** Shorten the level description to something below 512 characters.
- `LH-PUB-0006`: The level failed to publish because the server is missing resources required by the level.
- **Potential Fix:** Remove any resources that are not available on the server from the level.
- `LH-PUB-0007`: The level failed to publish because the root level is not a valid level.
- `LH-PUB-0008`: The level failed to publish because the root level is not an LBP3 Adventure level.
- `LH-PUB-0009`: The level failed to publish because the the user has reached their level publishing limit.
- **Fix:** Delete some of your previously published levels to make room for new ones.

## Level Republishing

- `LH-REP-0001`: The level failed to republish because the old slot does not exist.
- `LH-REP-0002`: The level failed to republish because the original publisher is not the current publisher.
- **Potential Fix:** Copying the level to another slot on your moon typically fixes this issue.
- `LH-REP-0003`: The level could not be unlocked because it was locked by a moderator.
- **Potential Fix:** Ask a server administrator/moderator to unlock the level for you.
5 changes: 4 additions & 1 deletion ProjectLighthouse.Localization/General.resx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">

</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
Expand Down Expand Up @@ -60,4 +60,7 @@
<data name="announcements" xml:space="preserve">
<value>Announcements</value>
</data>
<data name="notifications" xml:space="preserve">
<value>Notifications</value>
</data>
</root>
3 changes: 2 additions & 1 deletion ProjectLighthouse.Localization/StringLists/GeneralStrings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public static class GeneralStrings
public static readonly TranslatableString RecentActivity = create("recent_activity");
public static readonly TranslatableString Soon = create("soon");
public static readonly TranslatableString Announcements = create("announcements");

public static readonly TranslatableString Notifications = create("notifications");

private static TranslatableString create(string key) => new(TranslationAreas.General, key);
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
#nullable enable
using System.Text;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Logging;
using LBPUnion.ProjectLighthouse.Serialization;
using LBPUnion.ProjectLighthouse.Types.Entities.Notifications;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Entities.Token;
using LBPUnion.ProjectLighthouse.Types.Logging;
using LBPUnion.ProjectLighthouse.Types.Mail;
using LBPUnion.ProjectLighthouse.Types.Serialization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
Expand Down Expand Up @@ -66,12 +68,46 @@ public async Task<IActionResult> Announce()
$"token.ExpiresAt: {token.ExpiresAt.ToString()}\n" +
"---DEBUG INFO---");
#endif

return this.Ok(announceText.ToString());
}

[HttpGet("notification")]
public IActionResult Notification() => this.Ok();
[Produces("text/xml")]
public async Task<IActionResult> Notification()
{
GameTokenEntity token = this.GetToken();

List<NotificationEntity> notifications = await this.database.Notifications
.Where(n => n.UserId == token.UserId)
.Where(n => !n.IsDismissed)
.OrderByDescending(n => n.Id)
.ToListAsync();

// We don't need to do any more work if there are no unconverted notifications to begin with.
if (notifications.Count == 0) return this.Ok();

StringBuilder builder = new();

// ReSharper disable once ForCanBeConvertedToForeach
// Suppressing this because we need to modify the list while iterating over it.
for (int i = 0; i < notifications.Count; i++)
{
NotificationEntity n = notifications[i];

builder.AppendLine(LighthouseSerializer.Serialize(this.HttpContext.RequestServices,
GameNotification.CreateFromEntity(n)));

n.IsDismissed = true;
}

await this.database.SaveChangesAsync();

return this.Ok(new LbpCustomXml
{
Content = builder.ToString(),
});
}

/// <summary>
/// Filters chat messages sent by a user.
Expand Down Expand Up @@ -104,7 +140,7 @@ public async Task<IActionResult> Filter(IMailService mailService)
}

string username = await this.database.UsernameFromGameToken(token);

string filteredText = CensorHelper.FilterMessage(message);

if (ServerConfiguration.Instance.LogChatMessages) Logger.Info($"{username}: \"{message}\"", LogArea.Filter);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#nullable enable
using System.Diagnostics;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
Expand Down Expand Up @@ -47,12 +46,16 @@ public async Task<IActionResult> StartPublish()
if (slot == null)
{
Logger.Warn("Rejecting level upload, slot is null", LogArea.Publish);
await this.database.SendNotification(user.UserId,
"Your level failed to publish. (LH-PUB-0001)");
return this.BadRequest(); // if the level cant be parsed then it obviously cant be uploaded
}

if (string.IsNullOrEmpty(slot.RootLevel))
{
Logger.Warn("Rejecting level upload, slot does not include rootLevel", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish. (LH-PUB-0002)");
return this.BadRequest();
}

Expand All @@ -61,6 +64,8 @@ public async Task<IActionResult> StartPublish()
if (slot.Resources == null)
{
Logger.Warn("Rejecting level upload, resource list is null", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish. (LH-PUB-0003)");
return this.BadRequest();
}

Expand All @@ -73,11 +78,15 @@ public async Task<IActionResult> StartPublish()
if (oldSlot == null)
{
Logger.Warn("Rejecting level republish, could not find old slot", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to republish. (LH-REP-0001)");
return this.NotFound();
}
if (oldSlot.CreatorId != user.UserId)
{
Logger.Warn("Rejecting level republish, old slot's creator is not publishing user", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to republish because you are not the original publisher. (LH-REP-0002)");
return this.BadRequest();
}
}
Expand Down Expand Up @@ -111,36 +120,48 @@ public async Task<IActionResult> Publish([FromQuery] string? game)
if (slot == null)
{
Logger.Warn("Rejecting level upload, slot is null", LogArea.Publish);
await this.database.SendNotification(user.UserId,
"Your level failed to publish. (LH-PUB-0001)");
return this.BadRequest();
}

if (slot.Resources?.Length == 0)
{
Logger.Warn("Rejecting level upload, resource list is null", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish. (LH-PUB-0003)");
return this.BadRequest();
}
// Yes Rider, this isn't null
Debug.Assert(slot.Resources != null, "slot.ResourceList != null");

slot.Description = CensorHelper.FilterMessage(slot.Description);
slot.Name = CensorHelper.FilterMessage(slot.Name);

if (slot.Description.Length > 512)
if (slot.Name.Length > 64)
{
Logger.Warn($"Rejecting level upload, description too long ({slot.Description.Length} characters)", LogArea.Publish);
Logger.Warn($"Rejecting level upload, title too long ({slot.Name.Length} characters)",
LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish because the name is too long, {slot.Name.Length} characters. (LH-PUB-0004)");
return this.BadRequest();
}

slot.Name = CensorHelper.FilterMessage(slot.Name);
slot.Description = CensorHelper.FilterMessage(slot.Description);

if (slot.Name.Length > 64)
if (slot.Description.Length > 512)
{
Logger.Warn($"Rejecting level upload, title too long ({slot.Name.Length} characters)", LogArea.Publish);
Logger.Warn($"Rejecting level upload, description too long ({slot.Description.Length} characters)",
LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish because the description is too long, {slot.Description.Length} characters. (LH-PUB-0005)");
return this.BadRequest();
}

if (slot.Resources.Any(resource => !FileHelper.ResourceExists(resource)))
{
Logger.Warn("Rejecting level upload, missing resource(s)", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish because the server is missing resources. (LH-PUB-0006)");
return this.BadRequest();
}

Expand All @@ -149,6 +170,8 @@ public async Task<IActionResult> Publish([FromQuery] string? game)
if (rootLevel == null)
{
Logger.Warn("Rejecting level upload, unable to find rootLevel", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish. (LH-PUB-0002)");
return this.BadRequest();
}

Expand All @@ -157,6 +180,8 @@ public async Task<IActionResult> Publish([FromQuery] string? game)
if (rootLevel.FileType != LbpFileType.Level)
{
Logger.Warn("Rejecting level upload, rootLevel is not a level", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish. (LH-PUB-0007)");
return this.BadRequest();
}
}
Expand All @@ -165,8 +190,10 @@ public async Task<IActionResult> Publish([FromQuery] string? game)
if (rootLevel.FileType != LbpFileType.Adventure)
{
Logger.Warn("Rejecting level upload, rootLevel is not a LBP 3 Adventure", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish. (LH-PUB-0008)");
return this.BadRequest();
}
}
}

GameVersion slotVersion = FileHelper.ParseLevelVersion(rootLevel);
Expand All @@ -193,6 +220,8 @@ public async Task<IActionResult> Publish([FromQuery] string? game)
if (oldSlot.CreatorId != user.UserId)
{
Logger.Warn("Rejecting level republish, old level not owned by current user", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to republish because you are not the original publisher. (LH-REP-0002)");
return this.BadRequest();
}

Expand Down Expand Up @@ -240,10 +269,12 @@ public async Task<IActionResult> Publish([FromQuery] string? game)

oldSlot.MinimumPlayers = Math.Clamp(slot.MinimumPlayers, 1, 4);
oldSlot.MaximumPlayers = Math.Clamp(slot.MaximumPlayers, 1, 4);

// Check if the level has been locked by a moderator to avoid unlocking it
if (oldSlot.LockedByModerator)
if (oldSlot.LockedByModerator && !slot.InitiallyLocked)
{
await this.database.SendNotification(user.UserId,
$"{slot.Name} will not be unlocked because it has been locked by a moderator. (LH-REP-0003)");
oldSlot.InitiallyLocked = true;
}

Expand All @@ -256,6 +287,8 @@ public async Task<IActionResult> Publish([FromQuery] string? game)
if (usedSlots > user.EntitledSlots)
{
Logger.Warn("Rejecting level upload, too many published slots", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish because you have reached the maximum number of levels on your earth. (LH-PUB-0009)");
return this.BadRequest();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public async Task<IActionResult> WipePlanets([FromRoute] int id) {

UserEntity? targetedUser = await this.database.Users.FirstOrDefaultAsync(u => u.UserId == id);
if (targetedUser == null) return this.NotFound();

string[] hashes = {
targetedUser.PlanetHashLBP2,
targetedUser.PlanetHashLBP3,
Expand All @@ -44,7 +44,7 @@ public async Task<IActionResult> WipePlanets([FromRoute] int id) {
{
// Don't try to remove empty hashes. That's a horrible idea.
if (string.IsNullOrWhiteSpace(hash)) continue;

// Find users with a matching hash
List<UserEntity> users = await this.database.Users
.Where(u => u.PlanetHashLBP2 == hash ||
Expand All @@ -54,7 +54,7 @@ public async Task<IActionResult> WipePlanets([FromRoute] int id) {

// We should match at least the targeted user...
System.Diagnostics.Debug.Assert(users.Count != 0);

// Reset each users' hash.
foreach (UserEntity userWithPlanet in users)
{
Expand All @@ -63,7 +63,7 @@ public async Task<IActionResult> WipePlanets([FromRoute] int id) {
userWithPlanet.PlanetHashLBPVita = "";
Logger.Success($"Deleted planets for {userWithPlanet.Username} (id:{userWithPlanet.UserId})", LogArea.Admin);
}

// And finally, attempt to remove the resource from the filesystem. We don't want that taking up space.
try
{
Expand All @@ -82,7 +82,10 @@ public async Task<IActionResult> WipePlanets([FromRoute] int id) {
Logger.Error($"Failed to delete planet resource {hash}\n{e}", LogArea.Admin);
}
}


await this.database.SendNotification(targetedUser.UserId,
"Your earth decorations have been reset by a moderator.");

await this.database.SaveChangesAsync();

return this.Redirect($"/user/{targetedUser.UserId}");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#nullable enable
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Helpers;
Expand Down Expand Up @@ -34,6 +33,10 @@ public async Task<IActionResult> TeamPick([FromRoute] int id)
// Send webhook with slot.Name and slot.Creator.Username
await WebhookHelper.SendWebhook("New Team Pick!", $"The level [**{slot.Name}**]({ServerConfiguration.Instance.ExternalUrl}/slot/{slot.SlotId}) by **{slot.Creator?.Username}** has been team picked");

// Send a notification to the creator
await this.database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, has been team picked!");

await this.database.SaveChangesAsync();

return this.Redirect("~/slot/" + id);
Expand All @@ -50,6 +53,10 @@ public async Task<IActionResult> RemoveTeamPick([FromRoute] int id)

slot.TeamPick = false;

// Send a notification to the creator
await this.database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, is no longer team picked.");

await this.database.SaveChangesAsync();

return this.Redirect("~/slot/" + id);
Expand All @@ -64,6 +71,10 @@ public async Task<IActionResult> DeleteLevel([FromRoute] int id)
SlotEntity? slot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == id);
if (slot == null) return this.Ok();

// Send a notification to the creator
await this.database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, has been deleted by a moderator.");

await this.database.RemoveSlot(slot);

return this.Redirect("~/slots/0");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@page "/admin/user/{id:int}/sendNotification"
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.Admin.AdminSendNotificationPage

@{
Layout = "Layouts/BaseLayout";
Model.Title = $"Send notification to {Model.TargetedUser!.Username}";
}

@await Html.PartialAsync("Partials/AdminSendNotificationPartial", Model.TargetedUser)
Loading

0 comments on commit aea66b4

Please sign in to comment.