Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Discord Ahelp Reply System #2283

Merged
merged 40 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
96a5f78
First part of Remote Bwoinking
Myzumi Oct 16, 2024
da2a809
Merge branch 'new-frontiers-14:master' into discord_ahelp
Myzumi Oct 18, 2024
780ce08
This should technically work
Myzumi Oct 19, 2024
bdf791b
No Actoring
Myzumi Oct 19, 2024
432c3ef
Fixes Guid not sending over
Myzumi Oct 19, 2024
ce6f098
Making it work for the final.
Myzumi Oct 19, 2024
977e513
Fixes for api
Myzumi Oct 19, 2024
e513e29
Merge pull request #2 from Myzumi/watchdog_ahelp
Myzumi Oct 19, 2024
551da03
Moar Commants!
Myzumi Oct 19, 2024
9f94a64
Merge remote-tracking branch 'Myzumi/discord_ahelp' into discord_ahelp
Myzumi Oct 19, 2024
78c8252
comment
Myzumi Oct 19, 2024
a7276e7
wops
Myzumi Oct 19, 2024
83c7244
Fixes Naming Rules
Myzumi Oct 19, 2024
58f1882
Merge branch 'master' into discord_ahelp
Myzumi Oct 19, 2024
6a78daf
I Should also fix the naming in the actually code...
Myzumi Oct 19, 2024
04bbf9f
Merge remote-tracking branch 'Myzumi/discord_ahelp' into discord_ahelp
Myzumi Oct 19, 2024
125a681
Merge branch 'new-frontiers-14:master' into watchdog_ahelp
Myzumi Oct 22, 2024
eb8df53
Merge branch 'new-frontiers-14:master' into discord_ahelp
Myzumi Oct 22, 2024
7a4757c
Merge remote-tracking branch 'origin/watchdog_ahelp' into watchdog_ahelp
Myzumi Oct 22, 2024
f362beb
Merge remote-tracking branch 'origin/discord_ahelp' into watchdog_ahelp
Myzumi Oct 22, 2024
a968075
Testing some new code
Myzumi Oct 22, 2024
992e8df
Naming rule and dependency fix (hopefully)
Myzumi Oct 23, 2024
3ae3bbf
Serverside Webhook update on external sent ahelp messages
Myzumi Oct 23, 2024
9ceec41
Merge branch 'watchdog_ahelp' into discord_ahelp
Myzumi Oct 23, 2024
43fd204
Merge branch 'new-frontiers-14:master' into discord_ahelp
Myzumi Oct 28, 2024
af06b18
Merge branch 'new-frontiers-14:master' into discord_ahelp
Myzumi Nov 2, 2024
3939f30
Still get data from custom URL's, even if it dosent match a discord w…
Myzumi Nov 2, 2024
c98e417
Apply suggestions from code review (Part 1)
Myzumi Nov 6, 2024
06af92d
Merge branch 'new-frontiers-14:master' into discord_ahelp
Myzumi Nov 6, 2024
0cfe249
Apply suggestions from code review (Part 2)
Myzumi Nov 6, 2024
9fee8f6
Merge branch 'new-frontiers-14:master' into discord_ahelp
Myzumi Nov 12, 2024
edb1cf9
Bwoink system suggsetions
whatston3 Nov 21, 2024
2dee7fa
missing BwoinkSystem changes
whatston3 Nov 21, 2024
f20cb90
Change access on BwoinkSystem._messageQueues
whatston3 Nov 21, 2024
1e96a14
Merge pull request #6 from whatston3/discord_ahelp_suggestion
Myzumi Nov 26, 2024
124f737
Updates the Regex to support other Discord Clients (beta, alpha)
Myzumi Nov 26, 2024
9b2895e
Merge remote-tracking branch 'origin/master' into discord_ahelp
Myzumi Nov 26, 2024
3e4f125
Merge Fixes
Myzumi Nov 26, 2024
f9ae1f4
BwoinkSystem: explicitly match "canary."/"ptb."
whatston3 Nov 26, 2024
7db8dfc
Merge branch 'master' into discord_ahelp
Myzumi Nov 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function should do some minimal validation on the data it's given before passing it into the BwoinkSystem itself.

For example, you aren't updating the _activeConversations set that OnBwoinkTextMessage sends, limiting rates, and you trust that the formatted string is valid rather than applying the formatting in the BwoinkSystem.

Your use case is different from BwoinkSystem.OnBwoinkTextMessage, but it isn't too far off.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I may overseen that _activeConversations, i might check on how to add it in...
  2. Ratelimiting is good but at another point, Only admins can use this (on discord), so when an admin really spamms in an ahelp, you should really rethink if that admin is a good one... It takes a bit to write an message (without using copy-paste) but it takes a small bit to actually send a message (See showcase video a bit later in this PR)
  3. The String is validated on the bot side, but could also add in some kind of validating server sided? (Unsure yet on how to actually validate it.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the point is still valid - why couldn't the function look something like this:

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

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

        // Create our arguments
        var message = new SharedBwoinkSystem.BwoinkTextMessage(player.UserId, SharedBwoinkSystem.SystemUserId, body.Text);

        // Send off our object via a function or event
        // _entityManager.EventBus.RaiseLocalEvent(EntityUid.Invalid, message, true);
        var bwoinkSystem = _entitySystemManager.GetEntitySystem<BwoinkSystem>();
        bwoinkSystem.SendBwoinkTextMessageFromDiscord(message, ...)

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

Whatever function this ends up calling (either through an event or through a direct call) should go through the same logic that the other bwoinks go through - text escaping, admin notifications, all of it. Ideally, both this and OnBwoinkTextMessage call one function and aren't defined twice. If you want selective admin notification, great, but shouldn't that be available on both sides? If not, why? I think that the discord UI should, as much as possible, be a separate view on the same internal interface and not its own one with idiosyncracies to the in-game bwoink system.

No suggestion, I don't have anything working.

{
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 = $"(DC) {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; }
Comment on lines +8 to +9
Copy link
Contributor

@whatston3 whatston3 Nov 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this structure even extensible? If this isn't a Discord structure (if you have your own webhook), could you define this elsewhere? Surely you don't need most of these fields (edit: if this is another interface).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried out a custom webhook and in the end used a default discord one, Discord simply ignores extra fields given to it. The bot uses Most fields expect username and avatar_url are impossible to use on a Bot message and allowed_mentions is already done botsided.
Not sure if i can make this any better as its directly used inside BwoinkSystem

/// <summary>
/// The message to send in the webhook. Maximum of 2000 characters.
/// </summary>
Expand Down
Loading