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;
///
/// Represents the arguments for the event.
///
public record CommandReceivedEventArgs
{
///
/// The command that was received. This is the first word in the message, after the bot prefix.
///
public string Command { get; init; } = string.Empty;
///
/// The arguments to the command. This is everything after the command, split by spaces.
///
public string[] Arguments { get; init; } = Array.Empty();
///
/// 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.
///
public SocketMessage Message { get; init; } = default!;
}
///
/// Handles the connection to Discord and provides methods to interact with it.
///
public sealed class DiscordLink : IPostInjectInit
{
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly IConfigurationManager _configuration = default!;
///
/// The Discord client. This is null if the bot is not connected.
///
///
/// This should not be used directly outside of DiscordLink. So please do not make it public. Use the methods in this class instead.
///
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!;
///
/// If the bot is currently connected to Discord.
///
public bool IsConnected => _client != null;
#region Events
///
/// Event that is raised when a command is received from Discord.
///
public event Action? OnCommandReceived;
///
/// Event that is raised when a message is received from Discord. This is raised for every message, including commands.
///
public event Action? OnMessageReceived;
public void RegisterCommandCallback(Action 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
///
/// Sends a message to a Discord channel with the specified ID. Without any mentions.
///
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
}