Skip to content

Commit

Permalink
Discord Ahelp Reply System (#2283) (#1004)
Browse files Browse the repository at this point in the history
Co-authored-by: Whatstone <[email protected]>
Co-authored-by: Whatstone <[email protected]>
Co-authored-by: Myzumi <[email protected]>
Co-authored-by: Aiden <[email protected]>
  • Loading branch information
5 people committed Dec 17, 2024
1 parent 3da7b19 commit 68ae3bc
Show file tree
Hide file tree
Showing 3 changed files with 127 additions and 30 deletions.
49 changes: 48 additions & 1 deletion Content.Server/Administration/ServerApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Content.Server.Administration.Systems;
using Content.Server.Administration.Managers;
using Content.Server.GameTicking;
using Content.Server.GameTicking.Presets;
using Content.Server.GameTicking.Rules.Components;
using Content.Server.Maps;
using Content.Server.RoundEnd;
using Content.Shared.Administration.Managers;
using Content.Shared.Administration;
using Content.Shared.CCVar;
using Content.Shared.GameTicking.Components;
using Content.Shared.Prototypes;
Expand Down Expand Up @@ -48,7 +50,7 @@ public sealed partial class ServerApi : IPostInjectInit
[Dependency] private readonly IStatusHost _statusHost = default!;
[Dependency] private readonly IConfigurationManager _config = default!;
[Dependency] private readonly ISharedPlayerManager _playerManager = default!;
[Dependency] private readonly ISharedAdminManager _adminManager = default!;
[Dependency] private readonly IAdminManager _adminManager = default!; // Frontier: ISharedAdminManager<IAdminManager>
[Dependency] private readonly IGameMapManager _gameMapManager = default!;
[Dependency] private readonly IServerNetManager _netManager = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
Expand Down Expand Up @@ -81,6 +83,8 @@ void IPostInjectInit.PostInject()
RegisterActorHandler(HttpMethod.Post, "/admin/actions/force_preset", ActionForcePreset);
RegisterActorHandler(HttpMethod.Post, "/admin/actions/set_motd", ActionForceMotd);
RegisterActorHandler(HttpMethod.Patch, "/admin/actions/panic_bunker", ActionPanicPunker);

RegisterHandler(HttpMethod.Post, "/admin/actions/send_bwoink", ActionSendBwoink); // Frontier - Discord Ahelp Reply
}

public void Initialize()
Expand Down Expand Up @@ -393,6 +397,40 @@ await RunOnMainThread(async () =>
_sawmill.Info($"Forced instant round restart by {FormatLogActor(actor)}");
await RespondOk(context);
});
}
#endregion

#region Frontier
// Creating a region here incase more actions are added in the future

private async Task ActionSendBwoink(IStatusHandlerContext context)
{
var body = await ReadJson<BwoinkActionBody>(context);
if (body == null)
return;

await RunOnMainThread(async () =>
{
// Player not online or wrong Guid
if (!_playerManager.TryGetSessionById(new NetUserId(body.Guid), out var player))
{
await RespondError(
context,
ErrorCode.PlayerNotFound,
HttpStatusCode.UnprocessableContent,
"Player not found");
return;
}

var serverBwoinkSystem = _entitySystemManager.GetEntitySystem<BwoinkSystem>();
var message = new SharedBwoinkSystem.BwoinkTextMessage(player.UserId, SharedBwoinkSystem.SystemUserId, body.Text);
serverBwoinkSystem.OnWebhookBwoinkTextMessage(message, body);

// Respond with OK
await RespondOk(context);
});


}

#endregion
Expand Down Expand Up @@ -631,6 +669,15 @@ private sealed class MotdActionBody
public required string Motd { get; init; }
}

public sealed class BwoinkActionBody
{
public required string Text { get; init; }
public required string Username { get; init; }
public required Guid Guid { get; init; }
public bool UserOnly { get; init; }
public required bool WebhookUpdate { get; init; }
}

#endregion

#region Responses
Expand Down
106 changes: 77 additions & 29 deletions Content.Server/Administration/Systems/BwoinkSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public sealed partial class BwoinkSystem : SharedBwoinkSystem
[Dependency] private readonly IServerDbManager _dbManager = default!;
[Dependency] private readonly PlayerRateLimitManager _rateLimit = default!;

[GeneratedRegex(@"^https://discord\.com/api/webhooks/(\d+)/((?!.*/).*)$")]
[GeneratedRegex(@"^https://(?:(?:canary|ptb)\.)?discord\.com/api/webhooks/(\d+)/((?!.*/).*)$")]
private static partial Regex DiscordRegex();

private string _webhookUrl = string.Empty;
Expand Down Expand Up @@ -142,7 +142,7 @@ private async void OnCallChanged(string url)
var webhookId = match.Groups[1].Value;
var webhookToken = match.Groups[2].Value;

_onCallData = await GetWebhookData(webhookId, webhookToken);
_onCallData = await GetWebhookData(url);
}

private void PlayerRateLimitedAction(ICommonSession obj)
Expand Down Expand Up @@ -351,6 +351,7 @@ private async void OnWebhookChanged(string url)
{
// TODO: Ideally, CVar validation during setting should be better integrated
Log.Warning("Webhook URL does not appear to be valid. Using anyways...");
await GetWebhookData(url); // Frontier - Support for Custom URLS, we still want to see if theres Webhook data available
return;
}

Expand All @@ -360,22 +361,19 @@ private async void OnWebhookChanged(string url)
return;
}

var webhookId = match.Groups[1].Value;
var webhookToken = match.Groups[2].Value;

// Fire and forget
_webhookData = await GetWebhookData(webhookId, webhookToken);
await GetWebhookData(url); // Frontier - Support for Custom URLS
}

private async Task<WebhookData?> GetWebhookData(string id, string token)
private async Task<WebhookData?> GetWebhookData(string url)
{
var response = await _httpClient.GetAsync($"https://discord.com/api/v10/webhooks/{id}/{token}");
var response = await _httpClient.GetAsync(url);

var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
_sawmill.Log(LogLevel.Error,
$"Discord returned bad status code when trying to get webhook data (perhaps the webhook URL is invalid?): {response.StatusCode}\nResponse: {content}");
$"Webhook returned bad status code when trying to get webhook data (perhaps the webhook URL is invalid?): {response.StatusCode}\nResponse: {content}");
return null;
}

Expand Down Expand Up @@ -480,6 +478,7 @@ private async void ProcessQueue(NetUserId userId, Queue<DiscordRelayedData> mess

var payload = GeneratePayload(existingEmbed.Description,
existingEmbed.Username,
userId.UserId, // Frontier, this is used to identify the players in the webhook
existingEmbed.CharacterName);

// If there is no existing embed, create a new one
Expand Down Expand Up @@ -546,7 +545,7 @@ private async void ProcessQueue(NetUserId userId, Queue<DiscordRelayedData> mess
$"**[Go to ahelp](https://discord.com/channels/{guildId}/{channelId}/{existingEmbed.Id})**");
}

payload = GeneratePayload(message.ToString(), existingEmbed.Username, existingEmbed.CharacterName);
payload = GeneratePayload(message.ToString(), existingEmbed.Username, userId, existingEmbed.CharacterName);

var request = await _httpClient.PostAsync($"{_onCallUrl}?wait=true",
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
Expand All @@ -566,7 +565,7 @@ private async void ProcessQueue(NetUserId userId, Queue<DiscordRelayedData> mess
_processingChannels.Remove(userId);
}

private WebhookPayload GeneratePayload(string messages, string username, string? characterName = null)
private WebhookPayload GeneratePayload(string messages, string username, Guid userId, string? characterName = null) // Frontier: added Guid
{
// Add character name
if (characterName != null)
Expand All @@ -592,6 +591,7 @@ private WebhookPayload GeneratePayload(string messages, string username, string?
return new WebhookPayload
{
Username = username,
UserID = userId, // Frontier, this is used to identify the players in the webhook
AvatarUrl = string.IsNullOrWhiteSpace(_avatarUrl) ? null : _avatarUrl,
Embeds = new List<WebhookEmbed>
{
Expand Down Expand Up @@ -629,10 +629,20 @@ public override void Update(float frameTime)
}
}

// Frontier: webhook text messages
public void OnWebhookBwoinkTextMessage(BwoinkTextMessage message, ServerApi.BwoinkActionBody body)
{
// Note for forks:
AdminData webhookAdminData = new();

// TODO: fix args
OnBwoinkInternal(message, SystemUserId, webhookAdminData, body.Username, null, body.UserOnly, body.WebhookUpdate, true);
}

protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs)
{
base.OnBwoinkTextMessage(message, eventArgs);
_activeConversations[message.UserId] = DateTime.Now;

var senderSession = eventArgs.SenderSession;

// TODO: Sanitize text?
Expand All @@ -650,6 +660,23 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes
if (_rateLimit.CountAction(eventArgs.SenderSession, RateLimitKey) != RateLimitStatus.Allowed)
return;

OnBwoinkInternal(message, eventArgs.SenderSession.UserId, senderAdmin, eventArgs.SenderSession.Name, eventArgs.SenderSession.Channel, false, true, false);
}

/// <summary>
/// Sends a bwoink. Common to both internal messages (sent via the ahelp or admin interface) and webhook messages (sent through the webhook, e.g. via Discord)
/// </summary>
/// <param name="message">The message being sent.</param>
/// <param name="senderId">The network GUID of the person sending the message.</param>
/// <param name="senderAdmin">The admin privileges of the person sending the message.</param>
/// <param name="senderName">The name of the person sending the message.</param>
/// <param name="senderChannel">The channel to send a message to, e.g. in case of failure to send</param>
/// <param name="sendWebhook">If true, message should be sent off through the webhook if possible</param>
/// <param name="fromWebhook">Message originated from a webhook (e.g. Discord)</param>
private void OnBwoinkInternal(BwoinkTextMessage message, NetUserId senderId, AdminData? senderAdmin, string senderName, INetChannel? senderChannel, bool userOnly, bool sendWebhook, bool fromWebhook)
{
_activeConversations[message.UserId] = DateTime.Now;

var escapedText = FormattedMessage.EscapeText(message.Text);

string bwoinkText;
Expand All @@ -665,31 +692,37 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes
senderAdmin.Flags ==
AdminFlags.Adminhelp) // Mentor. Not full admin. That's why it's colored differently.
{
bwoinkText = $"[color=purple]{adminPrefix}{senderSession.Name}[/color]";
bwoinkText = $"[color=purple]{adminPrefix}{senderName}[/color]";
}
else if (senderAdmin is not null && senderAdmin.HasFlag(AdminFlags.Adminhelp))
else if (fromWebhook || senderAdmin is not null && senderAdmin.HasFlag(AdminFlags.Adminhelp)) // Frontier: anything sent via webhooks are from an admin.
{
bwoinkText = $"[color=red]{adminPrefix}{senderSession.Name}[/color]";
bwoinkText = $"[color=red]{adminPrefix}{senderName}[/color]";
}
else
{
bwoinkText = $"{senderSession.Name}";
bwoinkText = $"{senderName}";
}

if (fromWebhook)
bwoinkText = $"(DISCORD) {bwoinkText}";

bwoinkText = $"{(message.PlaySound ? "" : "(S) ")}{bwoinkText}: {escapedText}";

// If it's not an admin / admin chooses to keep the sound then play it.
var playSound = !senderAHelpAdmin || message.PlaySound;
var msg = new BwoinkTextMessage(message.UserId, senderSession.UserId, bwoinkText, playSound: playSound);
var playSound = senderAdmin == null || message.PlaySound;
var msg = new BwoinkTextMessage(message.UserId, senderId, bwoinkText, playSound: playSound);

LogBwoink(msg);

var admins = GetTargetAdmins();

// Notify all admins
foreach (var channel in admins)
if (!userOnly)
{
RaiseNetworkEvent(msg, channel);
foreach (var channel in admins)
{
RaiseNetworkEvent(msg, channel);
}
}

string adminPrefixWebhook = "";
Expand Down Expand Up @@ -721,13 +754,16 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes
}
else
{
overrideMsgText = $"{senderSession.Name}"; // Not an admin, name is not overridden.
overrideMsgText = $"{senderName}"; // Not an admin, name is not overridden.
}

if (fromWebhook)
overrideMsgText = $"(DC) {overrideMsgText}";

overrideMsgText = $"{(message.PlaySound ? "" : "(S) ")}{overrideMsgText}: {escapedText}";

RaiseNetworkEvent(new BwoinkTextMessage(message.UserId,
senderSession.UserId,
senderId,
overrideMsgText,
playSound: playSound),
session.Channel);
Expand All @@ -738,13 +774,13 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes
}

var sendsWebhook = _webhookUrl != string.Empty;
if (sendsWebhook)
if (sendsWebhook && sendWebhook)
{
if (!_messageQueues.ContainsKey(msg.UserId))
_messageQueues[msg.UserId] = new Queue<DiscordRelayedData>();

var str = message.Text;
var unameLength = senderSession.Name.Length;
var unameLength = senderName.Length;

if (unameLength + str.Length + _maxAdditionalChars > DescriptionMax)
{
Expand All @@ -753,12 +789,13 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes

var nonAfkAdmins = GetNonAfkAdmins();
var messageParams = new AHelpMessageParams(
senderSession.Name,
senderName,
str,
!personalChannel,
senderId != message.UserId,
_gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"),
_gameTicker.RunLevel,
playedSound: playSound,
isDiscord: fromWebhook,
noReceivers: nonAfkAdmins.Count == 0
);
_messageQueues[msg.UserId].Enqueue(GenerateAHelpMessage(messageParams));
Expand All @@ -768,10 +805,14 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes
return;

// No admin online, let the player know
var systemText = Loc.GetString("bwoink-system-starmute-message-no-other-users");
var starMuteMsg = new BwoinkTextMessage(message.UserId, SystemUserId, systemText);
RaiseNetworkEvent(starMuteMsg, senderSession.Channel);
if (senderChannel != null)
{
var systemText = Loc.GetString("bwoink-system-starmute-message-no-other-users");
var starMuteMsg = new BwoinkTextMessage(message.UserId, SystemUserId, systemText);
RaiseNetworkEvent(starMuteMsg, senderChannel);
}
}
// End Frontier:

private IList<INetChannel> GetNonAfkAdmins()
{
Expand Down Expand Up @@ -807,6 +848,10 @@ private static DiscordRelayedData GenerateAHelpMessage(AHelpMessageParams parame
stringbuilder.Append($" **{parameters.RoundTime}**");
if (!parameters.PlayedSound)
stringbuilder.Append(" **(S)**");

if (parameters.IsDiscord) // Frontier - Discord Indicator
stringbuilder.Append(" **(DC)**");

if (parameters.Icon == null)
stringbuilder.Append($" **{parameters.Username}:** ");
else
Expand Down Expand Up @@ -870,6 +915,7 @@ public sealed class AHelpMessageParams
public GameRunLevel RoundState { get; set; }
public bool PlayedSound { get; set; }
public bool NoReceivers { get; set; }
public bool IsDiscord { get; set; } // Frontier
public string? Icon { get; set; }

public AHelpMessageParams(
Expand All @@ -879,6 +925,7 @@ public AHelpMessageParams(
string roundTime,
GameRunLevel roundState,
bool playedSound,
bool isDiscord = false, // Frontier
bool noReceivers = false,
string? icon = null)
{
Expand All @@ -887,6 +934,7 @@ public AHelpMessageParams(
IsAdmin = isAdmin;
RoundTime = roundTime;
RoundState = roundState;
IsDiscord = isDiscord; // Frontier
PlayedSound = playedSound;
NoReceivers = noReceivers;
Icon = icon;
Expand Down
2 changes: 2 additions & 0 deletions Content.Server/Discord/WebhookPayload.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ namespace Content.Server.Discord;
// https://discord.com/developers/docs/resources/channel#message-object-message-structure
public struct WebhookPayload
{
[JsonPropertyName("UserID")] // Frontier, this is used to identify the players in the webhook
public Guid? UserID { get; set; }
/// <summary>
/// The message to send in the webhook. Maximum of 2000 characters.
/// </summary>
Expand Down

0 comments on commit 68ae3bc

Please sign in to comment.