DiscordLink.cs 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. using System.Linq;
  2. using System.Threading.Tasks;
  3. using Content.Shared.CCVar;
  4. using Discord;
  5. using Discord.WebSocket;
  6. using Robust.Shared.Configuration;
  7. using Robust.Shared.Reflection;
  8. using Robust.Shared.Utility;
  9. using LogMessage = Discord.LogMessage;
  10. namespace Content.Server.Discord.DiscordLink;
  11. /// <summary>
  12. /// Represents the arguments for the <see cref="DiscordLink.OnCommandReceived"/> event.
  13. /// </summary>
  14. public record CommandReceivedEventArgs
  15. {
  16. /// <summary>
  17. /// The command that was received. This is the first word in the message, after the bot prefix.
  18. /// </summary>
  19. public string Command { get; init; } = string.Empty;
  20. /// <summary>
  21. /// The arguments to the command. This is everything after the command, split by spaces.
  22. /// </summary>
  23. public string[] Arguments { get; init; } = Array.Empty<string>();
  24. /// <summary>
  25. /// Information about the message that the command was received from. This includes the message content, author, etc.
  26. /// Use this to reply to the message, delete it, etc.
  27. /// </summary>
  28. public SocketMessage Message { get; init; } = default!;
  29. }
  30. /// <summary>
  31. /// Handles the connection to Discord and provides methods to interact with it.
  32. /// </summary>
  33. public sealed class DiscordLink : IPostInjectInit
  34. {
  35. [Dependency] private readonly ILogManager _logManager = default!;
  36. [Dependency] private readonly IConfigurationManager _configuration = default!;
  37. /// <summary>
  38. /// The Discord client. This is null if the bot is not connected.
  39. /// </summary>
  40. /// <remarks>
  41. /// This should not be used directly outside of DiscordLink. So please do not make it public. Use the methods in this class instead.
  42. /// </remarks>
  43. private DiscordSocketClient? _client;
  44. private ISawmill _sawmill = default!;
  45. private ISawmill _sawmillNet = default!;
  46. private ulong _guildId;
  47. private string _botToken = string.Empty;
  48. private bool _registeredLinks = false;
  49. public string BotPrefix = default!;
  50. /// <summary>
  51. /// If the bot is currently connected to Discord.
  52. /// </summary>
  53. public bool IsConnected => _client != null;
  54. #region Events
  55. /// <summary>
  56. /// Event that is raised when a command is received from Discord.
  57. /// </summary>
  58. public event Action<CommandReceivedEventArgs>? OnCommandReceived;
  59. /// <summary>
  60. /// Event that is raised when a message is received from Discord. This is raised for every message, including commands.
  61. /// </summary>
  62. public event Action<SocketMessage>? OnMessageReceived;
  63. public void RegisterCommandCallback(Action<CommandReceivedEventArgs> callback, string command)
  64. {
  65. OnCommandReceived += args =>
  66. {
  67. if (args.Command == command)
  68. callback(args);
  69. };
  70. }
  71. #endregion
  72. public void Initialize()
  73. {
  74. _configuration.OnValueChanged(CCVars.DiscordGuildId, OnGuildIdChanged, true);
  75. _configuration.OnValueChanged(CCVars.DiscordPrefix, OnPrefixChanged, true);
  76. if (_configuration.GetCVar(CCVars.DiscordToken) is not { } token || token == string.Empty)
  77. {
  78. _sawmill.Info("No Discord token specified, not connecting.");
  79. return;
  80. }
  81. _client = new DiscordSocketClient(new DiscordSocketConfig()
  82. {
  83. GatewayIntents = GatewayIntents.All
  84. });
  85. _client.Log += Log;
  86. _client.MessageReceived += OnCommandReceivedInternal;
  87. _client.MessageReceived += OnMessageReceivedInternal;
  88. // If the Guild ID is empty OR the prefix is empty, we don't want to connect to Discord.
  89. if (_guildId == 0 || BotPrefix == string.Empty)
  90. {
  91. // This is a warning, not info, because it's a configuration error.
  92. // It is valid to not have a Discord token set which is why the above check is an info.
  93. // But if you have a token set, you should also have a guild ID and prefix set.
  94. _sawmill.Warning("No Discord guild ID or prefix specified, not connecting.");
  95. _client = null;
  96. return;
  97. }
  98. _botToken = token;
  99. // Since you cannot change the token while the server is running / the DiscordLink is initialized,
  100. // we can just set the token without updating it every time the cvar changes.
  101. _client.Ready += () =>
  102. {
  103. _sawmill.Info("Discord client ready.");
  104. return Task.CompletedTask;
  105. };
  106. Task.Run(async () =>
  107. {
  108. try
  109. {
  110. await LoginAsync(token);
  111. }
  112. catch (Exception e)
  113. {
  114. _sawmill.Error("Failed to connect to Discord!", e);
  115. }
  116. });
  117. }
  118. public async Task Shutdown()
  119. {
  120. if (_client != null)
  121. {
  122. _sawmill.Info("Disconnecting from Discord.");
  123. // Unsubscribe from the events.
  124. _client.MessageReceived -= OnCommandReceivedInternal;
  125. _client.MessageReceived -= OnMessageReceivedInternal;
  126. await _client.LogoutAsync();
  127. await _client.StopAsync();
  128. await _client.DisposeAsync();
  129. _client = null;
  130. }
  131. _configuration.UnsubValueChanged(CCVars.DiscordGuildId, OnGuildIdChanged);
  132. _configuration.UnsubValueChanged(CCVars.DiscordPrefix, OnPrefixChanged);
  133. }
  134. void IPostInjectInit.PostInject()
  135. {
  136. _sawmill = _logManager.GetSawmill("discord.link");
  137. _sawmillNet = _logManager.GetSawmill("discord.link.log");
  138. }
  139. private void OnGuildIdChanged(string guildId)
  140. {
  141. _guildId = ulong.TryParse(guildId, out var id) ? id : 0;
  142. }
  143. private void OnPrefixChanged(string prefix)
  144. {
  145. BotPrefix = prefix;
  146. }
  147. private async Task LoginAsync(string token)
  148. {
  149. DebugTools.Assert(_client != null);
  150. DebugTools.Assert(_client.LoginState == LoginState.LoggedOut);
  151. await _client.LoginAsync(TokenType.Bot, token);
  152. await _client.StartAsync();
  153. _sawmill.Info("Connected to Discord.");
  154. }
  155. private string FormatLog(LogMessage msg)
  156. {
  157. return msg.Exception is null
  158. ? $"{msg.Source}: {msg.Message}"
  159. : $"{msg.Source}: {msg.Message}\n{msg.Exception}";
  160. }
  161. private Task Log(LogMessage msg)
  162. {
  163. var logLevel = msg.Severity switch
  164. {
  165. LogSeverity.Critical => LogLevel.Fatal,
  166. LogSeverity.Error => LogLevel.Error,
  167. LogSeverity.Warning => LogLevel.Warning,
  168. _ => LogLevel.Debug
  169. };
  170. _sawmillNet.Log(logLevel, FormatLog(msg));
  171. return Task.CompletedTask;
  172. }
  173. private Task OnCommandReceivedInternal(SocketMessage message)
  174. {
  175. var content = message.Content;
  176. // If the message is too short to be a command, or doesn't start with the bot prefix, ignore it.
  177. if (content.Length <= BotPrefix.Length || !content.StartsWith(BotPrefix))
  178. return Task.CompletedTask;
  179. // Split the message into the command and the arguments.
  180. var split = content[BotPrefix.Length..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
  181. if (split.Length == 0)
  182. return Task.CompletedTask; // No command.
  183. // Raise the event!
  184. OnCommandReceived?.Invoke(new CommandReceivedEventArgs
  185. {
  186. Command = split[0],
  187. Arguments = split[1..],
  188. Message = message,
  189. });
  190. return Task.CompletedTask;
  191. }
  192. private Task OnMessageReceivedInternal(SocketMessage message)
  193. {
  194. OnMessageReceived?.Invoke(message);
  195. return Task.CompletedTask;
  196. }
  197. #region Proxy methods
  198. /// <summary>
  199. /// Sends a message to a Discord channel with the specified ID. Without any mentions.
  200. /// </summary>
  201. public async Task SendMessageAsync(ulong channelId, string message)
  202. {
  203. if (_client == null)
  204. {
  205. return;
  206. }
  207. var channel = _client.GetChannel(channelId) as IMessageChannel;
  208. if (channel == null)
  209. {
  210. _sawmill.Error("Tried to send a message to Discord but the channel was not found.");
  211. return;
  212. }
  213. await channel.SendMessageAsync(message, allowedMentions: AllowedMentions.None);
  214. }
  215. #endregion
  216. }