ソースを参照

Implements Discord OOC Relay (#209)

Implements OOC relay to Discord, from here: https://github.com/space-wizards/space-station-14/pull/33840

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Taislin 6 ヶ月 前
コミット
18711822fa

+ 17 - 11
Content.Server/Chat/Managers/ChatManager.cs

@@ -4,7 +4,7 @@
 using Content.Server.Administration.Logs;
 using Content.Server.Administration.Managers;
 using Content.Server.Administration.Systems;
-using Content.Server.MoMMI;
+using Content.Server.Discord.DiscordLink;
 using Content.Server.Players.RateLimiting;
 using Content.Server.Preferences.Managers;
 using Content.Shared.Administration;
@@ -36,7 +36,8 @@ internal sealed partial class ChatManager : IChatManager
 
     [Dependency] private readonly IReplayRecordingManager _replay = default!;
     [Dependency] private readonly IServerNetManager _netManager = default!;
-    [Dependency] private readonly IMoMMILink _mommiLink = default!;
+
+    [Dependency] private readonly DiscordChatLink _discordLink = default!;
     [Dependency] private readonly IAdminManager _adminManager = default!;
     [Dependency] private readonly IAdminLogManager _adminLogger = default!;
     [Dependency] private readonly IServerPreferencesManager _preferencesManager = default!;
@@ -82,10 +83,10 @@ private void OnAdminOocEnabledChanged(bool val)
         DispatchServerAnnouncement(Loc.GetString(val ? "chat-manager-admin-ooc-chat-enabled-message" : "chat-manager-admin-ooc-chat-disabled-message"));
     }
 
-        public void DeleteMessagesBy(NetUserId uid)
-        {
-            if (!_players.TryGetValue(uid, out var user))
-                return;
+    public void DeleteMessagesBy(NetUserId uid)
+    {
+        if (!_players.TryGetValue(uid, out var user))
+            return;
 
         var msg = new MsgDeleteChatMessagesBy { Key = user.Key, Entities = user.Entities };
         _netManager.ServerSendToAll(msg);
@@ -192,7 +193,12 @@ public void SendHookOOC(string sender, string message)
         ChatMessageToAll(ChatChannel.OOC, message, wrappedMessage, source: EntityUid.Invalid, hideChat: false, recordReplay: true);
         _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Hook OOC from {sender}: {message}");
     }
-
+    public void SendHookAdmin(string sender, string message)
+    {
+        var wrappedMessage = Loc.GetString("chat-manager-send-hook-admin-wrap-message", ("senderName", sender), ("message", FormattedMessage.EscapeText(message)));
+        ChatMessageToAll(ChatChannel.AdminChat, message, wrappedMessage, source: EntityUid.Invalid, hideChat: false, recordReplay: false);
+        _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Hook admin from {sender}: {message}");
+    }
     #endregion
 
     #region Public OOC Chat API
@@ -245,20 +251,20 @@ private void SendOOC(ICommonSession player, string message)
         }
 
         Color? colorOverride = null;
-        var wrappedMessage = Loc.GetString("chat-manager-send-ooc-wrap-message", ("playerName",player.Name), ("message", FormattedMessage.EscapeText(message)));
+        var wrappedMessage = Loc.GetString("chat-manager-send-ooc-wrap-message", ("playerName", player.Name), ("message", FormattedMessage.EscapeText(message)));
         if (_adminManager.HasAdminFlag(player, AdminFlags.Admin))
         {
             var prefs = _preferencesManager.GetPreferences(player.UserId);
             colorOverride = prefs.AdminOOCColor;
         }
-        if (  _netConfigManager.GetClientCVar(player.Channel, CCVars.ShowOocPatronColor) && player.Channel.UserData.PatronTier is { } patron && PatronOocColors.TryGetValue(patron, out var patronColor))
+        if (_netConfigManager.GetClientCVar(player.Channel, CCVars.ShowOocPatronColor) && player.Channel.UserData.PatronTier is { } patron && PatronOocColors.TryGetValue(patron, out var patronColor))
         {
-            wrappedMessage = Loc.GetString("chat-manager-send-ooc-patron-wrap-message", ("patronColor", patronColor),("playerName", player.Name), ("message", FormattedMessage.EscapeText(message)));
+            wrappedMessage = Loc.GetString("chat-manager-send-ooc-patron-wrap-message", ("patronColor", patronColor), ("playerName", player.Name), ("message", FormattedMessage.EscapeText(message)));
         }
 
         //TODO: player.Name color, this will need to change the structure of the MsgChatMessage
         ChatMessageToAll(ChatChannel.OOC, message, wrappedMessage, EntityUid.Invalid, hideChat: false, recordReplay: true, colorOverride: colorOverride, author: player.UserId);
-        _mommiLink.SendOOCMessage(player.Name, message.Replace("@", "\\@").Replace("<", "\\<").Replace("/", "\\/")); // @ and < are both problematic for discord due to pinging. / is sanitized solely to kneecap links to murder embeds via blunt force
+        _discordLink.SendMessage(message, player.Name, ChatChannel.OOC);
         _adminLogger.Add(LogType.Chat, LogImpact.Low, $"OOC from {player:Player}: {message}");
     }
 

+ 1 - 0
Content.Server/Chat/Managers/IChatManager.cs

@@ -21,6 +21,7 @@ public interface IChatManager : ISharedChatManager
         void TrySendOOCMessage(ICommonSession player, string message, OOCChatType type);
 
         void SendHookOOC(string sender, string message);
+        void SendHookAdmin(string sender, string message);
         void SendAdminAnnouncement(string message, AdminFlags? flagBlacklist = null, AdminFlags? flagWhitelist = null);
         void SendAdminAnnouncementMessage(ICommonSession player, string message, bool suppressLog = true);
 

+ 1 - 0
Content.Server/Content.Server.csproj

@@ -14,6 +14,7 @@
     <ServerGarbageCollection>true</ServerGarbageCollection>
   </PropertyGroup>
   <ItemGroup>
+    <PackageReference Include="Discord.Net" />
     <PackageReference Include="JetBrains.Annotations" PrivateAssets="All" />
   </ItemGroup>
   <ItemGroup>

+ 94 - 0
Content.Server/Discord/DiscordLink/DiscordChatILink.cs

@@ -0,0 +1,94 @@
+using System.Threading.Tasks;
+using Content.Server.Chat.Managers;
+using Content.Shared.CCVar;
+using Content.Shared.Chat;
+using Discord.WebSocket;
+using Robust.Shared.Asynchronous;
+using Robust.Shared.Configuration;
+
+namespace Content.Server.Discord.DiscordLink;
+
+public sealed class DiscordChatLink
+{
+    [Dependency] private DiscordLink _discordLink = default!;
+    [Dependency] private IConfigurationManager _configurationManager = default!;
+    [Dependency] private readonly IChatManager _chatManager = default!;
+    [Dependency] private readonly ITaskManager _taskManager = default!;
+
+    private ulong? _oocChannelId;
+    private ulong? _adminChannelId;
+
+    public void Initialize()
+    {
+        _discordLink.OnMessageReceived += OnMessageReceived;
+
+        _configurationManager.OnValueChanged(CCVars.OocDiscordChannelId, OnOocChannelIdChanged, true);
+        _configurationManager.OnValueChanged(CCVars.AdminChatDiscordChannelId, OnAdminChannelIdChanged, true);
+    }
+
+    public void Shutdown()
+    {
+        _discordLink.OnMessageReceived -= OnMessageReceived;
+
+        _configurationManager.UnsubValueChanged(CCVars.OocDiscordChannelId, OnOocChannelIdChanged);
+        _configurationManager.UnsubValueChanged(CCVars.AdminChatDiscordChannelId, OnAdminChannelIdChanged);
+    }
+
+    private void OnOocChannelIdChanged(string channelId)
+    {
+        if (string.IsNullOrEmpty(channelId))
+        {
+            _oocChannelId = null;
+            return;
+        }
+
+        _oocChannelId = ulong.Parse(channelId);
+    }
+
+    private void OnAdminChannelIdChanged(string channelId)
+    {
+        if (string.IsNullOrEmpty(channelId))
+        {
+            _adminChannelId = null;
+            return;
+        }
+
+        _adminChannelId = ulong.Parse(channelId);
+    }
+
+    private void OnMessageReceived(SocketMessage message)
+    {
+        if (message.Author.IsBot)
+            return;
+
+        if (message.Channel.Id == _oocChannelId)
+        {
+            _taskManager.RunOnMainThread(() => _chatManager.SendHookOOC(message.Author.Username, message.Content));
+        }
+        else if (message.Channel.Id == _adminChannelId)
+        {
+            _taskManager.RunOnMainThread(() => _chatManager.SendHookAdmin(message.Author.Username, message.Content));
+        }
+    }
+
+    public async Task SendMessage(string message, string author, ChatChannel channel)
+    {
+        var channelId = channel switch
+        {
+            ChatChannel.OOC => _oocChannelId,
+            ChatChannel.AdminChat => _adminChannelId,
+            _ => throw new InvalidOperationException("Channel not linked to Discord."),
+        };
+
+        if (channelId == null)
+        {
+            // Configuration not set up. Ignore.
+            return;
+        }
+
+        // @ and < are both problematic for discord due to pinging. / is sanitized solely to kneecap links to murder embeds via blunt force
+        message = message.Replace("@", "\\@").Replace("<", "\\<").Replace("/", "\\/");
+
+        await _discordLink.SendMessageAsync(channelId.Value, $"**{channel.GetString()}**: `{author}`: {message}");
+    }
+}

+ 257 - 0
Content.Server/Discord/DiscordLink/DiscordLink.cs

@@ -0,0 +1,257 @@
+using System.Linq;
+using System.Threading.Tasks;
+using Content.Shared.CCVar;
+using Discord;
+using Discord.WebSocket;
+using Robust.Shared.Configuration;
+using Robust.Shared.Reflection;
+using Robust.Shared.Utility;
+using LogMessage = Discord.LogMessage;
+
+namespace Content.Server.Discord.DiscordLink;
+
+/// <summary>
+/// Represents the arguments for the <see cref="DiscordLink.OnCommandReceived"/> event.
+/// </summary>
+public record CommandReceivedEventArgs
+{
+    /// <summary>
+    /// The command that was received. This is the first word in the message, after the bot prefix.
+    /// </summary>
+    public string Command { get; init; } = string.Empty;
+    /// <summary>
+    /// The arguments to the command. This is everything after the command, split by spaces.
+    /// </summary>
+    public string[] Arguments { get; init; } = Array.Empty<string>();
+    /// <summary>
+    /// Information about the message that the command was received from. This includes the message content, author, etc.
+    /// Use this to reply to the message, delete it, etc.
+    /// </summary>
+    public SocketMessage Message { get; init; } = default!;
+}
+
+/// <summary>
+/// Handles the connection to Discord and provides methods to interact with it.
+/// </summary>
+public sealed class DiscordLink : IPostInjectInit
+{
+    [Dependency] private readonly ILogManager _logManager = default!;
+    [Dependency] private readonly IConfigurationManager _configuration = default!;
+
+    /// <summary>
+    ///    The Discord client. This is null if the bot is not connected.
+    /// </summary>
+    /// <remarks>
+    ///     This should not be used directly outside of DiscordLink. So please do not make it public. Use the methods in this class instead.
+    /// </remarks>
+    private DiscordSocketClient? _client;
+    private ISawmill _sawmill = default!;
+    private ISawmill _sawmillNet = default!;
+
+    private ulong _guildId;
+    private string _botToken = string.Empty;
+
+    private bool _registeredLinks = false;
+
+    public string BotPrefix = default!;
+    /// <summary>
+    /// If the bot is currently connected to Discord.
+    /// </summary>
+    public bool IsConnected => _client != null;
+
+    #region Events
+
+    /// <summary>
+    ///     Event that is raised when a command is received from Discord.
+    /// </summary>
+    public event Action<CommandReceivedEventArgs>? OnCommandReceived;
+    /// <summary>
+    ///     Event that is raised when a message is received from Discord. This is raised for every message, including commands.
+    /// </summary>
+    public event Action<SocketMessage>? OnMessageReceived;
+
+    public void RegisterCommandCallback(Action<CommandReceivedEventArgs> callback, string command)
+    {
+        OnCommandReceived += args =>
+        {
+            if (args.Command == command)
+                callback(args);
+        };
+    }
+
+    #endregion
+
+    public void Initialize()
+    {
+        _configuration.OnValueChanged(CCVars.DiscordGuildId, OnGuildIdChanged, true);
+        _configuration.OnValueChanged(CCVars.DiscordPrefix, OnPrefixChanged, true);
+
+        if (_configuration.GetCVar(CCVars.DiscordToken) is not { } token || token == string.Empty)
+        {
+            _sawmill.Info("No Discord token specified, not connecting.");
+            return;
+        }
+
+        _client = new DiscordSocketClient(new DiscordSocketConfig()
+        {
+            GatewayIntents = GatewayIntents.All
+        });
+        _client.Log += Log;
+        _client.MessageReceived += OnCommandReceivedInternal;
+        _client.MessageReceived += OnMessageReceivedInternal;
+
+        // If the Guild ID is empty OR the prefix is empty, we don't want to connect to Discord.
+        if (_guildId == 0 || BotPrefix == string.Empty)
+        {
+            // This is a warning, not info, because it's a configuration error.
+            // It is valid to not have a Discord token set which is why the above check is an info.
+            // But if you have a token set, you should also have a guild ID and prefix set.
+            _sawmill.Warning("No Discord guild ID or prefix specified, not connecting.");
+            _client = null;
+            return;
+        }
+
+        _botToken = token;
+        // Since you cannot change the token while the server is running / the DiscordLink is initialized,
+        // we can just set the token without updating it every time the cvar changes.
+
+        _client.Ready += () =>
+        {
+            _sawmill.Info("Discord client ready.");
+            return Task.CompletedTask;
+        };
+
+        Task.Run(async () =>
+        {
+            try
+            {
+                await LoginAsync(token);
+            }
+            catch (Exception e)
+            {
+                _sawmill.Error("Failed to connect to Discord!", e);
+            }
+        });
+    }
+
+    public async Task Shutdown()
+    {
+        if (_client != null)
+        {
+            _sawmill.Info("Disconnecting from Discord.");
+
+            // Unsubscribe from the events.
+            _client.MessageReceived -= OnCommandReceivedInternal;
+            _client.MessageReceived -= OnMessageReceivedInternal;
+
+            await _client.LogoutAsync();
+            await _client.StopAsync();
+            await _client.DisposeAsync();
+            _client = null;
+        }
+
+        _configuration.UnsubValueChanged(CCVars.DiscordGuildId, OnGuildIdChanged);
+        _configuration.UnsubValueChanged(CCVars.DiscordPrefix, OnPrefixChanged);
+    }
+
+    void IPostInjectInit.PostInject()
+    {
+        _sawmill = _logManager.GetSawmill("discord.link");
+        _sawmillNet = _logManager.GetSawmill("discord.link.log");
+    }
+
+    private void OnGuildIdChanged(string guildId)
+    {
+        _guildId = ulong.TryParse(guildId, out var id) ? id : 0;
+    }
+
+    private void OnPrefixChanged(string prefix)
+    {
+        BotPrefix = prefix;
+    }
+
+    private async Task LoginAsync(string token)
+    {
+        DebugTools.Assert(_client != null);
+        DebugTools.Assert(_client.LoginState == LoginState.LoggedOut);
+
+        await _client.LoginAsync(TokenType.Bot, token);
+        await _client.StartAsync();
+
+
+        _sawmill.Info("Connected to Discord.");
+    }
+
+    private string FormatLog(LogMessage msg)
+    {
+        return msg.Exception is null
+            ? $"{msg.Source}: {msg.Message}"
+            : $"{msg.Source}: {msg.Message}\n{msg.Exception}";
+    }
+
+    private Task Log(LogMessage msg)
+    {
+        var logLevel = msg.Severity switch
+        {
+            LogSeverity.Critical => LogLevel.Fatal,
+            LogSeverity.Error => LogLevel.Error,
+            LogSeverity.Warning => LogLevel.Warning,
+            _ => LogLevel.Debug
+        };
+
+        _sawmillNet.Log(logLevel, FormatLog(msg));
+        return Task.CompletedTask;
+    }
+
+    private Task OnCommandReceivedInternal(SocketMessage message)
+    {
+        var content = message.Content;
+        // If the message is too short to be a command, or doesn't start with the bot prefix, ignore it.
+        if (content.Length <= BotPrefix.Length || !content.StartsWith(BotPrefix))
+            return Task.CompletedTask;
+
+        // Split the message into the command and the arguments.
+        var split = content[BotPrefix.Length..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
+        if (split.Length == 0)
+            return Task.CompletedTask;  // No command.
+
+        // Raise the event!
+        OnCommandReceived?.Invoke(new CommandReceivedEventArgs
+        {
+            Command = split[0],
+            Arguments = split[1..],
+            Message = message,
+        });
+        return Task.CompletedTask;
+    }
+
+    private Task OnMessageReceivedInternal(SocketMessage message)
+    {
+        OnMessageReceived?.Invoke(message);
+        return Task.CompletedTask;
+    }
+
+    #region Proxy methods
+
+    /// <summary>
+    /// Sends a message to a Discord channel with the specified ID. Without any mentions.
+    /// </summary>
+    public async Task SendMessageAsync(ulong channelId, string message)
+    {
+        if (_client == null)
+        {
+            return;
+        }
+
+        var channel = _client.GetChannel(channelId) as IMessageChannel;
+        if (channel == null)
+        {
+            _sawmill.Error("Tried to send a message to Discord but the channel was not found.");
+            return;
+        }
+
+        await channel.SendMessageAsync(message, allowedMentions: AllowedMentions.None);
+    }
+
+    #endregion
+}

+ 11 - 6
Content.Server/Entry/EntryPoint.cs

@@ -6,6 +6,7 @@
 using Content.Server.Chat.Managers;
 using Content.Server.Connection;
 using Content.Server.Database;
+using Content.Server.Discord.DiscordLink;
 using Content.Server.EUI;
 using Content.Server.GameTicking;
 using Content.Server.GhostKick;
@@ -78,7 +79,7 @@ public override void Init()
 
             foreach (var callback in TestingCallbacks)
             {
-                var cast = (ServerModuleTestingCallbacks) callback;
+                var cast = (ServerModuleTestingCallbacks)callback;
                 cast.ServerBeforeIoC?.Invoke();
             }
 
@@ -146,6 +147,8 @@ public override void PostInit()
                 IoCManager.Resolve<IAdminManager>().Initialize();
                 IoCManager.Resolve<IAfkManager>().Initialize();
                 IoCManager.Resolve<RulesManager>().Initialize();
+                IoCManager.Resolve<DiscordLink>().Initialize();
+                IoCManager.Resolve<DiscordChatLink>().Initialize();
                 _euiManager.Initialize();
 
                 IoCManager.Resolve<IGameMapManager>().Initialize();
@@ -164,11 +167,11 @@ public override void Update(ModUpdateLevel level, FrameEventArgs frameEventArgs)
             switch (level)
             {
                 case ModUpdateLevel.PostEngine:
-                {
-                    _euiManager.SendUpdates();
-                    _voteManager.Update();
-                    break;
-                }
+                    {
+                        _euiManager.SendUpdates();
+                        _voteManager.Update();
+                        break;
+                    }
 
                 case ModUpdateLevel.FramePostEngine:
                     _updateManager.Update();
@@ -184,6 +187,8 @@ protected override void Dispose(bool disposing)
             _playTimeTracking?.Shutdown();
             _dbManager?.Shutdown();
             IoCManager.Resolve<ServerApi>().Shutdown();
+            IoCManager.Resolve<DiscordLink>().Shutdown();
+            IoCManager.Resolve<DiscordChatLink>().Shutdown();
         }
 
         private static void LoadConfigPresets(IConfigurationManager cfg, IResourceManager res, ISawmill sawmill)

+ 4 - 2
Content.Server/IoC/ServerContentIoC.cs

@@ -13,7 +13,7 @@
 using Content.Server.Info;
 using Content.Server.Mapping;
 using Content.Server.Maps;
-using Content.Server.MoMMI;
+using Content.Server.Discord.DiscordLink;
 using Content.Server.NodeContainer.NodeGroups;
 using Content.Server.Players.JobWhitelist;
 using Content.Server.Players.PlayTimeTracking;
@@ -39,7 +39,6 @@ public static void Register()
             IoCManager.Register<IChatManager, ChatManager>();
             IoCManager.Register<ISharedChatManager, ChatManager>();
             IoCManager.Register<IChatSanitizationManager, ChatSanitizationManager>();
-            IoCManager.Register<IMoMMILink, MoMMILink>();
             IoCManager.Register<IServerPreferencesManager, ServerPreferencesManager>();
             IoCManager.Register<IServerDbManager, ServerDbManager>();
             IoCManager.Register<RecipeManager, RecipeManager>();
@@ -77,6 +76,9 @@ public static void Register()
             IoCManager.Register<ConnectionManager>();
             IoCManager.Register<MultiServerKickManager>();
             IoCManager.Register<CVarControlManager>();
+
+            IoCManager.Register<DiscordLink>();
+            IoCManager.Register<DiscordChatLink>();
         }
     }
 }

+ 0 - 7
Content.Server/MoMMI/IMoMMILink.cs

@@ -1,7 +0,0 @@
-namespace Content.Server.MoMMI
-{
-    public interface IMoMMILink
-    {
-        void SendOOCMessage(string sender, string message);
-    }
-}

+ 0 - 151
Content.Server/MoMMI/MoMMILink.cs

@@ -1,151 +0,0 @@
-using System.Net;
-using System.Net.Http;
-using System.Net.Http.Json;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using System.Threading.Tasks;
-using Content.Server.Chat.Managers;
-using Content.Shared.CCVar;
-using Robust.Server.ServerStatus;
-using Robust.Shared.Asynchronous;
-using Robust.Shared.Configuration;
-
-namespace Content.Server.MoMMI
-{
-    internal sealed class MoMMILink : IMoMMILink, IPostInjectInit
-    {
-        [Dependency] private readonly IConfigurationManager _configurationManager = default!;
-        [Dependency] private readonly IStatusHost _statusHost = default!;
-        [Dependency] private readonly IChatManager _chatManager = default!;
-        [Dependency] private readonly ITaskManager _taskManager = default!;
-
-        private readonly HttpClient _httpClient = new();
-
-        void IPostInjectInit.PostInject()
-        {
-            _statusHost.AddHandler(HandleChatPost);
-        }
-
-        public async void SendOOCMessage(string sender, string message)
-        {
-            var sentMessage = new MoMMIMessageOOC
-            {
-                Sender = sender,
-                Contents = message
-            };
-
-            await SendMessageInternal("ooc", sentMessage);
-        }
-
-        private async Task SendMessageInternal(string type, object messageObject)
-        {
-            var url = _configurationManager.GetCVar(CCVars.StatusMoMMIUrl);
-            var password = _configurationManager.GetCVar(CCVars.StatusMoMMIPassword);
-            if (string.IsNullOrWhiteSpace(url))
-            {
-                return;
-            }
-
-            if (string.IsNullOrWhiteSpace(password))
-            {
-                Logger.WarningS("mommi", "MoMMI URL specified but not password!");
-                return;
-            }
-
-            var sentMessage = new MoMMIMessageBase
-            {
-                Password = password,
-                Type = type,
-                Contents = messageObject
-            };
-
-            var request = await _httpClient.PostAsJsonAsync(url, sentMessage);
-
-            if (!request.IsSuccessStatusCode)
-            {
-                throw new Exception($"MoMMI returned bad status code: {request.StatusCode}");
-            }
-        }
-
-        private async Task<bool> HandleChatPost(IStatusHandlerContext context)
-        {
-            if (context.RequestMethod != HttpMethod.Post || context.Url.AbsolutePath != "/ooc")
-            {
-                return false;
-            }
-
-            var password = _configurationManager.GetCVar(CCVars.StatusMoMMIPassword);
-
-            if (string.IsNullOrEmpty(password))
-            {
-                await context.RespondErrorAsync(HttpStatusCode.Forbidden);
-                return true;
-            }
-
-            OOCPostMessage? message = null;
-            try
-            {
-                message = await context.RequestBodyJsonAsync<OOCPostMessage>();
-            }
-            catch (JsonException)
-            {
-                // message null so enters block down below.
-            }
-
-            if (message == null)
-            {
-                await context.RespondErrorAsync(HttpStatusCode.BadRequest);
-                return true;
-            }
-
-            if (message.Password != password)
-            {
-                await context.RespondErrorAsync(HttpStatusCode.Forbidden);
-                return true;
-            }
-
-            var sender = message.Sender;
-            var contents = message.Contents.ReplaceLineEndings(" ");
-
-            _taskManager.RunOnMainThread(() => _chatManager.SendHookOOC(sender, contents));
-
-            await context.RespondAsync("Success", HttpStatusCode.OK);
-            return true;
-        }
-
-        private sealed class MoMMIMessageBase
-        {
-            [JsonInclude] [JsonPropertyName("password")]
-            public string Password = null!;
-
-            [JsonInclude] [JsonPropertyName("type")]
-            public string Type = null!;
-
-            [JsonInclude] [JsonPropertyName("contents")]
-            public object Contents = null!;
-        }
-
-        private sealed class MoMMIMessageOOC
-        {
-            [JsonInclude] [JsonPropertyName("sender")]
-            public string Sender = null!;
-
-            [JsonInclude] [JsonPropertyName("contents")]
-            public string Contents = null!;
-        }
-
-        private sealed class OOCPostMessage
-        {
-#pragma warning disable CS0649
-            [JsonInclude] [JsonPropertyName("password")]
-            public string Password = null!;
-
-            [JsonInclude] [JsonPropertyName("sender")]
-            public string Sender = null!;
-
-            [JsonInclude] [JsonPropertyName("contents")]
-            public string Contents = null!;
-#pragma warning restore CS0649
-        }
-    }
-}

+ 12 - 0
Content.Shared/CCVar/CCCVars.Chat.Admin.cs

@@ -0,0 +1,12 @@
+using Robust.Shared.Configuration;
+
+namespace Content.Shared.CCVar;
+
+public sealed partial class CCVars
+{
+    /// <summary>
+    ///     The discord channel ID to send admin chat messages to (also receive them). This requires the Discord Integration to be enabled and configured.
+    /// </summary>
+    public static readonly CVarDef<string> AdminChatDiscordChannelId =
+        CVarDef.Create("admin.chat_discord_channel_id", string.Empty, CVar.SERVERONLY);
+}

+ 5 - 0
Content.Shared/CCVar/CCVars.Chat.Ooc.cs

@@ -24,4 +24,9 @@ public sealed partial class CCVars
 
     public static readonly CVarDef<bool> ShowOocPatronColor =
         CVarDef.Create("ooc.show_ooc_patron_color", true, CVar.ARCHIVE | CVar.REPLICATED | CVar.CLIENT);
+    /// <summary>
+    ///     The discord channel ID to send OOC messages to (also recieve them). This requires the Discord Integration to be enabled and configured.
+    /// </summary>
+    public static readonly CVarDef<string> OocDiscordChannelId =
+        CVarDef.Create("ooc.discord_channel_id", string.Empty, CVar.SERVERONLY);
 }

+ 19 - 0
Content.Shared/CCVar/CCVars.Discord.cs

@@ -58,8 +58,27 @@ public sealed partial class CCVars
     /// </summary>
     public static readonly CVarDef<string> DiscordRoundEndRoleWebhook =
         CVarDef.Create("discord.round_end_role", string.Empty, CVar.SERVERONLY);
+    /// <summary>
+    ///     The token used to authenticate with Discord. For the Bot to function set: discord.token, discord.guild_id, and discord.prefix.
+    ///     If this is empty, the bot will not connect.
+    /// </summary>
+    public static readonly CVarDef<string> DiscordToken =
+        CVarDef.Create("discord.token", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL);
 
     /// <summary>
+    ///     The Discord guild ID to use for commands as well as for several other features.
+    ///     If this is empty, the bot will not connect.
+    /// </summary>
+    public static readonly CVarDef<string> DiscordGuildId =
+        CVarDef.Create("discord.guild_id", string.Empty, CVar.SERVERONLY);
+
+    /// <summary>
+    ///     Prefix used for commands for the Discord bot.
+    ///     If this is empty, the bot will not connect.
+    /// </summary>
+    public static readonly CVarDef<string> DiscordPrefix =
+        CVarDef.Create("discord.prefix", "!", CVar.SERVERONLY);
+    /// <summary>
     ///     URL of the Discord webhook which will relay watchlist connection notifications. If left empty, disables the webhook.
     /// </summary>
     public static readonly CVarDef<string> DiscordWatchlistConnectionWebhook =

+ 0 - 13
Content.Shared/CCVar/CCVars.Status.cs

@@ -1,13 +0,0 @@
-using Robust.Shared.Configuration;
-
-namespace Content.Shared.CCVar;
-
-public sealed partial class CCVars
-{
-    public static readonly CVarDef<string> StatusMoMMIUrl =
-        CVarDef.Create("status.mommiurl", "", CVar.SERVERONLY);
-
-    public static readonly CVarDef<string> StatusMoMMIPassword =
-        CVarDef.Create("status.mommipassword", "", CVar.SERVERONLY | CVar.CONFIDENTIAL);
-
-}

+ 19 - 0
Content.Shared/Chat/ChatChannel.cs

@@ -92,4 +92,23 @@ public enum ChatChannel : ushort
 
         AdminRelated = Admin | AdminAlert | AdminChat,
     }
+    /// <summary>
+    /// Contains extension methods for <see cref="ChatChannel"/>
+    /// </summary>
+    public static class ChatChannelExt
+    {
+        /// <summary>
+        /// Gets a string representation of a chat channel.
+        /// </summary>
+        /// <exception cref="ArgumentOutOfRangeException">Thrown when this channel does not have a string representation set.</exception>
+        public static string GetString(this ChatChannel channel)
+        {
+            return channel switch
+            {
+                ChatChannel.OOC => "OOC",
+                ChatChannel.Admin => "ADMIN",
+                _ => throw new ArgumentOutOfRangeException(nameof(channel), channel, null)
+            };
+        }
+    }
 }

+ 2 - 1
Directory.Packages.props

@@ -7,6 +7,7 @@
     <PackageVersion Remove="Npgsql.EntityFrameworkCore.PostgreSQL" />
     <PackageVersion Remove="Microsoft.EntityFrameworkCore.Design" />
     <PackageVersion Include="CsvHelper" Version="33.0.1" />
+    <PackageVersion Include="Discord.Net" Version="3.16.0" />
     <PackageVersion Include="ImGui.NET" Version="1.87.3" />
     <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.1">
       <PrivateAssets>all</PrivateAssets>
@@ -18,4 +19,4 @@
     <PackageVersion Include="Veldrid" Version="4.8.0" />
     <PackageVersion Include="Veldrid.SPIRV" Version="1.0.15" />
   </ItemGroup>
-</Project>
+</Project>

+ 1 - 0
Resources/Locale/en-US/chat/managers/chat-manager.ftl

@@ -37,6 +37,7 @@ chat-manager-entity-me-wrap-message = [italic]{ PROPER($entity) ->
 chat-manager-entity-looc-wrap-message = LOOC: [bold]{$entityName}:[/bold] {$message}
 chat-manager-send-ooc-wrap-message = OOC: [bold]{$playerName}:[/bold] {$message}
 chat-manager-send-ooc-patron-wrap-message = OOC: [bold][color={$patronColor}]{$playerName}[/color]:[/bold] {$message}
+chat-manager-send-hook-admin-wrap-message = ADMIN: [bold](D){$senderName}:[/bold] {$message}
 
 chat-manager-send-dead-chat-wrap-message = {$deadChannelName}: [bold][BubbleHeader]{$playerName}[/BubbleHeader]:[/bold] [BubbleContent]{$message}[/BubbleContent]
 chat-manager-send-admin-dead-chat-wrap-message = {$adminChannelName}: [bold]([BubbleHeader]{$userName}[/BubbleHeader]):[/bold] [BubbleContent]{$message}[/BubbleContent]