fish-station/Content.Server/Discord/DiscordWebhook.cs

285 lines
9.8 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.Shared._Sunrise.SunriseCCVars;
using Content.Shared.CCVar;
using Robust.Shared.Configuration;
namespace Content.Server.Discord;
public sealed class DiscordWebhook : IPostInjectInit, IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
[Dependency] private readonly ILogManager _log = default!;
[Dependency] private readonly IConfigurationManager _configuration = default!;
private const string BaseUrl = "https://discord.com/api/v10/webhooks";
private HttpClient _http = default!;
private ISawmill _sawmill = default!;
// Sunrise added start
private const char CustomHeadersSplitter = '|';
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30f);
// Sunrise added end
private string GetUrl(WebhookIdentifier identifier)
{
return $"{BaseUrl}/{identifier.Id}/{identifier.Token}";
}
/// <summary>
/// Gets the webhook data from the given webhook url.
/// </summary>
/// <param name="url">The url to get the data from.</param>
/// <returns>The webhook data returned from the url.</returns>
public async Task<WebhookData?> GetWebhook(string url)
{
try
{
return await _http.GetFromJsonAsync<WebhookData>(url);
}
catch (Exception e)
{
_sawmill.Error($"Error getting discord webhook data.\n{e}");
return null;
}
}
/// <summary>
/// Gets the webhook data from the given webhook url.
/// </summary>
/// <param name="url">The url to get the data from.</param>
/// <param name="onComplete">The delegate to invoke with the obtained data, if any.</param>
public async void GetWebhook(string url, Action<WebhookData> onComplete)
{
if (await GetWebhook(url) is { } data)
onComplete(data);
}
/// <summary>
/// Tries to get the webhook data from the given webhook url if it is not null or whitespace.
/// </summary>
/// <param name="url">The url to get the data from.</param>
/// <param name="onComplete">The delegate to invoke with the obtained data, if any.</param>
public async void TryGetWebhook(string url, Action<WebhookData> onComplete)
{
if (await GetWebhook(url) is { } data)
onComplete(data);
}
/// <summary>
/// Creates a new webhook message with the given identifier and payload.
/// </summary>
/// <param name="identifier">The identifier for the webhook url.</param>
/// <param name="payload">The payload to create the message from.</param>
/// <returns>The response from Discord's API.</returns>
public async Task<HttpResponseMessage> CreateMessage(WebhookIdentifier identifier, WebhookPayload payload)
{
var url = $"{GetUrl(identifier)}?wait=true";
var response = await _http.PostAsJsonAsync(url, payload, JsonOptions);
LogResponse(response, "Create");
return response;
}
/// <summary>
/// Deletes a webhook message with the given identifier and message id.
/// </summary>
/// <param name="identifier">The identifier for the webhook url.</param>
/// <param name="messageId">The message id to delete.</param>
/// <returns>The response from Discord's API.</returns>
public async Task<HttpResponseMessage> DeleteMessage(WebhookIdentifier identifier, ulong messageId)
{
var url = $"{GetUrl(identifier)}/messages/{messageId}";
var response = await _http.DeleteAsync(url);
LogResponse(response, "Delete");
return response;
}
/// <summary>
/// Creates a new webhook message with the given identifier, message id and payload.
/// </summary>
/// <param name="identifier">The identifier for the webhook url.</param>
/// <param name="messageId">The message id to edit.</param>
/// <param name="payload">The payload used to edit the message.</param>
/// <returns>The response from Discord's API.</returns>
public async Task<HttpResponseMessage> EditMessage(WebhookIdentifier identifier, ulong messageId, WebhookPayload payload)
{
var url = $"{GetUrl(identifier)}/messages/{messageId}";
var response = await _http.PatchAsJsonAsync(url, payload, JsonOptions);
LogResponse(response, "Edit");
return response;
}
void IPostInjectInit.PostInject()
{
_sawmill = _log.GetSawmill("DISCORD");
}
// Sunrise edit start
// Тут добавлена поддержка прокси и кастомных заголовков. РКН сосать
#region HTTP client
public void SetupClient()
{
_http = CreateHttpClient();
}
public HttpClient GetClient()
{
return _http;
}
private HttpClient CreateHttpClient()
{
HttpClient client;
var proxyAddress = _configuration.GetCVar(SunriseCCVars.DiscordProxyAddress);
if (string.IsNullOrWhiteSpace(proxyAddress))
{
client = new HttpClient();
_sawmill.Debug("No proxy configured for Discord webhooks.");
}
else
{
try
{
client = CreateClientWithProxies(proxyAddress);
_sawmill.Info("Configured Discord webhooks to use proxies");
}
catch (Exception ex)
{
_sawmill.Error($"Failed to configure proxy for Discord webhooks: {ex.Message}. Continuing without proxy.");
client = new HttpClient();
client.Timeout = Timeout;
}
}
// Add custom headers if configured
TryAddCustomHeaders(client);
return client;
}
private HttpClient CreateClientWithProxies(string proxyAddress)
{
var handler = CreateHandler(proxyAddress);
if (handler == null)
{
throw new InvalidOperationException("Failed to create proxy handler");
}
var client = new HttpClient(handler);
client.Timeout = Timeout;
return client;
}
public SocketsHttpHandler? CreateHandler(string proxyAddress)
{
if (!Uri.TryCreate(proxyAddress, UriKind.Absolute, out var proxyUri))
{
_sawmill.Error($"Invalid proxy URI: {proxyAddress}. Discord connections will continue without proxy.");
return null;
}
var proxyUsername = _configuration.GetCVar(SunriseCCVars.DiscordProxyUsername);
var proxyPassword = _configuration.GetCVar(SunriseCCVars.DiscordProxyPassword);
var handler = new SocketsHttpHandler();
NetworkCredential? credentials = null;
if (!string.IsNullOrWhiteSpace(proxyUsername) && !string.IsNullOrWhiteSpace(proxyPassword))
credentials = new NetworkCredential(proxyUsername, proxyPassword);
var proxy = new WebProxy(proxyUri, false, null, credentials)
{
UseDefaultCredentials = false,
};
handler.Proxy = proxy;
handler.UseProxy = true;
handler.ConnectTimeout = Timeout;
return handler;
}
private bool TryAddCustomHeaders(HttpClient client)
{
var customHeaders = _configuration.GetCVar(SunriseCCVars.DiscordCustomHeaders);
if (string.IsNullOrWhiteSpace(customHeaders))
return false;
var usedHeadersCount = 0;
var headers = customHeaders.Split(CustomHeadersSplitter);
foreach (var header in headers)
{
var parts = header.Trim().Split(':', 2);
if (parts.Length != 2)
{
_sawmill.Error($"Invalid header: {header}");
continue;
}
var headerName = parts[0].Trim();
var headerValue = parts[1].Trim();
if (string.IsNullOrWhiteSpace(headerName))
{
_sawmill.Error($"Empty header name in: {header}");
continue;
}
client.DefaultRequestHeaders.Add(headerName, headerValue);
usedHeadersCount++;
}
if (usedHeadersCount == 0)
return false;
_sawmill.Info($"Configured Discord webhooks with {usedHeadersCount} custom headers.");
return true;
}
#endregion
public void Dispose()
{
_http?.Dispose();
}
// Sunrise edit end
/// <summary>
/// Logs detailed information about the HTTP response received from a Discord webhook request.
/// If the response status code is non-2XX it logs the status code, relevant rate limit headers.
/// </summary>
/// <param name="response">The HTTP response received from the Discord API.</param>
/// <param name="methodName">The name (constant) of the method that initiated the webhook request (e.g., "Create", "Edit", "Delete").</param>
private void LogResponse(HttpResponseMessage response, string methodName)
{
if (!response.IsSuccessStatusCode)
{
_sawmill.Error($"Failed to {methodName} message. Status code: {response.StatusCode}.");
if (response.Headers.TryGetValues("Retry-After", out var retryAfter))
_sawmill.Debug($"Failed webhook response Retry-After: {string.Join(", ", retryAfter)}");
if (response.Headers.TryGetValues("X-RateLimit-Global", out var globalRateLimit))
_sawmill.Debug($"Failed webhook response X-RateLimit-Global: {string.Join(", ", globalRateLimit)}");
if (response.Headers.TryGetValues("X-RateLimit-Scope", out var rateLimitScope))
_sawmill.Debug($"Failed webhook response X-RateLimit-Scope: {string.Join(", ", rateLimitScope)}");
}
}
}