Add new Game PR Announcer (#89647)

Ready for deployment by operations.
This commit is contained in:
Jordan Dominion
2025-03-10 18:07:55 -04:00
committed by GitHub
parent 684ce1771d
commit 51b47a94a9
21 changed files with 1159 additions and 879 deletions

4
.github/CODEOWNERS vendored
View File

@@ -23,6 +23,8 @@
/code/modules/tgs/ @Cyberboss
/code/ze_genesis_call/ @Cyberboss
/tools/tgs_test/ @Cyberboss
/tools/Tgstation.DiscordDiscussions/ @Cyberboss
/tools/Tgstation.PRAnnouncer/ @Cyberboss
# Cobby
@@ -214,8 +216,6 @@
/tools/build/ @scriptis @stylemistake
/tools/tgs_scripts/ @Cyberboss @scriptis
/tools/WebhookProcessor/ @BraveMole @TiviPlus
# Host Hell
/code/controllers/configuration/entries @scriptis

5
.gitignore vendored
View File

@@ -184,6 +184,11 @@ Temporary Items
/tools/Tgstation.DiscordDiscussions/bin/*
/tools/Tgstation.DiscordDiscussions/obj/*
/tools/Tgstation.DiscordDiscussions/Properties/launchSettings.json
/tools/Tgstation.PRAnnouncer/.vs/*
/tools/Tgstation.PRAnnouncer/bin/*
/tools/Tgstation.PRAnnouncer/obj/*
/tools/Tgstation.PRAnnouncer/Properties/launchSettings.json
/tools/Tgstation.PRAnnouncer/appsettings.*.json
#GitHub Atom
.atom-build.json

View File

@@ -44,6 +44,7 @@
// TOPICS
// If you modify the protocol for this, update tools/Tgstation.PRAnnouncer
/datum/world_topic/ping
keyword = "ping"
log = FALSE
@@ -60,6 +61,7 @@
/datum/world_topic/playing/Run(list/input)
return GLOB.player_list.len
// If you modify the protocol for this, update tools/Tgstation.PRAnnouncer
/datum/world_topic/pr_announce
keyword = "announce"
require_comms_key = TRUE

View File

@@ -0,0 +1,149 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Byond.TopicSender;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Tgstation.PRAnnouncer
{
/// <summary>
/// An <see cref="IHealthCheck"/> that checks connectivity to configured game servers.
/// </summary>
sealed class GameServersConnectivityHealthCheck : IHealthCheck, IDisposable
{
/// <summary>
/// The name of the health check.
/// </summary>
public const string Name = "GameServerConnectivity";
/// <summary>
/// The <see cref="ITopicClient"/> to use.
/// </summary>
readonly ITopicClient topicClient;
/// <summary>
/// The <see cref="IOptionsMonitor{TOptions}"/> for the <see cref="Settings"/>.
/// </summary>
readonly IOptionsMonitor<Settings> settings;
/// <summary>
/// The <see cref="ILogger"/> to write to.
/// </summary>
readonly ILogger<GameServersConnectivityHealthCheck> logger;
/// <summary>
/// The <see cref="IDisposable"/> returned from <see cref="IOptionsMonitor{TOptions}.OnChange(Action{TOptions, string?})"/> for <see cref="settings"/>.
/// </summary>
readonly IDisposable? optionsMonitorRegistration;
/// <summary>
/// The last time <see cref="LiveHealthCheck(CancellationToken)"/> was run.
/// </summary>
DateTimeOffset lastCheck;
/// <summary>
/// The last response from <see cref="LiveHealthCheck(CancellationToken)"/>.
/// </summary>
HealthCheckResult cachedResult;
/// <summary>
/// Initializes a new instance of the <see cref="GameServersConnectivityHealthCheck"/> class..
/// </summary>
/// <param name="topicClient">The value of <see cref="topicClient"/>.</param>
/// <param name="settings">The value of <see cref="settings"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
public GameServersConnectivityHealthCheck(
ITopicClient topicClient,
IOptionsMonitor<Settings> settings,
ILogger<GameServersConnectivityHealthCheck> logger)
{
this.topicClient = topicClient ?? throw new ArgumentNullException(nameof(topicClient));
this.settings = settings ?? throw new ArgumentNullException(nameof(settings));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
optionsMonitorRegistration = settings.OnChange(_ => lastCheck = DateTimeOffset.MinValue);
}
/// <inheritdoc />
public void Dispose()
=> optionsMonitorRegistration?.Dispose();
/// <inheritdoc />
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var nextCheck = lastCheck + TimeSpan.FromSeconds(settings.CurrentValue.GameServerHealthCheckSeconds);
if (now >= nextCheck)
{
cachedResult = await LiveHealthCheck(cancellationToken);
lastCheck = now;
}
return cachedResult;
}
/// <summary>
/// Run a non-cached health check.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="HealthCheckResult"/>.</returns>
async ValueTask<HealthCheckResult> LiveHealthCheck(CancellationToken cancellationToken)
{
var servers = settings.CurrentValue.Servers;
if (servers == null || servers.Count == 0)
return HealthCheckResult.Healthy("No servers to check on");
var tasks = servers.ToDictionary(server => server, server => CheckServer(server, cancellationToken));
await Task.WhenAll(tasks.Values);
var failedTasks = tasks.Where(kvp => kvp.Value.Result != null).ToArray();
var counter = 0;
var data = tasks.ToDictionary(
kvp => $"#{++counter}: {kvp.Key.Address}:{kvp.Key.Port}",
kvp => kvp.Value.Result == null ? (object)"Success" : "Failed");
if (failedTasks.Length > 0)
{
var exception = new AggregateException(failedTasks.Select(kvp => kvp.Value.Result!));
if (failedTasks.Length == servers.Count)
return HealthCheckResult.Degraded("All servers have failed the ping test!", exception, data);
return HealthCheckResult.Degraded("Some servers have failed the ping test!", exception, data);
}
return HealthCheckResult.Healthy("All servers passed the ping test", data);
}
/// <summary>
/// Check on a given <paramref name="server"/>.
/// </summary>
/// <param name="server">The <see cref="ServerConfig"/> of the server to check on.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in <see langword="null"/> on a successful connection test, or an <see cref="Exception"/> if an error occurred.</returns>
async Task<Exception?> CheckServer(ServerConfig server, CancellationToken cancellationToken)
{
var address = server.Address;
try
{
if (address == null)
throw new Exception($"A server has a null {nameof(ServerConfig.Address)}!");
var result = await topicClient.SendTopic(address, "ping", server.Port, cancellationToken)
?? throw new Exception("Topic client returned null!");
if (result.ResponseType != TopicResponseType.FloatResponse)
throw new Exception("Response was not a float!");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Server \"{address}:{port}\" failed health check!", address, server.Port);
return ex;
}
return null;
}
}
}

View File

@@ -0,0 +1,13 @@
namespace Tgstation.PRAnnouncer
{
/// <summary>
/// The "payload" option for the PR announce topic.
/// </summary>
sealed class PRAnnouncePayload
{
/// <summary>
/// The <see cref="PRAnnouncePayloadPullRequest"/>.
/// </summary>
public required PRAnnouncePayloadPullRequest PullRequest { get; init; }
}
}

View File

@@ -0,0 +1,27 @@
using System;
using Octokit.Webhooks.Models.PullRequestEvent;
namespace Tgstation.PRAnnouncer
{
/// <summary>
/// The pull_request entry in the announce payload.
/// </summary>
public class PRAnnouncePayloadPullRequest
{
/// <summary>
/// The <see cref="PullRequest.Id"/>.
/// </summary>
public long Id { get; }
/// <summary>
/// Initializes a new instance of the <see cref="PRAnnouncePayloadPullRequest"/>.
/// </summary>
/// <param name="pullRequest">The <see cref="PullRequest"/>.</param>
public PRAnnouncePayloadPullRequest(PullRequest pullRequest)
{
ArgumentNullException.ThrowIfNull(pullRequest);
Id = pullRequest.Id;
}
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Web;
using Octokit.Webhooks.Events;
namespace Tgstation.PRAnnouncer
{
/// <summary>
/// The payload the /datum/world_topic/pr_announce handler expects.
/// </summary>
sealed class PRAnnounceQuery
{
/// <summary>
/// The <see cref="JsonSerializerOptions"/> for sending payloads to game servers.
/// </summary>
static readonly JsonSerializerOptions serializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
readonly PRAnnouncePayload payload;
/// <summary>
/// The raw html announce <see cref="string"/>.
/// </summary>
readonly string announce;
/// <summary>
/// The comms key.
/// </summary>
readonly string key;
/// <summary>
/// Initializes a new instance of the <see cref="PRAnnounceQuery"/> class.
/// </summary>
/// <param name="pullRequestEvent">The <see cref="PullRequestEvent"/> to announce.</param>
/// <param name="commsKey">The value of <see cref="key"/>.</param>
public PRAnnounceQuery(PullRequestEvent pullRequestEvent, string commsKey)
{
ArgumentNullException.ThrowIfNull(pullRequestEvent);
key = commsKey ?? throw new ArgumentNullException(commsKey);
payload = new PRAnnouncePayload
{
PullRequest = new PRAnnouncePayloadPullRequest(pullRequestEvent.PullRequest),
};
announce = $"[{pullRequestEvent.PullRequest.Base.Repo.FullName}] Pull Request {(pullRequestEvent.PullRequest.Merged == true ? "merged" : (pullRequestEvent.Action ?? "(NULL ACTION)"))} {HtmlEncoder.Default.Encode(pullRequestEvent.Sender?.Login ?? "(NULL)")}: <a href=\"{pullRequestEvent.PullRequest.HtmlUrl}\"> #{pullRequestEvent.PullRequest.Number} {HtmlEncoder.Default.Encode($"{pullRequestEvent.PullRequest.User.Login} - {pullRequestEvent.PullRequest.Title}")}";
}
/// <summary>
/// Serialize the <see cref="PRAnnounceQuery"/> to a topic <see cref="string"/>.
/// </summary>
/// <returns>The serialized <see cref="PRAnnounceQuery"/>.</returns>
public string Serialize()
=> $"?key={HttpUtility.UrlEncode(key)}&announce={HttpUtility.UrlEncode(announce)}&payload={HttpUtility.UrlEncode(JsonSerializer.Serialize(payload, serializerOptions))}";
}
}

View File

@@ -0,0 +1,84 @@
using System;
using System.Threading.Tasks;
using Byond.TopicSender;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Octokit.Webhooks;
using Octokit.Webhooks.AspNetCore;
using Prometheus;
namespace Tgstation.PRAnnouncer
{
/// <summary>
/// The program.
/// </summary>
public class Program
{
/// <summary>
/// Program entrypoint.
/// </summary>
/// <param name="args">Command line arguments.</param>
/// <returns>A <see cref="Task"/> representing the lifetime of the program.</returns>
public static async Task Main(string[] args)
{
var appBuilder = WebApplication.CreateBuilder(args);
appBuilder.Host.UseSystemd();
var servicesBuilder = appBuilder.Services;
servicesBuilder.AddOptions();
servicesBuilder.AddHealthChecks()
.AddCheck<GameServersConnectivityHealthCheck>(GameServersConnectivityHealthCheck.Name)
.ForwardToPrometheus();
servicesBuilder.AddLogging(loggingBuilder => loggingBuilder.AddConsole());
servicesBuilder.Configure<Settings>(appBuilder.Configuration.GetSection("Settings"));
servicesBuilder.AddSingleton<ITopicClient>(services => {
var timeouts = services.GetRequiredService<IOptionsSnapshot<Settings>>().Value.TopicTimeouts;
return new TopicClient(
new SocketParameters
{
ConnectTimeout = TimeSpan.FromSeconds(timeouts?.ConnectTimeoutSeconds ?? TopicTimeouts.DefaultTimeoutSeconds),
SendTimeout = TimeSpan.FromSeconds(timeouts?.SendTimeoutSeconds ?? TopicTimeouts.DefaultTimeoutSeconds),
ReceiveTimeout = TimeSpan.FromSeconds(timeouts?.ReceiveTimeoutSeconds ?? TopicTimeouts.DefaultTimeoutSeconds),
DisconnectTimeout = TimeSpan.FromSeconds(timeouts?.DisconnectTimeoutSeconds ?? TopicTimeouts.DefaultTimeoutSeconds),
},
services.GetService<ILogger<TopicClient>>());
});
servicesBuilder.AddSingleton<IMetricFactory>(_ => Metrics.DefaultFactory);
servicesBuilder.AddSingleton<WebhookEventProcessor, TgstationWebhookEventProcessor>();
await using var app = appBuilder.Build();
var services = app.Services;
var logger = services.GetRequiredService<ILogger<Program>>();
try
{
var settings = services.GetRequiredService<IOptions<Settings>>();
var secret = settings.Value.GitHubSecret;
app.MapGitHubWebhooks(secret: secret);
app.MapMetrics();
app.MapHealthChecks("/health");
await app.RunAsync();
}
catch (Exception ex)
{
logger.LogCritical(ex, "Application crashed!");
}
}
}
}

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace Tgstation.PRAnnouncer
{
/// <summary>
/// Configuration for a game server to send announcement messages to.
/// </summary>
sealed class ServerConfig
{
/// <summary>
/// The server's address.
/// </summary>
public string? Address { get; set; }
/// <summary>
/// The server's port.
/// </summary>
public ushort Port { get; set; }
/// <summary>
/// The list of repository slugs that the server should listen to pull request events for. If <see langword="null"/>, all repositories will be listened to.
/// </summary>
/// <example>tgstation/tgstation</example>
public IReadOnlyList<string>? InterestedRepoSlugs { get; set; }
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
namespace Tgstation.PRAnnouncer
{
/// <summary>
/// App settings.
/// </summary>
sealed class Settings
{
/// <summary>
/// The <see cref="PRAnnouncer.TopicTimeouts"/>. These require a server restart to change.
/// </summary>
public TopicTimeouts? TopicTimeouts { get; set; }
/// <summary>
/// Secret for communication with game servers.
/// </summary>
public string? CommsKey { get; set; }
/// <summary>
/// The secret for the GitHub webhook, if any.
/// </summary>
public string? GitHubSecret { get; set; }
/// <summary>
/// The number of seconds between ping checks on configured <see cref="Servers"/>.
/// </summary>
public uint GameServerHealthCheckSeconds { get; set; }
/// <summary>
/// The <see cref="ServerConfig"/>s for each server to forward topics to.
/// </summary>
public IReadOnlyList<ServerConfig>? Servers { get; set; }
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<Version>1.0.0</Version>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<StyleCopTreatErrorsAsWarnings>false</StyleCopTreatErrorsAsWarnings>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Byond.TopicSender" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.2" />
<PackageReference Include="Octokit.Webhooks.AspNetCore" Version="2.4.1" />
<PackageReference Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using System.Web;
using Byond.TopicSender;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Octokit.Webhooks;
using Octokit.Webhooks.Events;
using Octokit.Webhooks.Events.PullRequest;
using Prometheus;
namespace Tgstation.PRAnnouncer
{
/// <summary>
/// Tgstation webhook processor.
/// </summary>
sealed class TgstationWebhookEventProcessor : WebhookEventProcessor
{
/// <summary>
/// The <see cref="ITopicClient"/> to use.
/// </summary>
readonly ITopicClient topicClient;
/// <summary>
/// The <see cref="ILogger"/> to write to.
/// </summary>
readonly ILogger<TgstationWebhookEventProcessor> logger;
/// <summary>
/// The <see cref="IOptionsMonitor{TOptions}"/> for the <see cref="Settings"/>.
/// </summary>
readonly IOptionsMonitor<Settings> options;
readonly Counter announcementsTriggered;
readonly Counter badCalls;
readonly Counter successfulTopicCalls;
readonly Counter failedTopicCalls;
/// <summary>
/// Initializes a new instanc eof the <see cref="TgstationWebhookEventProcessor"/> class.
/// </summary>
/// <param name="topicClient">The value of <see cref="topicClient"/>.</param>
/// <param name="metricFactory">The <see cref="IMetricFactory"/> used to create metrics.</param>
/// <param name="options">The value of <see cref="options"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
public TgstationWebhookEventProcessor(
ITopicClient topicClient,
IMetricFactory metricFactory,
IOptionsMonitor<Settings> options,
ILogger<TgstationWebhookEventProcessor> logger)
{
this.topicClient = topicClient ?? throw new ArgumentNullException(nameof(topicClient));
ArgumentNullException.ThrowIfNull(metricFactory);
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
announcementsTriggered = metricFactory.CreateCounter("pr_announcer_announcements_triggered", "The number of webhooks that triggered a PR announcement that have been processed");
badCalls = metricFactory.CreateCounter("pr_announcer_bad_calls", "The number of malformed webhook calls received");
successfulTopicCalls = metricFactory.CreateCounter("pr_announcer_successful_topic_calls", "Total number of successful topic calls");
failedTopicCalls = metricFactory.CreateCounter("pr_announcer_failed_topic_calls", "Total number of failed topic calls");
}
/// <inheritdoc />
protected override Task ProcessPullRequestWebhookAsync(WebhookHeaders headers, PullRequestEvent pullRequestEvent, PullRequestAction action)
{
var repo = pullRequestEvent.Repository;
if (repo == null)
{
logger.LogWarning("Bad payload: Repo was null");
badCalls.Inc();
return Task.CompletedTask;
}
var slug = $"{repo.Owner.Login}/{repo.Name}";
if (!(action == PullRequestAction.Closed
|| action == PullRequestAction.Opened))
{
logger.LogDebug(
"Ignoring unwanted PR action {action}: {slug} #{number} by @{author}",
pullRequestEvent.Action,
slug,
pullRequestEvent.Number,
pullRequestEvent.PullRequest.User.Login);
return Task.CompletedTask;
}
logger.LogInformation(
"Received pull request webhook: {slug} #{number} by @{author} {action}",
slug,
pullRequestEvent.Number,
pullRequestEvent.PullRequest.User.Login,
pullRequestEvent.Action);
#pragma warning disable IDE0059 // Unnecessary assignment of a value, WTF VS?
if (pullRequestEvent.AdditionalProperties?.TryGetValue("author_association", out var authorAssociation) ?? false
#pragma warning restore IDE0059
&& (authorAssociation.ValueEquals("FIRST_TIMER") || authorAssociation.ValueEquals("FIRST_TIME_CONTRIBUTROR")))
{
logger.LogInformation(
"Not triggering announcement, first time contributor detected");
return Task.CompletedTask;
}
var settings = options.CurrentValue;
var commsKey = settings.CommsKey;
if(commsKey == null)
{
logger.LogError("Cannot process webhook, {commsKey} is null!", nameof(Settings.CommsKey));
return Task.CompletedTask;
}
var relevantServers = (IReadOnlyCollection<ServerConfig>?)settings
.Servers
?.Where(
config => config
.InterestedRepoSlugs
?.Any(
interestedSlug => interestedSlug.Equals(
slug,
StringComparison.OrdinalIgnoreCase))
?? true)
.ToList()
?? [];
if (relevantServers.Count == 0)
{
logger.LogInformation("No servers interested");
return Task.CompletedTask;
}
announcementsTriggered.Inc();
var payload = new PRAnnounceQuery(pullRequestEvent, commsKey);
return Task.WhenAll(relevantServers.Select(server => SendPayload(server, payload)));
}
/// <summary>
/// Send a given <paramref name="payload"/> to a given <paramref name="server"/>.
/// </summary>
/// <param name="server">The <see cref="ServerConfig"/> of the server to send to.</param>
/// <param name="payload">The <see cref="PRAnnounceQuery"/></param>
/// <returns></returns>
async Task SendPayload(ServerConfig server, PRAnnounceQuery payload)
{
var address = server.Address;
if(address == null)
{
logger.LogError("A server has a null Address configured!");
return;
}
var encodedPayload = payload.Serialize();
try
{
var result = await topicClient.SendTopic(address, encodedPayload, server.Port);
successfulTopicCalls.Inc();
}
catch (Exception ex)
{
failedTopicCalls.Inc();
logger.LogError(ex, "Failed to send topic to game server {address}:{port}", address, server.Port);
}
}
}
}

View File

@@ -0,0 +1,33 @@
namespace Tgstation.PRAnnouncer
{
/// <summary>
/// Timeout controls for sending topics.
/// </summary>
sealed class TopicTimeouts
{
/// <summary>
/// The default value for properties if they are <see langword="null"/>.
/// </summary>
public const uint DefaultTimeoutSeconds = 5;
/// <summary>
/// The timeout for the send operation.
/// </summary>
public uint? SendTimeoutSeconds { get; set; }
/// <summary>
/// The timeout for the receive operation.
/// </summary>
public uint? ReceiveTimeoutSeconds { get; set; }
/// <summary>
/// The timeout for the receive operation.
/// </summary>
public uint? ConnectTimeoutSeconds { get; set; }
/// <summary>
/// The timeout for the disconnect operation.
/// </summary>
public uint? DisconnectTimeoutSeconds { get; set; }
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,322 @@
[
{
"pname": "Byond.TopicSender",
"version": "8.0.1",
"hash": "sha256-Pcsegjpwrw7R1zo6NOSOIIkjs8k6SCoB5BbnDUYWLKw="
},
{
"pname": "Macross.Json.Extensions",
"version": "3.0.0",
"hash": "sha256-nBoB4vY+R19jGPdnlN6hNmZjOeAEkOA1CS+DHLjZUJA="
},
{
"pname": "Microsoft.Extensions.Configuration",
"version": "3.1.0",
"hash": "sha256-KI1WXvnF/Xe9cKTdDjzm0vd5h9bmM+3KinuWlsF/X+c="
},
{
"pname": "Microsoft.Extensions.Configuration",
"version": "9.0.2",
"hash": "sha256-AUNaLhYTcHUkqKGhSL7QgrifV9JkjKhNQ4Ws8UtZhlM="
},
{
"pname": "Microsoft.Extensions.Configuration.Abstractions",
"version": "3.1.0",
"hash": "sha256-GMxvf0iAiWUWo0awlDczzcxNo8+MITBLp0/SqqYo8Lg="
},
{
"pname": "Microsoft.Extensions.Configuration.Abstractions",
"version": "6.0.0",
"hash": "sha256-Evg+Ynj2QUa6Gz+zqF+bUyfGD0HI5A2fHmxZEXbn3HA="
},
{
"pname": "Microsoft.Extensions.Configuration.Abstractions",
"version": "9.0.2",
"hash": "sha256-icRtfbi0nDRUYDErtKYx0z6A1gWo5xdswsSM6o4ozxc="
},
{
"pname": "Microsoft.Extensions.Configuration.Binder",
"version": "3.1.0",
"hash": "sha256-/B7WjPZPvRM+CPgfaCQunSi2mpclH4orrFxHGLs8Uo4="
},
{
"pname": "Microsoft.Extensions.Configuration.Binder",
"version": "9.0.2",
"hash": "sha256-lYWUfvSnpp9M4N4wIfFnMlB+8K79g9uUa1NXsgnxs0k="
},
{
"pname": "Microsoft.Extensions.Configuration.CommandLine",
"version": "9.0.2",
"hash": "sha256-qsEwiAO/n2+k8Q8/AftqdSlvvQWDx7WKb+9VlP8Nuxw="
},
{
"pname": "Microsoft.Extensions.Configuration.EnvironmentVariables",
"version": "9.0.2",
"hash": "sha256-XgSdv8+zh2vXmhP+a31/+Y+mNLwQwLflfCiEtDemea0="
},
{
"pname": "Microsoft.Extensions.Configuration.FileExtensions",
"version": "9.0.2",
"hash": "sha256-eeZbwf2lcV74mjXtOX8q0MxvP4QzEYyHXr1EGFS/orU="
},
{
"pname": "Microsoft.Extensions.Configuration.Json",
"version": "9.0.2",
"hash": "sha256-7/ewyjh0gXu798fYcJxOCkdaAPIzrJ8reuTzqz93IJ0="
},
{
"pname": "Microsoft.Extensions.Configuration.UserSecrets",
"version": "9.0.2",
"hash": "sha256-0OmAQn8gIqTPN4s0NkcidXivjq5LsEGiNVxmp3qxGoo="
},
{
"pname": "Microsoft.Extensions.DependencyInjection",
"version": "3.1.0",
"hash": "sha256-S72hzDAYWzrfCH5JLJBRtwPEM/Xjh17HwcKuA3wLhvU="
},
{
"pname": "Microsoft.Extensions.DependencyInjection",
"version": "9.0.2",
"hash": "sha256-jNQVj2Xo7wzVdNDu27bLbYCVUOF8yDVrFtC3cZ9OsXo="
},
{
"pname": "Microsoft.Extensions.DependencyInjection.Abstractions",
"version": "3.1.0",
"hash": "sha256-cG0XS3ibJ9siu8eaQGJnyRwlEbQ9c/eGCtvPjs7Rdd8="
},
{
"pname": "Microsoft.Extensions.DependencyInjection.Abstractions",
"version": "6.0.0",
"hash": "sha256-SZke0jNKIqJvvukdta+MgIlGsrP2EdPkkS8lfLg7Ju4="
},
{
"pname": "Microsoft.Extensions.DependencyInjection.Abstractions",
"version": "8.0.0",
"hash": "sha256-75KzEGWjbRELczJpCiJub+ltNUMMbz5A/1KQU+5dgP8="
},
{
"pname": "Microsoft.Extensions.DependencyInjection.Abstractions",
"version": "9.0.2",
"hash": "sha256-WoTLgw/OlXhgN54Szip0Zpne7i/YTXwZ1ZLCPcHV6QM="
},
{
"pname": "Microsoft.Extensions.Diagnostics",
"version": "9.0.2",
"hash": "sha256-ImTZ6PZyKEdq1XvqYT5DPr6cG0BSTrsrO7rTDuy29fc="
},
{
"pname": "Microsoft.Extensions.Diagnostics.Abstractions",
"version": "9.0.2",
"hash": "sha256-JTJ8LCW3aYUO86OPgXRQthtDTUMikOfILExgeOF8CX4="
},
{
"pname": "Microsoft.Extensions.Diagnostics.HealthChecks",
"version": "6.0.9",
"hash": "sha256-2KRX3U+FNauAZJln0zeJayHPVUR86luWCmfESutHvRo="
},
{
"pname": "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions",
"version": "6.0.9",
"hash": "sha256-4inPpRPTC+hs3vaZJ5Par/uTbiFqlRvxMBQfDDDPYts="
},
{
"pname": "Microsoft.Extensions.FileProviders.Abstractions",
"version": "6.0.0",
"hash": "sha256-uBjWjHKEXjZ9fDfFxMjOou3lhfTNhs1yO+e3fpWreLk="
},
{
"pname": "Microsoft.Extensions.FileProviders.Abstractions",
"version": "9.0.2",
"hash": "sha256-RmVshMCWW1/RE/Wk8AeT4r6uZ+XFuwDFYzdxYKSm440="
},
{
"pname": "Microsoft.Extensions.FileProviders.Physical",
"version": "9.0.2",
"hash": "sha256-vQBgVLW813wOnJ1+943ArDWReok6p0jAl7fhwvyFtL8="
},
{
"pname": "Microsoft.Extensions.FileSystemGlobbing",
"version": "9.0.2",
"hash": "sha256-oH6X8SQjqi5Q2HLRILcUr9iPqnC1Ky5m5GbYYCKCxag="
},
{
"pname": "Microsoft.Extensions.Hosting",
"version": "9.0.2",
"hash": "sha256-eI9ckarRX0UCX+mBsEBYdvHZrmN86bXyTRvbH4gU9JM="
},
{
"pname": "Microsoft.Extensions.Hosting.Abstractions",
"version": "6.0.0",
"hash": "sha256-ksIPO6RhfbYx/i3su4J3sDhoL+TDnITKsgIpEqnpktc="
},
{
"pname": "Microsoft.Extensions.Hosting.Abstractions",
"version": "9.0.2",
"hash": "sha256-PUCam4g5g84qIqfPA9sVBNVPA26rWFq7js9nHF3WLZc="
},
{
"pname": "Microsoft.Extensions.Hosting.Systemd",
"version": "9.0.2",
"hash": "sha256-J2L7JYO2dmTd4JIQZ0zphtk0p12y9GgYcJHFO8dO1/c="
},
{
"pname": "Microsoft.Extensions.Http",
"version": "3.1.0",
"hash": "sha256-nhkt3qVsTXccgrW3mvx8veaJICREzeJrXfrjXI7rNwo="
},
{
"pname": "Microsoft.Extensions.Logging",
"version": "3.1.0",
"hash": "sha256-BDrsqgiLYAphIOlnEuXy6iLoED/ykFO53merHCSGfrQ="
},
{
"pname": "Microsoft.Extensions.Logging",
"version": "9.0.2",
"hash": "sha256-vPCb4ZoiwZUSGJIOhYiLwcZLnsd0ZZhny6KQkT88nI0="
},
{
"pname": "Microsoft.Extensions.Logging.Abstractions",
"version": "3.1.0",
"hash": "sha256-D3GHIGN0r6zLHHP2/5jt6hB0oMvRyl5ysvVrPVmmyv8="
},
{
"pname": "Microsoft.Extensions.Logging.Abstractions",
"version": "6.0.2",
"hash": "sha256-VRyyMGCMBh25vIIzbLapMAqY8UffqJRvkF/kcYcjZfM="
},
{
"pname": "Microsoft.Extensions.Logging.Abstractions",
"version": "8.0.0",
"hash": "sha256-Jmddjeg8U5S+iBTwRlVAVLeIHxc4yrrNgqVMOB7EjM4="
},
{
"pname": "Microsoft.Extensions.Logging.Abstractions",
"version": "9.0.2",
"hash": "sha256-mCxeuc+37XY0bmZR+z4p1hrZUdTZEg+FRcs/m6dAQDU="
},
{
"pname": "Microsoft.Extensions.Logging.Configuration",
"version": "9.0.2",
"hash": "sha256-SeNQ8us2cZ8xbJx8TK7xm3IxQR95EanSfMYhqvP2pWU="
},
{
"pname": "Microsoft.Extensions.Logging.Console",
"version": "9.0.2",
"hash": "sha256-yD30lW3ax4JHmZ9QIp1b0ELrXiwykP5KHF/feJGweyE="
},
{
"pname": "Microsoft.Extensions.Logging.Debug",
"version": "9.0.2",
"hash": "sha256-0WP9jFTsbXCIhYx/2IFL69mv2+K3Ld7C4QvwY00iOD0="
},
{
"pname": "Microsoft.Extensions.Logging.EventLog",
"version": "9.0.2",
"hash": "sha256-e4q/Z6xLq2HzQiKI7npagyEZdkfUe+FbIz3Tg+hPH9g="
},
{
"pname": "Microsoft.Extensions.Logging.EventSource",
"version": "9.0.2",
"hash": "sha256-W7yidllNOKxTvgIUqjJ3h55PAIR/XREfbuH+8TUhD0o="
},
{
"pname": "Microsoft.Extensions.ObjectPool",
"version": "7.0.0",
"hash": "sha256-JxlxPnjmWbEhYLNWlSn+kNxUfwvlxgKiKFjkJyYGn5Y="
},
{
"pname": "Microsoft.Extensions.Options",
"version": "3.1.0",
"hash": "sha256-0EOsmu/oLAz9WXp1CtMlclzdvs5jea0zJmokeyFnbCo="
},
{
"pname": "Microsoft.Extensions.Options",
"version": "6.0.0",
"hash": "sha256-DxnEgGiCXpkrxFkxXtOXqwaiAtoIjA8VSSWCcsW0FwE="
},
{
"pname": "Microsoft.Extensions.Options",
"version": "9.0.2",
"hash": "sha256-y2jZfcWx/H6Sx7wklA248r6kPjZmzTTLGxW8ZxrzNLM="
},
{
"pname": "Microsoft.Extensions.Options.ConfigurationExtensions",
"version": "9.0.2",
"hash": "sha256-xOYLRlXDI4gMEoQ+J+sQBNRT2RPDNrSCZkob7qBiV10="
},
{
"pname": "Microsoft.Extensions.Primitives",
"version": "3.1.0",
"hash": "sha256-K/cDq+LMfK4cBCvKWkmWAC+IB6pEWolR1J5zL60QPvA="
},
{
"pname": "Microsoft.Extensions.Primitives",
"version": "6.0.0",
"hash": "sha256-AgvysszpQ11AiTBJFkvSy8JnwIWTj15Pfek7T7ThUc4="
},
{
"pname": "Microsoft.Extensions.Primitives",
"version": "8.0.0",
"hash": "sha256-FU8qj3DR8bDdc1c+WeGZx/PCZeqqndweZM9epcpXjSo="
},
{
"pname": "Microsoft.Extensions.Primitives",
"version": "9.0.2",
"hash": "sha256-zy/YNMaY47o6yNv2WuYiAJEjtoOF8jlWgsWHqXeSm4s="
},
{
"pname": "Octokit.Webhooks",
"version": "2.4.1",
"hash": "sha256-FZ2Eg432VkDh8fQWXkacGJNf9Uvv5LUCAU/xrqugnFc="
},
{
"pname": "Octokit.Webhooks.AspNetCore",
"version": "2.4.1",
"hash": "sha256-UGRKHb8wwtNl1nMIp2hZs0DTQZmWOAFZB8MXzbRBV7g="
},
{
"pname": "prometheus-net",
"version": "8.2.1",
"hash": "sha256-NxHeXd4fwwc4MMsT6mrfX81czjHnq2GMStWTabZxMDw="
},
{
"pname": "prometheus-net.AspNetCore",
"version": "8.2.1",
"hash": "sha256-dhrATENkD/1GfSPBkAd3GvyHvzR5q+c+k22UTp33z+c="
},
{
"pname": "prometheus-net.AspNetCore.HealthChecks",
"version": "8.2.1",
"hash": "sha256-C0RIYDSfmaWMJrQE7QTWdtGVc1iLbNUkTEe0bBUpSQQ="
},
{
"pname": "System.Diagnostics.DiagnosticSource",
"version": "9.0.2",
"hash": "sha256-vhlhNgWeEosMB3DyneAUgH2nlpHORo7vAIo5Bx5Dgrc="
},
{
"pname": "System.Diagnostics.EventLog",
"version": "9.0.2",
"hash": "sha256-IoiQbH8To9UqzYgJzYpFbuiRV3KGU85y4ccPTyttP/w="
},
{
"pname": "System.IO.Pipelines",
"version": "9.0.2",
"hash": "sha256-uxM7J0Q/dzEsD0NGcVBsOmdHiOEawZ5GNUKBwpdiPyE="
},
{
"pname": "System.Runtime.CompilerServices.Unsafe",
"version": "6.0.0",
"hash": "sha256-bEG1PnDp7uKYz/OgLOWs3RWwQSVYm+AnPwVmAmcgp2I="
},
{
"pname": "System.Text.Encodings.Web",
"version": "9.0.2",
"hash": "sha256-tZhc/Xe+SF9bCplthph2QmQakWxKVjMfQJZzD1Xbpg8="
},
{
"pname": "System.Text.Json",
"version": "9.0.2",
"hash": "sha256-kftKUuGgZtF4APmp77U79ws76mEIi+R9+DSVGikA5y8="
}
]

View File

@@ -0,0 +1,13 @@
{
description = "tgstation-pr-announcer";
inputs = {};
outputs = { ... }: {
nixosModules = {
default = { ... }: {
imports = [ ./tgstation-pr-announcer.nix ];
};
};
};
}

View File

@@ -0,0 +1,57 @@
{
pkgs,
...
}:
let
inherit (pkgs) stdenv lib;
versionParse = stdenv.mkDerivation {
pname = "tgstation-pr-announcer-version-parse";
version = "1.0.0";
meta = with pkgs.lib; {
description = "Version parser for tgstation-pr-announcer";
homepage = "https://github.com/tgstation/tgstation";
license = licenses.agpl3Plus;
platforms = platforms.x86_64;
};
nativeBuildInputs = with pkgs; [
xmlstarlet
];
src = ./.;
installPhase = ''
mkdir -p $out
xmlstarlet sel --template --value-of /Project/PropertyGroup/Version ./Tgstation.PRAnnouncer.csproj > $out/tgstation-pr-announcer-version.txt
'';
};
version = (builtins.readFile "${versionParse}/tgstation-pr-announcer-version.txt");
in
pkgs.buildDotnetModule {
pname = "tgstation-pr-announcer";
version = (builtins.readFile "${versionParse}/tgstation-pr-announcer-version.txt");
meta = with pkgs.lib; {
description = "Tool for forwarding GitHub webhooks for PRs to DM game servers";
homepage = "https://github.com/tgstation/tgstation";
license = licenses.agpl3Plus;
platforms = platforms.x86_64;
};
nativeBuildInputs = with pkgs; [
versionParse
];
src = ./.;
projectFile = "Tgstation.PRAnnouncer.csproj";
nugetDeps = ./deps.json;
dotnet-sdk = pkgs.dotnetCorePackages.sdk_8_0;
dotnet-runtime = pkgs.dotnetCorePackages.aspnetcore_8_0;
executables = [ "Tgstation.PRAnnouncer" ];
}

View File

@@ -0,0 +1,102 @@
inputs@{
config,
lib,
systemdUtils,
nixpkgs,
pkgs,
...
}:
let
cfg = config.services.tgstation-pr-announcer;
package = import ./package.nix inputs;
in
{
##### interface. here we define the options that users of our service can specify
options = {
# the options for our service will be located under services.foo
services.tgstation-pr-announcer = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to enable tgstation-pr-announcer.
'';
};
username = lib.mkOption {
type = lib.types.nonEmptyStr;
default = "tgstation-pr-announcer";
description = ''
The name of the user used to execute tgstation-pr-announcer.
'';
};
groupname = lib.mkOption {
type = lib.types.nonEmptyStr;
default = "tgstation-pr-announcer";
description = ''
The name of group the user used to execute tgstation-pr-announcer will belong to.
'';
};
production-appsettings = lib.mkOption {
type = lib.types.path;
default = '''';
description = ''
A formatted appsettings.Production.json file.
'';
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Environment file as defined in {manpage}`systemd.exec(5)`
'';
};
wants = lib.mkOption {
type = lib.types.listOf systemdUtils.lib.unitNameType;
default = [];
description = ''
Start the specified units when this unit is started.
'';
};
};
};
config = lib.mkIf cfg.enable {
users.groups."${cfg.groupname}" = { };
users.users."${cfg.username}" = {
isSystemUser = true;
group = cfg.groupname;
};
environment.etc = {
"tgstation-pr-announcer.d/appsettings.Production.json" = {
source = cfg.production-appsettings;
group = cfg.groupname;
mode = "0640";
};
};
systemd.services.tgstation-pr-announcer = {
description = "tgstation-pr-announcer";
serviceConfig = {
EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;
User = cfg.username;
Type = "notify";
WorkingDirectory = "/etc/tgstation-pr-announcer.d";
ExecStart = "${package}/bin/Tgstation.PRAnnouncer";
Restart = "always";
};
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
];
};
};
}

View File

@@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.9.34728.123
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tgstation.PRAnnouncer", "Tgstation.PRAnnouncer.csproj", "{3BD2EB29-D776-4533-969D-B94564A69DCD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{3BD2EB29-D776-4533-969D-B94564A69DCD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3BD2EB29-D776-4533-969D-B94564A69DCD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3BD2EB29-D776-4533-969D-B94564A69DCD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3BD2EB29-D776-4533-969D-B94564A69DCD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {899C600F-81CE-456E-9D76-58F8CD5CEACB}
EndGlobalSection
EndGlobal

View File

@@ -1,741 +0,0 @@
<?php
/*
* Github webhook In-game PR Announcer and Changelog Generator for /tg/Station13
* Author: MrStonedOne
* For documentation on the changelog generator see https://tgstation13.org/phpBB/viewtopic.php?f=5&t=5157
* To hide prs from being announced in game, place a [s] in front of the title
* All runtime errors are echo'ed to the webhook's logs in github
* Events to be sent via GitHub webhook: Pull Requests, Pushes
* Any other Event will result in a 404 returned to the webhook.
*/
/**CREDITS:
* GitHub webhook handler template.
*
* @see https://developer.github.com/webhooks/
* @author Miloslav Hula (https://github.com/milo)
*/
define('S_LINK_EMBED', 1<<0);
define('S_MENTIONS', 1<<1);
define('S_MARKDOWN', 1<<2);
define('S_HTML_COMMENTS', 1<<3);
define('F_UNVALIDATED_USER', 1<<0);
define('F_SECRET_PR', 1<<1);
//CONFIGS ARE IN SECRET.PHP, THESE ARE JUST DEFAULTS!
$hookSecret = '08ajh0qj93209qj90jfq932j32r';
$apiKey = '209ab8d879c0f987d06a09b9d879c0f987d06a09b9d8787d0a089c';
$repoOwnerAndName = "tgstation/tgstation"; // this is just the repository auto-updates happen from
$repoAutoTaggerWhitelist = array("tgstation", "TerraGov-Marine-Corps");
$servers = array();
$enable_live_tracking = true;
$path_to_script = 'tools/WebhookProcessor/github_webhook_processor.php';
$tracked_branch = "master";
$maintainer_team_id = 133041;
$validation = "org";
$validation_count = 1;
$tracked_branch = 'master';
$require_changelogs = false;
$discordWebHooks = array();
// Only these repositories will announce in game.
// Any repository that players actually care about.
$game_announce_whitelist = array(
"tgstation",
"TerraGov-Marine-Corps",
);
// Any repository that matches in this blacklist will not appear on Discord.
$discord_announce_blacklist = array(
"/^event-.*$/",
);
require_once 'secret.php';
//CONFIG END
function log_error($msg) {
echo htmlSpecialChars($msg);
file_put_contents('htwebhookerror.log', '['.date(DATE_ATOM).'] '.$msg.PHP_EOL, FILE_APPEND);
}
set_error_handler(function($severity, $message, $file, $line) {
throw new \ErrorException($message, 0, $severity, $file, $line);
});
set_exception_handler(function($e) {
header('HTTP/1.1 500 Internal Server Error');
log_error('Error on line ' . $e->getLine() . ': ' . $e->getMessage());
die();
});
$rawPost = NULL;
if (!$hookSecret || $hookSecret == '08ajh0qj93209qj90jfq932j32r')
throw new \Exception("Hook secret is required and can not be default");
if (!isset($_SERVER['HTTP_X_HUB_SIGNATURE'])) {
throw new \Exception("HTTP header 'X-Hub-Signature' is missing.");
} elseif (!extension_loaded('hash')) {
throw new \Exception("Missing 'hash' extension to check the secret code validity.");
}
list($algo, $hash) = explode('=', $_SERVER['HTTP_X_HUB_SIGNATURE'], 2) + array('', '');
if (!in_array($algo, hash_algos(), TRUE)) {
throw new \Exception("Hash algorithm '$algo' is not supported.");
}
$rawPost = file_get_contents('php://input');
if ($hash !== hash_hmac($algo, $rawPost, $hookSecret)) {
throw new \Exception('Hook secret does not match.');
}
$contenttype = null;
//apache and nginx/fastcgi/phpfpm call this two different things.
if (!isset($_SERVER['HTTP_CONTENT_TYPE'])) {
if (!isset($_SERVER['CONTENT_TYPE']))
throw new \Exception("Missing HTTP 'Content-Type' header.");
else
$contenttype = $_SERVER['CONTENT_TYPE'];
} else {
$contenttype = $_SERVER['HTTP_CONTENT_TYPE'];
}
if (!isset($_SERVER['HTTP_X_GITHUB_EVENT'])) {
throw new \Exception("Missing HTTP 'X-Github-Event' header.");
}
switch ($contenttype) {
case 'application/json':
$json = $rawPost ?: file_get_contents('php://input');
break;
case 'application/x-www-form-urlencoded':
$json = $_POST['payload'];
break;
default:
throw new \Exception("Unsupported content type: $contenttype");
}
# Payload structure depends on triggered event
# https://developer.github.com/v3/activity/events/types/
$payload = json_decode($json, true);
switch (strtolower($_SERVER['HTTP_X_GITHUB_EVENT'])) {
case 'ping':
echo 'pong';
break;
case 'pull_request':
handle_pr($payload);
break;
case 'pull_request_review':
if($payload['action'] == 'submitted'){
$lower_state = strtolower($payload['review']['state']);
if(($lower_state == 'approved' || $lower_state == 'changes_requested') && is_maintainer($payload, $payload['review']['user']['login']))
remove_ready_for_review($payload);
}
break;
default:
header('HTTP/1.0 404 Not Found');
echo "Event:$_SERVER[HTTP_X_GITHUB_EVENT] Payload:\n";
print_r($payload); # For debug only. Can be found in GitHub hook log.
die();
}
function apisend($url, $method = 'GET', $content = null, $authorization = null) {
if (is_array($content))
$content = json_encode($content);
$headers = array();
$headers[] = 'Content-type: application/json';
if ($authorization)
$headers[] = 'Authorization: ' . $authorization;
$scontext = array('http' => array(
'method' => $method,
'header' => implode("\r\n", $headers),
'ignore_errors' => true,
'user_agent' => 'tgstation13.org-Github-Automation-Tools'
));
if ($content)
$scontext['http']['content'] = $content;
return file_get_contents($url, false, stream_context_create($scontext));
}
function github_apisend($url, $method = 'GET', $content = NULL) {
global $apiKey;
return apisend($url, $method, $content, 'token ' . $apiKey);
}
function discord_webhook_send($webhook, $content) {
return apisend($webhook, 'POST', $content);
}
function validate_user($payload) {
global $validation, $validation_count;
$query = array();
if (empty($validation))
$validation = 'org';
switch (strtolower($validation)) {
case 'disable':
return TRUE;
case 'repo':
$query['repo'] = $payload['pull_request']['base']['repo']['full_name'];
break;
default:
$query['user'] = $payload['pull_request']['base']['repo']['owner']['login'];
break;
}
$query['author'] = $payload['pull_request']['user']['login'];
$query['is'] = 'merged';
$querystring = '';
foreach($query as $key => $value)
$querystring .= ($querystring == '' ? '' : '+') . urlencode($key) . ':' . urlencode($value);
$res = github_apisend('https://api.github.com/search/issues?q='.$querystring);
$res = json_decode($res, TRUE);
return (isset($res['total_count']) && $res['total_count'] >= (int)$validation_count);
}
function get_labels($payload){
$url = $payload['pull_request']['issue_url'] . '/labels';
$existing_labels = json_decode(github_apisend($url), true);
$existing = array();
foreach((array) $existing_labels as $label)
$existing[] = $label['name'];
return $existing;
}
function remove_ready_for_review($payload, $labels = null){
if($labels == null)
$labels = get_labels($payload);
$index = array_search('Needs Review', $labels);
if($index !== FALSE)
unset($labels[$index]);
$url = $payload['pull_request']['issue_url'] . '/labels';
github_apisend($url, 'PUT', $labels);
}
function dismiss_review($payload, $id, $reason){
$content = array('message' => $reason);
github_apisend($payload['pull_request']['url'] . '/reviews/' . $id . '/dismissals', 'PUT', $content);
}
function get_reviews($payload){
return json_decode(github_apisend($payload['pull_request']['url'] . '/reviews'), true);
}
function check_dismiss_changelog_review($payload){
global $require_changelogs;
global $no_changelog;
if(!$require_changelogs)
return;
if(!$no_changelog)
checkchangelog($payload);
$review_message = 'Your changelog for this PR is either malformed or non-existent. Please create one to document your changes.';
$reviews = get_reviews($payload);
if($no_changelog){
//check and see if we've already have this review
foreach($reviews as $R)
if($R['body'] == $review_message && strtolower($R['state']) == 'changes_requested')
return;
//otherwise make it ourself
github_apisend($payload['pull_request']['url'] . '/reviews', 'POST', array('body' => $review_message, 'event' => 'REQUEST_CHANGES'));
}
else
//kill previous reviews
foreach($reviews as $R)
if($R['body'] == $review_message && strtolower($R['state']) == 'changes_requested')
dismiss_review($payload, $R['id'], 'Changelog added/fixed.');
}
function is_blacklisted($blacklist, $name) {
foreach ($blacklist as $pattern) {
if (preg_match($pattern, $name)) {
return true;
}
}
return false;
}
function handle_pr($payload) {
global $discord_announce_blacklist;
global $no_changelog;
global $game_announce_whitelist;
$action = 'opened';
$validated = validate_user($payload);
switch ($payload["action"]) {
case 'opened':
if($no_changelog)
check_dismiss_changelog_review($payload);
break;
case 'edited':
check_dismiss_changelog_review($payload);
case 'synchronize':
return;
case 'reopened':
$action = $payload['action'];
break;
case 'closed':
if (!$payload['pull_request']['merged']) {
$action = 'closed';
}
else {
$action = 'merged';
auto_update($payload);
checkchangelog($payload);
$validated = TRUE; //pr merged events always get announced.
}
break;
default:
return;
}
$pr_flags = 0;
if (strpos(strtolower($payload['pull_request']['title']), '[s]') !== false) {
$pr_flags |= F_SECRET_PR;
}
if (!$validated) {
$pr_flags |= F_UNVALIDATED_USER;
}
$repo_name = $payload['repository']['name'];
if (!is_blacklisted($discord_announce_blacklist, $repo_name)) {
discord_announce($action, $payload, $pr_flags);
}
if (in_array($repo_name, $game_announce_whitelist)) {
game_announce($action, $payload, $pr_flags);
}
}
function filter_announce_targets($targets, $owner, $repo, $action, $pr_flags) {
foreach ($targets as $i=>$target) {
if (isset($target['exclude_events']) && in_array($action, array_map('strtolower', $target['exclude_events']))) {
unset($targets[$i]);
continue;
}
if (isset($target['announce_secret']) && $target['announce_secret']) {
if (!($pr_flags & F_SECRET_PR) && $target['announce_secret'] === 'only') {
unset($targets[$i]);
continue;
}
} else if ($pr_flags & F_SECRET_PR) {
unset($targets[$i]);
continue;
}
if (isset($target['announce_unvalidated']) && $target['announce_unvalidated']) {
if (!($pr_flags & F_UNVALIDATED_USER) && $target['announce_unvalidated'] === 'only') {
unset($targets[$i]);
continue;
}
} else if ($pr_flags & F_UNVALIDATED_USER) {
unset($targets[$i]);
continue;
}
$wildcard = false;
if (isset($target['include_repos'])) {
foreach ($target['include_repos'] as $match_string) {
$owner_repo_pair = explode('/', strtolower($match_string));
if (count($owner_repo_pair) != 2) {
log_error('Bad include repo: `'. $match_string.'`');
continue;
}
if (strtolower($owner) == $owner_repo_pair[0]) {
if (strtolower($repo) == $owner_repo_pair[1])
continue 2; //don't parse excludes when we have an exact include match
if ($owner_repo_pair[1] == '*') {
$wildcard = true;
continue; //do parse excludes when we have a wildcard match (but check the other entries for exact matches first)
}
}
}
if (!$wildcard) {
unset($targets[$i]);
continue;
}
}
if (isset($target['exclude_repos']))
foreach ($target['exclude_repos'] as $match_string) {
$owner_repo_pair = explode('/', strtolower($match_string));
if (count($owner_repo_pair) != 2) {
log_error('Bad exclude repo: `'. $match_string.'`');
continue;
}
if (strtolower($owner) == $owner_repo_pair[0]) {
if (strtolower($repo) == $owner_repo_pair[1]) {
unset($targets[$i]);
continue 2;
}
if ($owner_repo_pair[1] == '*') {
if ($wildcard)
log_error('Identical wildcard include and exclude: `'.$match_string.'`. Excluding.');
unset($targets[$i]);
continue 2;
}
}
}
}
return $targets;
}
function game_announce($action, $payload, $pr_flags) {
global $servers;
$msg = '['.$payload['pull_request']['base']['repo']['full_name'].'] Pull Request '.$action.' by '.htmlSpecialChars($payload['sender']['login']).': <a href="'.$payload['pull_request']['html_url'].'">'.htmlSpecialChars('#'.$payload['pull_request']['number'].' '.$payload['pull_request']['user']['login'].' - '.$payload['pull_request']['title']).'</a>';
$game_servers = filter_announce_targets($servers, $payload['pull_request']['base']['repo']['owner']['login'], $payload['pull_request']['base']['repo']['name'], $action, $pr_flags);
$game_payload = array();
$game_payload['pull_request'] = array();
$game_payload['pull_request']['id'] = $payload['pull_request']['id'];
$msg = '?announce='.urlencode($msg).'&payload='.urlencode(json_encode($game_payload));
foreach ($game_servers as $serverid => $server) {
try {
$server_message = $msg;
if (isset($server['comskey']))
$server_message .= '&key='.urlencode($server['comskey']);
game_server_send($server['address'], $server['port'], $server_message);
} catch (exception $e) {
log_error('Error on line ' . $e->getLine() . ': ' . $e->getMessage());
continue;
}
}
}
function discord_announce($action, $payload, $pr_flags) {
global $discordWebHooks;
$color;
switch ($action) {
case 'reopened':
case 'opened':
$color = 0x2cbe4e;
break;
case 'closed':
$color = 0xcb2431;
break;
case 'merged':
$color = 0x6f42c1;
break;
default:
return;
}
$data = array(
'username' => 'GitHub',
'avatar_url' => $payload['pull_request']['base']['user']['avatar_url'],
);
$content = 'Pull Request #'.$payload['pull_request']['number'].' *'.$action.'* by '.discord_sanitize($payload['sender']['login'])."\n".discord_sanitize($payload['pull_request']['user']['login']).' - __**'.discord_sanitize($payload['pull_request']['title']).'**__'."\n".'<'.$payload['pull_request']['html_url'].'>';
$embeds = array(
array(
'title' => '__**'.discord_sanitize($payload['pull_request']['title'], S_MARKDOWN).'**__',
'description' => discord_sanitize(str_replace(array("\r\n", "\n"), array(' ', ' '), substr($payload['pull_request']['body'], 0, 320)), S_HTML_COMMENTS),
'url' => $payload['pull_request']['html_url'],
'color' => $color,
'author' => array(
'name' => discord_sanitize($payload['pull_request']['user']['login'], S_MARKDOWN),
'url' => $payload['pull_request']['user']['html_url'],
'icon_url' => $payload['pull_request']['user']['avatar_url']
),
'footer' => array(
'text' => '#'.$payload['pull_request']['number'].' '.discord_sanitize($payload['pull_request']['base']['repo']['full_name'], S_MARKDOWN).' '.discord_sanitize($payload['pull_request']['head']['ref'], S_MARKDOWN).' -> '.discord_sanitize($payload['pull_request']['base']['ref'], S_MARKDOWN),
'icon_url' => $payload['pull_request']['base']['user']['avatar_url']
)
)
);
$discordWebHook_targets = filter_announce_targets($discordWebHooks, $payload['pull_request']['base']['repo']['owner']['login'], $payload['pull_request']['base']['repo']['name'], $action, $pr_flags);
foreach ($discordWebHook_targets as $discordWebHook) {
$sending_data = $data;
if (isset($discordWebHook['embed']) && $discordWebHook['embed']) {
$sending_data['embeds'] = $embeds;
if (!isset($discordWebHook['no_text']) || !$discordWebHook['no_text'])
$sending_data['content'] = $content;
} else {
$sending_data['content'] = $content;
}
discord_webhook_send($discordWebHook['url'], $sending_data);
}
}
function discord_sanitize($text, $flags = S_MENTIONS|S_LINK_EMBED|S_MARKDOWN) {
if ($flags & S_MARKDOWN)
$text = str_ireplace(array('\\', '*', '_', '~', '`', '|'), (array('\\\\', '\\*', '\\_', '\\~', '\\`', '\\|')), $text);
if ($flags & S_HTML_COMMENTS)
$text = preg_replace('/<!--(.*)-->/Uis', '', $text);
if ($flags & S_MENTIONS)
$text = str_ireplace(array('@everyone', '@here', '<@'), array('`@everyone`', '`@here`', '@<'), $text);
if ($flags & S_LINK_EMBED)
$text = preg_replace("/((https?|ftp|byond)\:\/\/)([a-z0-9-.]*)\.([a-z]{2,3})(\:[0-9]{2,5})?(\/(?:[a-z0-9+\$_-]\.?)+)*\/?(\?[a-z+&\$_.-][a-z0-9;:@&%=+\/\$_.-]*)?(#[a-z_.-][a-z0-9+\$_.-]*)?/mi", '<$0>', $text);
return $text;
}
//creates a comment on the payload issue
function create_comment($payload, $comment){
github_apisend($payload['pull_request']['comments_url'], 'POST', json_encode(array('body' => $comment)));
}
function is_maintainer($payload, $author){
global $maintainer_team_id;
$repo_is_org = $payload['pull_request']['base']['repo']['owner']['type'] == 'Organization';
if($maintainer_team_id == null || !$repo_is_org) {
$collaburl = str_replace('{/collaborator}', '/' . $author, $payload['pull_request']['base']['repo']['collaborators_url']) . '/permission';
$perms = json_decode(github_apisend($collaburl), true);
$permlevel = $perms['permission'];
return $permlevel == 'admin' || $permlevel == 'write';
}
else {
$check_url = 'https://api.github.com/teams/' . $maintainer_team_id . '/memberships/' . $author;
$result = json_decode(github_apisend($check_url), true);
return isset($result['state']) && $result['state'] == 'active';
}
}
$github_diff = null;
function get_diff($payload) {
global $github_diff;
if ($github_diff === null && $payload['pull_request']['diff_url']) {
//go to the diff url
$url = $payload['pull_request']['diff_url'];
$github_diff = file_get_contents($url);
}
return $github_diff;
}
function auto_update($payload){
global $enable_live_tracking;
global $path_to_script;
global $repoOwnerAndName;
global $tracked_branch;
global $github_diff;
if(!$enable_live_tracking || !has_tree_been_edited($payload, $path_to_script) || $payload['pull_request']['base']['ref'] != $tracked_branch)
return;
get_diff($payload);
$content = file_get_contents('https://raw.githubusercontent.com/' . $repoOwnerAndName . '/' . $tracked_branch . '/'. $path_to_script);
$content_diff = "### Diff not available. :slightly_frowning_face:";
if($github_diff && preg_match('/(diff --git a\/' . preg_quote($path_to_script, '/') . '.+?)(?:\Rdiff|$)/s', $github_diff, $matches)) {
$script_diff = $matches[1];
if($script_diff) {
$content_diff = "``" . "`DIFF\n" . $script_diff ."\n``" . "`";
}
}
create_comment($payload, "Edit detected. Self updating... \n<details><summary>Here are my changes:</summary>\n\n" . $content_diff . "\n</details>\n<details><summary>Here is my new code:</summary>\n\n``" . "`HTML+PHP\n" . $content . "\n``" . '`\n</details>');
$code_file = fopen(basename($path_to_script), 'w');
fwrite($code_file, $content);
fclose($code_file);
}
function has_tree_been_edited($payload, $tree){
global $github_diff;
get_diff($payload);
//find things in the _maps/map_files tree
//e.g. diff --git a/_maps/map_files/Cerestation/cerestation.dmm b/_maps/map_files/Cerestation/cerestation.dmm
return ($github_diff !== FALSE) && (preg_match('/^diff --git a\/' . preg_quote($tree, '/') . '/m', $github_diff) !== 0);
}
$no_changelog = false;
function checkchangelog($payload) {
global $no_changelog;
if (!isset($payload['pull_request']) || !isset($payload['pull_request']['body'])) {
return array();
}
if (!isset($payload['pull_request']['user']) || !isset($payload['pull_request']['user']['login'])) {
return array();
}
$body = $payload['pull_request']['body'];
$tags = array();
if(preg_match('/(?i)(fix|fixes|fixed|resolve|resolves|resolved)\s*#[0-9]+/',$body)) //github autoclose syntax
$tags[] = 'Fix';
$body = str_replace("\r\n", "\n", $body);
$body = explode("\n", $body);
$incltag = false;
$foundcltag = false;
foreach ($body as $line) {
$line = trim($line);
if (substr($line,0,4) == ':cl:' || substr($line,0,1) == '??') {
$incltag = true;
$foundcltag = true;
continue;
} else if (substr($line,0,5) == '/:cl:' || substr($line,0,6) == '/ :cl:' || substr($line,0,5) == ':/cl:' || substr($line,0,5) == '/??' || substr($line,0,6) == '/ ??' ) {
$incltag = false;
continue;
}
if (!$incltag)
continue;
$firstword = explode(' ', $line)[0];
$pos = strpos($line, " ");
$item = '';
if ($pos) {
$firstword = trim(substr($line, 0, $pos));
$item = trim(substr($line, $pos+1));
} else {
$firstword = $line;
}
// Line is empty
if (!strlen($firstword)) {
continue;
}
//not a prefix line.
if (!strlen($firstword) || $firstword[strlen($firstword)-1] != ':') {
continue;
}
$cltype = strtolower(substr($firstword, 0, -1));
// !!!
// !!! If you are changing any of these at the bottom, also edit `tools/pull_request_hooks/changelogConfig.js`.
// !!!
switch ($cltype) {
case 'fix':
case 'fixes':
case 'bugfix':
if($item != 'fixed a few things') {
$tags[] = 'Fix';
}
break;
case 'qol':
if($item != 'made something easier to use') {
$tags[] = 'Quality of Life';
}
break;
case 'sound':
if($item != 'added/modified/removed audio or sound effects') {
$tags[] = 'Sound';
}
break;
case 'add':
case 'adds':
case 'rscadd':
if($item != 'Added new mechanics or gameplay changes' && $item != 'Added more things') {
$tags[] = 'Feature';
}
break;
case 'del':
case 'dels':
case 'rscdel':
if($item != 'Removed old things') {
$tags[] = 'Removal';
}
break;
case 'image':
if($item != 'added/modified/removed some icons or images') {
$tags[] = 'Sprites';
}
break;
case 'typo':
case 'spellcheck':
if($item != 'fixed a few typos') {
$tags[] = 'Grammar and Formatting';
}
break;
case 'balance':
if($item != 'rebalanced something'){
$tags[] = 'Balance';
}
break;
case 'code_imp':
case 'code':
if($item != 'changed some code'){
$tags[] = 'Code Improvement';
}
break;
case 'refactor':
if($item != 'refactored some code'){
$tags[] = 'Refactor';
}
break;
case 'config':
if($item != 'changed some config setting'){
$tags[] = 'Config Update';
}
break;
case 'admin':
if($item != 'messed with admin stuff'){
$tags[] = 'Administration';
}
break;
}
}
return $tags;
}
function game_server_send($addr, $port, $str) {
// All queries must begin with a question mark (ie "?players")
if($str[0] != '?') $str = ('?' . $str);
/* --- Prepare a packet to send to the server (based on a reverse-engineered packet structure) --- */
$query = "\x00\x83" . pack('n', strlen($str) + 6) . "\x00\x00\x00\x00\x00" . $str . "\x00";
/* --- Create a socket and connect it to the server --- */
$server = socket_create(AF_INET,SOCK_STREAM,SOL_TCP) or exit("ERROR");
socket_set_option($server, SOL_SOCKET, SO_SNDTIMEO, array('sec' => 2, 'usec' => 0)); //sets connect and send timeout to 2 seconds
if(!@socket_connect($server,$addr,$port)) {
return "ERROR: Connection failed";
}
/* --- Send bytes to the server. Loop until all bytes have been sent --- */
$bytestosend = strlen($query);
$bytessent = 0;
while ($bytessent < $bytestosend) {
//echo $bytessent.'<br>';
$result = socket_write($server,substr($query,$bytessent),$bytestosend-$bytessent);
//echo 'Sent '.$result.' bytes<br>';
if ($result===FALSE)
return "ERROR: " . socket_strerror(socket_last_error());
$bytessent += $result;
}
/* --- Idle for a while until received bytes from game server --- */
$result = @socket_read($server, 10000, PHP_BINARY_READ);
@socket_close($server); // we don't need this anymore
if($result != "") {
if($result[0] == "\x00" || $result[1] == "\x83") { // make sure it's the right packet format
// Actually begin reading the output:
$sizebytes = unpack('n', $result[2] . $result[3]); // array size of the type identifier and content
$size = $sizebytes[1] - 1; // size of the string/floating-point (minus the size of the identifier byte)
if($result[4] == "\x2a") { // 4-byte big-endian floating-point
$unpackint = unpack('f', $result[5] . $result[6] . $result[7] . $result[8]); // 4 possible bytes: add them up together, unpack them as a floating-point
return $unpackint[1];
}
else if($result[4] == "\x06") { // ASCII string
$unpackstr = ""; // result string
$index = 5; // string index
while($size > 0) { // loop through the entire ASCII string
$size--;
$unpackstr .= $result[$index]; // add the string position to return string
$index++;
}
return $unpackstr;
}
}
}
return "";
}
?>

View File

@@ -1,136 +0,0 @@
<?php
//This file contains things that should not be touched by the automatic live tracker
//Github lets you have it sign the message with a secret that you can validate. This prevents people from faking events.
//This var should match the secret you configured for this webhook on github.
//This is required as otherwise somebody could trick the script into leaking the api key.
$hookSecret = '08ajh0qj93209qj90jfq932j32r';
//Api key for pushing changelogs.
//This requires the public_repo (or repo for private repositories) and read:org permissions
$apiKey = '209ab8d879c0f987d06a09b9d879c0f987d06a09b9d8787d0a089c';
//The repository auto-updates are sourced from.
$repoOwnerAndName = "tgstation/tgstation";
//Whitelist of repository names that have PRs auto-tagged
$repoAutoTaggerWhitelist = array("tgstation", "TerraGov-Marine-Corps");
//Auto update settings
$enable_live_tracking = true; //auto update this file from the repository
$path_to_script = 'tools/WebhookProcessor/github_webhook_processor.php';
$tracked_branch = "master";
//PR balance settings.
$trackPRBalance = true; //set this to false to disable PR balance tracking
$prBalanceJson = ''; //Set this to the path you'd like the writable pr balance file to be stored, not setting it writes it to the working directory
$startingPRBalance = 5; //Starting balance for never before seen users
//team 133041: tgstation/commit-access
$maintainer_team_id = 133041; //org team id that is exempt from PR balance system, setting this to null will use anyone with write access to the repo. Get from https://api.github.com/orgs/:org/teams
//anti-spam measures. Don't announce PRs in game to people unless they've gotten a pr merged before
//options are:
// "repo" - user has to have a pr merged in the repo before.
// "org" - user has to have a pr merged in any repo in the organization (for repos owned directly by users, this applies to any repo directly owned by the same user.)
// "disable" - disables.
//defaults to org if left blank or given invalid values.
//This can also be ignored on a per webhook or per game server bases.
$validation = "org";
//how many merged prs must they have under the rules above to have their pr announced to the game servers.
$validation_count = 1;
//enforce changelogs on PRs
$require_changelogs = false;
/*
* Announcement Settings.
* Allows you to announce prs to discord webhooks or the game servers
*/
/* Common configs:
The following config items can be added to both game server and discord announcement endpoints. Simply replace the $servers part with $discordWebHooks:
include_repos - List of repos in owner/repo format to send to this endpoint. (defaults to all repos if not defined)
* can be given in place of repo to match all repos under an organization
$servers[$configitem]['include_repos'][] = "tgstation/*";
exclude_repos - List of repos in owner/repo format to not send to this endpoint.
* can be given in place of repo to match all repos under an organization
$servers[$configitem]['exclude_repos'][] = 'tgstation/TerraGov-Marine-Corps';
$servers[$configitem]['exclude_repos'][] = 'tgstation/tgstation13.org';
exclude_events - List of events to not announce, values: opened, closed, reopened, or merged
$servers[$configitem]['exclude_events'][] = 'closed';
$servers[$configitem]['exclude_events'][] = 'reopened';
announce_secret - Announce secret/security prs that have a [s] in front of the title? Defaults to no.
Can also be set to 'only' to only announce secret prs.
$servers[$configitem]['announce_secret'] = false;
$servers[$configitem]['announce_secret'] = 'only';
announce_unvalidated - Announce prs by unvalidated users (see the validation setting above)? Defaults to no.
Can also be set to 'only' to only announce prs by unvalidated users.
$servers[$configitem]['announce_unvalidated'] = false;
//Note: the same webhook or game server can be given in mutiple announce endpoints with different settings, allowing you to say, have embeds only show on prs to certain repos by excluding the repo in an endpoint with embed = false, and including the repo in an endpoint with embed = true true. This could also be used to only block closed and reopened events on prs by unvalidated users.
//Game servers to announce PRs to.
/*
$configitem = -1;//ignore me
//Game Server Start
$servers[++$configitem] = array();
$servers[$configitem]['address'] = 'game.tgstation13.org';
$servers[$configitem]['port'] = '1337';
$servers[$configitem]['comskey'] = '89aj90cq2fm0amc90832mn9rm90';
//Game Server End
//Game Server Start
$servers[++$configitem] = array();
$servers[$configitem]['address'] = 'game.tgstation13.org';
$servers[$configitem]['port'] = '2337';
$servers[$configitem]['comskey'] = '89aj90cq2fm0amc90832mn9rm90';
//Game Server End
unset($configitem);//ignore
*/
//discord webhooks to announce PRs to.
/*
$configitem = -1;//ignore me
//Discord Webhook Start
$discordWebHooks[++$configitem] = array();
// Webhook Url (you can get this from discord via the webhook setting menu of the server or a channel.)
$discordWebHooks[$configitem]['url'] = 'https://discord.com/api/webhooks/538933489920245771/xaoYtVuype-P1rb_uthQLkh_C4iVL3sjtIvFEp7rsfhbBs8tDsSJgE0a9MNWJaoSPBPK';
// show an embed with more info?
$discordWebHooks[$configitem]['embed'] = true;
// if the above is true, don't include the text portion before the embed.
// (This option is not advised as it's not compatible with users who disable embeds).
$discordWebHooks[$configitem]['no_text'] = false;
//Discord Webhook End
//Discord Webhook Start
$discordWebHooks[++$configitem] = array();
// Webhook Url (you can get this from discord via the webhook setting menu of the server or a channel.)
$discordWebHooks[$configitem]['url'] = 'https://discord.com/api/webhooks/538933686956064769/q0uDel7S6eutvRIyEwsuZo_ppzAoxqUNeU2PRChYVsYoJmmn2f2YYSDoMjy9FhhXKqpI';
// show an embed with more info?
$discordWebHooks[$configitem]['embed'] = true;
// if the above is true, don't include the text portion before the embed.
// (This option is not advised as it's not compatible with users who disable embeds).
$discordWebHooks[$configitem]['no_text'] = false;
//Discord Webhook End
*/
unset($configitem); //ignore