Compare commits

...
Author SHA1 Message Date
benbierens 4adce837ec Logs total run duration in overview log. 2023-10-23 10:32:11 +02:00
benbierens e11a7d1600 Gives deployments a name. 2023-10-23 10:19:52 +02:00
benbierens ad70394333 Merge branch 'master' into app/discord-bot 2023-10-23 10:13:23 +02:00
benbierens 50fbf0ad52 Replaces retry-time with maxNumberOfRetries in timesets. 2023-10-23 10:11:02 +02:00
benbierens 45fbd699a9 Disables calls to custom API endpoints. 2023-10-23 09:36:31 +02:00
benbierens bf18fa03a2 adds sleep to the starting of the test screens 2023-10-22 11:29:16 +02:00
benbierens 116f62e73e Adds eth address validation. 2023-10-22 11:26:00 +02:00
benbierens 8ef2e6023e All works 2023-10-22 11:10:45 +02:00
benbierens e16b1ce079 Sets up guild role checking 2023-10-22 10:38:46 +02:00
benbierens 4aa4731480 setting up all the commands 2023-10-22 10:10:52 +02:00
benbierens 8ad2dee67c Adds user repo. 2023-10-22 09:32:03 +02:00
benbierens 869aeb9253 Deals with timeout for operations that may take a while. 2023-10-20 11:20:38 +02:00
benbierens 8910c7ff27 Stores contracts deployment as part of CodexDeployment json. 2023-10-20 10:15:38 +02:00
benbierens 2b10f2ec58 Adds mint command 2023-10-20 10:14:56 +02:00
benbierens 991927b95f Setting up balance-getting command 2023-10-20 09:49:23 +02:00
benbierens b1bd1de027 Merge branch 'feature/multiple-container-addresses' 2023-10-20 08:31:45 +02:00
benbierens 3b258c9e2e Pins contract image to one compatible with current main codex 2023-10-20 08:31:23 +02:00
benbierens 766e2f5c20 Very basic endpoint pinging that might not even work. 2023-10-18 14:59:39 +02:00
benbierens 888b19d8e5 working example of slash commands with arguments 2023-10-18 13:55:56 +02:00
benbierens f33866efc1 setting up slash commands 2023-10-18 13:48:15 +02:00
benbierens 8c7229504e do not print token 2023-10-18 11:21:06 +02:00
benbierens bcb05cd0c9 Dockerizes discord bot 2023-10-18 11:01:24 +02:00
benbierens 7179c70463 Sets up an echo command 2023-10-18 09:10:04 +02:00
benbierens b3da42522f Sets up project 2023-10-18 08:57:59 +02:00
36 changed files with 972 additions and 46 deletions
+1 -1
View File
@@ -196,7 +196,7 @@ namespace Core
private T Retry<T>(Func<T> operation, string description)
{
return Time.Retry(operation, timeSet.HttpCallRetryTime(), timeSet.HttpCallRetryDelay(), description);
return Time.Retry(operation, timeSet.HttpMaxNumberOfRetries(), timeSet.HttpCallRetryDelay(), description);
}
private HttpClient GetClient()
+6 -6
View File
@@ -3,7 +3,7 @@
public interface ITimeSet
{
TimeSpan HttpCallTimeout();
TimeSpan HttpCallRetryTime();
int HttpMaxNumberOfRetries();
TimeSpan HttpCallRetryDelay();
TimeSpan WaitForK8sServiceDelay();
TimeSpan K8sOperationTimeout();
@@ -13,12 +13,12 @@
{
public TimeSpan HttpCallTimeout()
{
return TimeSpan.FromMinutes(5);
return TimeSpan.FromMinutes(3);
}
public TimeSpan HttpCallRetryTime()
public int HttpMaxNumberOfRetries()
{
return TimeSpan.FromMinutes(1);
return 3;
}
public TimeSpan HttpCallRetryDelay()
@@ -44,9 +44,9 @@
return TimeSpan.FromHours(2);
}
public TimeSpan HttpCallRetryTime()
public int HttpMaxNumberOfRetries()
{
return TimeSpan.FromHours(5);
return 1;
}
public TimeSpan HttpCallRetryDelay()
+18 -12
View File
@@ -46,33 +46,35 @@
public static void Retry(Action action, string description)
{
Retry(action, TimeSpan.FromMinutes(1), description);
Retry(action, 1, description);
}
public static T Retry<T>(Func<T> action, string description)
{
return Retry(action, TimeSpan.FromMinutes(1), description);
return Retry(action, 1, description);
}
public static void Retry(Action action, TimeSpan timeout, string description)
public static void Retry(Action action, int maxRetries, string description)
{
Retry(action, timeout, TimeSpan.FromSeconds(1), description);
Retry(action, maxRetries, TimeSpan.FromSeconds(1), description);
}
public static T Retry<T>(Func<T> action, TimeSpan timeout, string description)
public static T Retry<T>(Func<T> action, int maxRetries, string description)
{
return Retry(action, timeout, TimeSpan.FromSeconds(1), description);
return Retry(action, maxRetries, TimeSpan.FromSeconds(1), description);
}
public static void Retry(Action action, TimeSpan timeout, TimeSpan retryTime, string description)
public static void Retry(Action action, int maxRetries, TimeSpan retryTime, string description)
{
var start = DateTime.UtcNow;
var retries = 0;
var exceptions = new List<Exception>();
while (true)
{
if (DateTime.UtcNow - start > timeout)
if (retries > maxRetries)
{
throw new TimeoutException($"Retry '{description}' of {timeout.TotalSeconds} seconds timed out.", new AggregateException(exceptions));
var duration = DateTime.UtcNow - start;
throw new TimeoutException($"Retry '{description}' timed out after {maxRetries} tries over {Time.FormatDuration(duration)}.", new AggregateException(exceptions));
}
try
@@ -83,21 +85,24 @@
catch (Exception ex)
{
exceptions.Add(ex);
retries++;
}
Sleep(retryTime);
}
}
public static T Retry<T>(Func<T> action, TimeSpan timeout, TimeSpan retryTime, string description)
public static T Retry<T>(Func<T> action, int maxRetries, TimeSpan retryTime, string description)
{
var start = DateTime.UtcNow;
var retries = 0;
var exceptions = new List<Exception>();
while (true)
{
if (DateTime.UtcNow - start > timeout)
if (retries > maxRetries)
{
throw new TimeoutException($"Retry '{description}' of {timeout.TotalSeconds} seconds timed out.", new AggregateException(exceptions));
var duration = DateTime.UtcNow - start;
throw new TimeoutException($"Retry '{description}' timed out after {maxRetries} tries over {Time.FormatDuration(duration)}.", new AggregateException(exceptions));
}
try
@@ -107,6 +112,7 @@
catch (Exception ex)
{
exceptions.Add(ex);
retries++;
}
Sleep(retryTime);
@@ -4,7 +4,7 @@ namespace CodexContractsPlugin
{
public class CodexContractsContainerRecipe : ContainerRecipeFactory
{
public static string DockerImage { get; } = "codexstorage/codex-contracts-eth:latest-dist-tests";
public static string DockerImage { get; } = "codexstorage/codex-contracts-eth:sha-1854dfb-dist-tests";
public const string MarketplaceAddressFilename = "/hardhat/deployments/codexdisttestnetwork/Marketplace.json";
public const string MarketplaceArtifactFilename = "/hardhat/artifacts/contracts/Marketplace.sol/Marketplace.json";
@@ -1,28 +1,32 @@
using GethPlugin;
using CodexContractsPlugin;
using GethPlugin;
using KubernetesWorkflow;
namespace CodexPlugin
{
public class CodexDeployment
{
public CodexDeployment(RunningContainer[] codexContainers, GethDeployment gethDeployment, RunningContainer? prometheusContainer, DeploymentMetadata metadata)
public CodexDeployment(RunningContainer[] codexContainers, GethDeployment gethDeployment, CodexContractsDeployment codexContractsDeployment, RunningContainer? prometheusContainer, DeploymentMetadata metadata)
{
CodexContainers = codexContainers;
GethDeployment = gethDeployment;
CodexContractsDeployment = codexContractsDeployment;
PrometheusContainer = prometheusContainer;
Metadata = metadata;
}
public RunningContainer[] CodexContainers { get; }
public GethDeployment GethDeployment { get; }
public CodexContractsDeployment CodexContractsDeployment { get; }
public RunningContainer? PrometheusContainer { get; }
public DeploymentMetadata Metadata { get; }
}
public class DeploymentMetadata
{
public DeploymentMetadata(DateTime startUtc, DateTime finishedUtc, string kubeNamespace, int numberOfCodexNodes, int numberOfValidators, int storageQuotaMB, CodexLogLevel codexLogLevel, int initialTestTokens, int minPrice, int maxCollateral, int maxDuration, int blockTTL, int blockMI, int blockMN)
public DeploymentMetadata(string name, DateTime startUtc, DateTime finishedUtc, string kubeNamespace, int numberOfCodexNodes, int numberOfValidators, int storageQuotaMB, CodexLogLevel codexLogLevel, int initialTestTokens, int minPrice, int maxCollateral, int maxDuration, int blockTTL, int blockMI, int blockMN)
{
Name = name;
StartUtc = startUtc;
FinishedUtc = finishedUtc;
KubeNamespace = kubeNamespace;
@@ -39,6 +43,7 @@ namespace CodexPlugin
BlockMN = blockMN;
}
public string Name { get; }
public DateTime StartUtc { get; }
public DateTime FinishedUtc { get; }
public string KubeNamespace { get; }
+3 -2
View File
@@ -13,8 +13,9 @@ namespace CodexPlugin
string GetName();
CodexDebugResponse GetDebugInfo();
CodexDebugPeerResponse GetDebugPeer(string peerId);
CodexDebugBlockExchangeResponse GetDebugBlockExchange();
CodexDebugRepoStoreResponse[] GetDebugRepoStore();
// These debug methods are not available in master-line Codex. Use only for custom builds.
//CodexDebugBlockExchangeResponse GetDebugBlockExchange();
//CodexDebugRepoStoreResponse[] GetDebugRepoStore();
ContentId UploadFile(TrackedFile file);
TrackedFile? DownloadContent(ContentId contentId, string fileLabel = "");
void ConnectToPeer(ICodexNode node);
+5
View File
@@ -13,5 +13,10 @@
}
public string Address { get; }
public override string ToString()
{
return Address;
}
}
}
@@ -28,7 +28,7 @@
public override string ToString()
{
return $"{Wei} Wei";
return $"{Eth} Eth";
}
}
@@ -92,6 +92,7 @@ namespace ContinuousTests
{
var testDuration = Time.FormatDuration(DateTime.UtcNow - startTime);
var testData = FormatTestRuns(testLoops);
overviewLog.Log("Total duration: " + testDuration);
if (config.TargetDurationSeconds > 0)
{
@@ -57,8 +57,8 @@ namespace ContinuousTests.Tests
private void LogRepoStore(ICodexNode codexNode)
{
var response = codexNode.GetDebugRepoStore();
Log.Log($"{codexNode.GetName()} has {string.Join(",", response.Select(r => r.cid))}");
//var response = codexNode.GetDebugRepoStore();
//Log.Log($"{codexNode.GetName()} has {string.Join(",", response.Select(r => r.cid))}");
}
private void LogStoredBytes(ICodexNode node)
@@ -90,8 +90,8 @@ namespace ContinuousTests.Tests
private void LogBlockExchangeStatus(ICodexNode codexNode, string msg)
{
var response = codexNode.GetDebugBlockExchange();
Log.Log($"{codexNode.GetName()} {msg}: {JsonConvert.SerializeObject(response)}");
//var response = codexNode.GetDebugBlockExchange();
//Log.Log($"{codexNode.GetName()} {msg}: {JsonConvert.SerializeObject(response)}");
}
}
}
@@ -9,6 +9,7 @@ cd ../../Tools/CodexNetDeployer
for i in $( seq 0 $replication)
do
dotnet run \
--deploy-name=codex-continuous-$name-$i \
--kube-config=/opt/kubeconfig.yaml \
--kube-namespace=codex-continuous-$name-tests-$i \
--deploy-file=codex-deployment-$name-$i.json \
@@ -44,4 +45,6 @@ do
--cleanup=1 \
--full-container-logs=1 \
--target-duration=172800 # 48 hours
sleep 30
done
@@ -42,21 +42,22 @@ namespace CodexTests.BasicTests
{
foreach (var node in nodes)
{
Time.Retry(() => AssertBlockExchangeIsEmpty(node), nameof(AssertExchangeIsEmpty));
// API Call not available in master-line Codex image.
//Time.Retry(() => AssertBlockExchangeIsEmpty(node), nameof(AssertExchangeIsEmpty));
}
}
private void AssertBlockExchangeIsEmpty(ICodexNode node)
{
var msg = $"BlockExchange for {node.GetName()}: ";
var response = node.GetDebugBlockExchange();
foreach (var peer in response.peers)
{
var activeWants = peer.wants.Where(w => !w.cancel).ToArray();
Assert.That(activeWants.Length, Is.EqualTo(0), msg + "thinks a peer has active wants.");
}
Assert.That(response.taskQueue, Is.EqualTo(0), msg + "has tasks in queue.");
Assert.That(response.pendingBlocks, Is.EqualTo(0), msg + "has pending blocks.");
}
//private void AssertBlockExchangeIsEmpty(ICodexNode node)
//{
// var msg = $"BlockExchange for {node.GetName()}: ";
// var response = node.GetDebugBlockExchange();
// foreach (var peer in response.peers)
// {
// var activeWants = peer.wants.Where(w => !w.cancel).ToArray();
// Assert.That(activeWants.Length, Is.EqualTo(0), msg + "thinks a peer has active wants.");
// }
// Assert.That(response.taskQueue, Is.EqualTo(0), msg + "has tasks in queue.");
// Assert.That(response.pendingBlocks, Is.EqualTo(0), msg + "has pending blocks.");
//}
}
}
+35
View File
@@ -0,0 +1,35 @@
using Discord.WebSocket;
namespace BiblioTech
{
public class AdminChecker
{
private SocketGuild guild = null!;
private ulong[] adminIds = Array.Empty<ulong>();
private DateTime lastUpdate = DateTime.MinValue;
public void SetGuild(SocketGuild guild)
{
this.guild = guild;
}
public bool IsUserAdmin(ulong userId)
{
if (ShouldUpdate()) UpdateAdminIds();
return adminIds.Contains(userId);
}
private bool ShouldUpdate()
{
return !adminIds.Any() || (DateTime.UtcNow - lastUpdate) > TimeSpan.FromMinutes(10);
}
private void UpdateAdminIds()
{
lastUpdate = DateTime.UtcNow;
var adminRole = guild.Roles.Single(r => r.Name == Program.Config.AdminRoleName);
adminIds = adminRole.Members.Select(m => m.Id).ToArray();
}
}
}
+66
View File
@@ -0,0 +1,66 @@
using Discord.WebSocket;
using Discord;
using BiblioTech.Commands;
namespace BiblioTech
{
public abstract class BaseCommand
{
public abstract string Name { get; }
public abstract string StartingMessage { get; }
public abstract string Description { get; }
public virtual CommandOption[] Options
{
get
{
return Array.Empty<CommandOption>();
}
}
public async Task SlashCommandHandler(SocketSlashCommand command)
{
if (command.CommandName != Name) return;
try
{
await command.RespondAsync(StartingMessage);
await Invoke(command);
}
catch (Exception ex)
{
await command.FollowupAsync("Something failed while trying to do that...");
Console.WriteLine(ex);
}
}
protected abstract Task Invoke(SocketSlashCommand command);
protected bool IsSenderAdmin(SocketSlashCommand command)
{
return Program.AdminChecker.IsUserAdmin(command.User.Id);
}
protected ulong GetUserId(UserOption userOption, SocketSlashCommand command)
{
var targetUser = userOption.GetOptionUserId(command);
if (IsSenderAdmin(command) && targetUser != null) return targetUser.Value;
return command.User.Id;
}
}
public class CommandOption
{
public CommandOption(string name, string description, ApplicationCommandOptionType type, bool isRequired)
{
Name = name;
Description = description;
Type = type;
IsRequired = isRequired;
}
public string Name { get; }
public string Description { get; }
public ApplicationCommandOptionType Type { get; }
public bool IsRequired { get; }
}
}
+45
View File
@@ -0,0 +1,45 @@
using CodexContractsPlugin;
using Core;
using Discord.WebSocket;
using GethPlugin;
namespace BiblioTech
{
public abstract class BaseNetCommand : BaseCommand
{
private readonly DeploymentsFilesMonitor monitor;
private readonly CoreInterface ci;
public BaseNetCommand(DeploymentsFilesMonitor monitor, CoreInterface ci)
{
this.monitor = monitor;
this.ci = ci;
}
protected override async Task Invoke(SocketSlashCommand command)
{
var deployments = monitor.GetDeployments();
if (deployments.Length == 0)
{
await command.FollowupAsync("No deployments are currently available.");
return;
}
if (deployments.Length > 1)
{
await command.FollowupAsync("Multiple deployments are online. I don't know which one to pick!");
return;
}
var codexDeployment = deployments.Single();
var gethDeployment = codexDeployment.GethDeployment;
var contractsDeployment = codexDeployment.CodexContractsDeployment;
var gethNode = ci.WrapGethDeployment(gethDeployment);
var contracts = ci.WrapCodexContractsDeployment(contractsDeployment);
await Execute(command, gethNode, contracts);
}
protected abstract Task Execute(SocketSlashCommand command, IGethNode gethNode, ICodexContracts contracts);
}
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Discord.Net" Version="3.12.0" />
<ProjectReference Include="..\..\Framework\ArgsUniform\ArgsUniform.csproj" />
<ProjectReference Include="..\..\ProjectPlugins\CodexPlugin\CodexPlugin.csproj" />
</ItemGroup>
</Project>
+63
View File
@@ -0,0 +1,63 @@
using Discord.Net;
using Discord.WebSocket;
using Discord;
using Newtonsoft.Json;
namespace BiblioTech
{
public class CommandHandler
{
private readonly DiscordSocketClient client;
private readonly BaseCommand[] commands;
public CommandHandler(DiscordSocketClient client, params BaseCommand[] commands)
{
this.client = client;
this.commands = commands;
client.Ready += Client_Ready;
client.SlashCommandExecuted += SlashCommandHandler;
}
private async Task Client_Ready()
{
var guild = client.Guilds.Single(g => g.Name == Program.Config.ServerName);
Program.AdminChecker.SetGuild(guild);
var builders = commands.Select(c =>
{
var builder = new SlashCommandBuilder()
.WithName(c.Name)
.WithDescription(c.Description);
foreach (var option in c.Options)
{
builder.AddOption(option.Name, option.Type, option.Description, isRequired: option.IsRequired);
}
return builder;
});
try
{
foreach (var builder in builders)
{
await guild.CreateApplicationCommandAsync(builder.Build());
}
}
catch (HttpException exception)
{
var json = JsonConvert.SerializeObject(exception.Errors, Formatting.Indented);
Console.WriteLine(json);
}
}
private async Task SlashCommandHandler(SocketSlashCommand command)
{
foreach (var cmd in commands)
{
await cmd.SlashCommandHandler(command);
}
}
}
}
@@ -0,0 +1,35 @@
using Discord.WebSocket;
namespace BiblioTech.Commands
{
public class ClearUserAssociationCommand : BaseCommand
{
private readonly UserOption user = new UserOption(
description: "User to clear Eth address for.",
isRequired: true);
public override string Name => "clear";
public override string StartingMessage => "Hold on...";
public override string Description => "Admin only. Clears current Eth address for a user, allowing them to set a new one.";
public override CommandOption[] Options => new[] { user };
protected override async Task Invoke(SocketSlashCommand command)
{
if (!IsSenderAdmin(command))
{
await command.FollowupAsync("You're not an admin.");
return;
}
var userId = user.GetOptionUserId(command);
if (userId == null)
{
await command.FollowupAsync("Failed to get user ID");
return;
}
Program.UserRepo.ClearUserAssociatedAddress(userId.Value);
await command.FollowupAsync("Done."); ;
}
}
}
@@ -0,0 +1,38 @@
using CodexPlugin;
using Discord.WebSocket;
namespace BiblioTech.Commands
{
public class DeploymentsCommand : BaseCommand
{
private readonly DeploymentsFilesMonitor monitor;
public DeploymentsCommand(DeploymentsFilesMonitor monitor)
{
this.monitor = monitor;
}
public override string Name => "deployments";
public override string StartingMessage => "Fetching deployments information...";
public override string Description => "Lists active TestNet deployments";
protected override async Task Invoke(SocketSlashCommand command)
{
var deployments = monitor.GetDeployments();
if (!deployments.Any())
{
await command.FollowupAsync("No deployments available.");
return;
}
await command.FollowupAsync($"Deployments: {string.Join(", ", deployments.Select(FormatDeployment))}");
}
private string FormatDeployment(CodexDeployment deployment)
{
var m = deployment.Metadata;
return $"{m.Name} ({m.StartUtc.ToString("o")})";
}
}
}
@@ -0,0 +1,43 @@
using Discord.WebSocket;
using GethPlugin;
using Nethereum.Util;
namespace BiblioTech.Commands
{
public class EthAddressOption : CommandOption
{
public EthAddressOption()
: base(name: "ethaddress",
description: "Ethereum address starting with '0x'.",
type: Discord.ApplicationCommandOptionType.String,
isRequired: true)
{
}
public async Task<EthAddress?> Parse(SocketSlashCommand command)
{
var ethOptionData = command.Data.Options.SingleOrDefault(o => o.Name == Name);
if (ethOptionData == null)
{
await command.FollowupAsync("EthAddress option not received.");
return null;
}
var ethAddressStr = ethOptionData.Value as string;
if (string.IsNullOrEmpty(ethAddressStr))
{
await command.FollowupAsync("EthAddress is null or empty.");
return null;
}
if (!AddressUtil.Current.IsValidAddressLength(ethAddressStr) ||
!AddressUtil.Current.IsValidEthereumAddressHexFormat(ethAddressStr) ||
!AddressUtil.Current.IsChecksumAddress(ethAddressStr))
{
await command.FollowupAsync("EthAddress is not valid.");
return null;
}
return new EthAddress(ethAddressStr);
}
}
}
@@ -0,0 +1,42 @@
using CodexContractsPlugin;
using Core;
using Discord.WebSocket;
using GethPlugin;
namespace BiblioTech.Commands
{
public class GetBalanceCommand : BaseNetCommand
{
private readonly UserAssociateCommand userAssociateCommand;
private readonly UserOption optionalUser = new UserOption(
description: "If set, get balance for another user. (Optional, admin-only)",
isRequired: false);
public GetBalanceCommand(DeploymentsFilesMonitor monitor, CoreInterface ci, UserAssociateCommand userAssociateCommand)
: base(monitor, ci)
{
this.userAssociateCommand = userAssociateCommand;
}
public override string Name => "balance";
public override string StartingMessage => "Fetching balance...";
public override string Description => "Shows Eth and TestToken balance of an eth address.";
public override CommandOption[] Options => new[] { optionalUser };
protected override async Task Execute(SocketSlashCommand command, IGethNode gethNode, ICodexContracts contracts)
{
var userId = GetUserId(optionalUser, command);
var addr = Program.UserRepo.GetCurrentAddressForUser(userId);
if (addr == null)
{
await command.FollowupAsync($"No address has been set for this user. Please use '/{userAssociateCommand.Name}' to set it first.");
return;
}
var eth = gethNode.GetEthBalance(addr);
var testTokens = contracts.GetTestTokenBalance(gethNode, addr);
await command.FollowupAsync($"{command.User.Username} has {eth} and {testTokens}.");
}
}
}
+85
View File
@@ -0,0 +1,85 @@
using CodexContractsPlugin;
using Core;
using Discord.WebSocket;
using GethPlugin;
namespace BiblioTech.Commands
{
public class MintCommand : BaseNetCommand
{
private readonly Ether defaultEthToSend = 10.Eth();
private readonly TestToken defaultTestTokensToMint = 1024.TestTokens();
private readonly UserOption optionalUser = new UserOption(
description: "If set, mint tokens for this user. (Optional, admin-only)",
isRequired: false);
private readonly UserAssociateCommand userAssociateCommand;
public MintCommand(DeploymentsFilesMonitor monitor, CoreInterface ci, UserAssociateCommand userAssociateCommand)
: base(monitor, ci)
{
this.userAssociateCommand = userAssociateCommand;
}
public override string Name => "mint";
public override string StartingMessage => "Minting some tokens...";
public override string Description => "Mint some TestTokens and send some Eth to the user if their balance is low.";
public override CommandOption[] Options => new[] { optionalUser };
protected override async Task Execute(SocketSlashCommand command, IGethNode gethNode, ICodexContracts contracts)
{
var userId = GetUserId(optionalUser, command);
var addr = Program.UserRepo.GetCurrentAddressForUser(userId);
if (addr == null)
{
await command.FollowupAsync($"No address has been set for this user. Please use '/{userAssociateCommand.Name}' to set it first.");
return;
}
var report = new List<string>();
var sentEth = ProcessEth(gethNode, addr, report);
var mintedTokens = ProcessTokens(gethNode, contracts, addr, report);
Program.UserRepo.AddMintEventForUser(userId, addr, sentEth, mintedTokens);
await command.FollowupAsync(string.Join(Environment.NewLine, report));
}
private TestToken ProcessTokens(IGethNode gethNode, ICodexContracts contracts, EthAddress addr, List<string> report)
{
if (ShouldMintTestTokens(gethNode, contracts, addr))
{
contracts.MintTestTokens(gethNode, addr, defaultTestTokensToMint);
report.Add($"Minted {defaultTestTokensToMint}.");
return defaultTestTokensToMint;
}
report.Add("TestToken balance over threshold.");
return 0.TestTokens();
}
private Ether ProcessEth(IGethNode gethNode, EthAddress addr, List<string> report)
{
if (ShouldSendEth(gethNode, addr))
{
gethNode.SendEth(addr, defaultEthToSend);
report.Add($"Sent {defaultEthToSend}.");
return defaultEthToSend;
}
report.Add("Eth balance is over threshold.");
return 0.Eth();
}
private bool ShouldMintTestTokens(IGethNode gethNode, ICodexContracts contracts, EthAddress addr)
{
var testTokens = contracts.GetTestTokenBalance(gethNode, addr);
return testTokens.Amount < 64m;
}
private bool ShouldSendEth(IGethNode gethNode, EthAddress addr)
{
var eth = gethNode.GetEthBalance(addr);
return eth.Eth < 1.0m;
}
}
}
@@ -0,0 +1,35 @@
using Discord.WebSocket;
namespace BiblioTech.Commands
{
public class ReportHistoryCommand : BaseCommand
{
private readonly UserOption user = new UserOption(
description: "User to report history for.",
isRequired: true);
public override string Name => "report";
public override string StartingMessage => "Getting that data...";
public override string Description => "Admin only. Reports bot-interaction history for a user.";
public override CommandOption[] Options => new[] { user };
protected override async Task Invoke(SocketSlashCommand command)
{
if (!IsSenderAdmin(command))
{
await command.FollowupAsync("You're not an admin.");
return;
}
var userId = user.GetOptionUserId(command);
if (userId == null)
{
await command.FollowupAsync("Failed to get user ID");
return;
}
var report = Program.UserRepo.GetInteractionReport(userId.Value);
await command.FollowupAsync(string.Join(Environment.NewLine, report));
}
}
}
@@ -0,0 +1,34 @@
using Discord.WebSocket;
namespace BiblioTech.Commands
{
public class UserAssociateCommand : BaseCommand
{
private readonly EthAddressOption ethOption = new EthAddressOption();
private readonly UserOption optionalUser = new UserOption(
description: "If set, associates Ethereum address for another user. (Optional, admin-only)",
isRequired: false);
public override string Name => "set";
public override string StartingMessage => "hold on...";
public override string Description => "Associates a Discord user with an Ethereum address.";
public override CommandOption[] Options => new CommandOption[] { ethOption, optionalUser };
protected override async Task Invoke(SocketSlashCommand command)
{
var userId = GetUserId(optionalUser, command);
var data = await ethOption.Parse(command);
if (data == null) return;
var currentAddress = Program.UserRepo.GetCurrentAddressForUser(userId);
if (currentAddress != null && !IsSenderAdmin(command))
{
await command.FollowupAsync($"You've already set your Ethereum address to {currentAddress}.");
return;
}
Program.UserRepo.AssociateUserWithAddress(userId, data);
await command.FollowupAsync("Done! Thank you for joining the test net!");
}
}
}
+22
View File
@@ -0,0 +1,22 @@
using Discord;
using Discord.WebSocket;
namespace BiblioTech.Commands
{
public class UserOption : CommandOption
{
public UserOption(string description, bool isRequired)
: base("user", description, ApplicationCommandOptionType.User, isRequired)
{
}
public ulong? GetOptionUserId(SocketSlashCommand command)
{
var userOptionData = command.Data.Options.SingleOrDefault(o => o.Name == Name);
if (userOptionData == null) return null;
var user = userOptionData.Value as IUser;
if (user == null) return null;
return user.Id;
}
}
}
+22
View File
@@ -0,0 +1,22 @@
using ArgsUniform;
namespace BiblioTech
{
public class Configuration
{
[Uniform("token", "t", "TOKEN", true, "Discord Application Token")]
public string ApplicationToken { get; set; } = string.Empty;
[Uniform("server-name", "sn", "SERVERNAME", true, "Name of the Discord server")]
public string ServerName { get; set; } = string.Empty;
[Uniform("endpoints", "e", "ENDPOINTS", false, "Path where endpoint JSONs are located. Also accepts codex-deployment JSONs.")]
public string EndpointsPath { get; set; } = "endpoints";
[Uniform("userdata", "u", "USERDATA", false, "Path where user data files will be saved.")]
public string UserDataPath { get; set; } = "userdata";
[Uniform("admin-role", "a", "ADMINROLE", true, "Name of the Discord server admin role")]
public string AdminRoleName { get; set; } = string.Empty;
}
}
@@ -0,0 +1,51 @@
using CodexPlugin;
using Newtonsoft.Json;
namespace BiblioTech
{
public class DeploymentsFilesMonitor
{
private DateTime lastUpdate = DateTime.MinValue;
private CodexDeployment[] deployments = Array.Empty<CodexDeployment>();
public CodexDeployment[] GetDeployments()
{
if (ShouldUpdate()) UpdateDeployments();
return deployments;
}
private void UpdateDeployments()
{
lastUpdate = DateTime.UtcNow;
var path = Program.Config.EndpointsPath;
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
File.WriteAllText(Path.Combine(path, "readme.txt"), "Place codex-deployment.json here.");
return;
}
var files = Directory.GetFiles(path);
deployments = files.Select(ProcessFile).Where(d => d != null).Cast<CodexDeployment>().ToArray();
}
private CodexDeployment? ProcessFile(string filename)
{
try
{
var lines = string.Join(" ", File.ReadAllLines(filename));
return JsonConvert.DeserializeObject<CodexDeployment>(lines);
}
catch
{
return null;
}
}
private bool ShouldUpdate()
{
return !deployments.Any() || (DateTime.UtcNow - lastUpdate) > TimeSpan.FromMinutes(10);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
using ArgsUniform;
using BiblioTech.Commands;
using Core;
using Discord;
using Discord.WebSocket;
using Logging;
namespace BiblioTech
{
public class Program
{
private DiscordSocketClient client = null!;
public static Configuration Config { get; private set; } = null!;
public static DeploymentsFilesMonitor DeploymentFilesMonitor { get; } = new DeploymentsFilesMonitor();
public static UserRepo UserRepo { get; } = new UserRepo();
public static AdminChecker AdminChecker { get; } = new AdminChecker();
public static Task Main(string[] args)
{
var uniformArgs = new ArgsUniform<Configuration>(PrintHelp, args);
Config = uniformArgs.Parse();
if (!Directory.Exists(Config.UserDataPath))
{
Directory.CreateDirectory(Config.UserDataPath);
}
return new Program().MainAsync();
}
public async Task MainAsync()
{
Console.WriteLine("Starting Codex Discord Bot...");
client = new DiscordSocketClient();
client.Log += Log;
ProjectPlugin.Load<CodexPlugin.CodexPlugin>();
ProjectPlugin.Load<GethPlugin.GethPlugin>();
ProjectPlugin.Load<CodexContractsPlugin.CodexContractsPlugin>();
var entryPoint = new EntryPoint(new ConsoleLog(), new KubernetesWorkflow.Configuration(
kubeConfigFile: null,
operationTimeout: TimeSpan.FromMinutes(5),
retryDelay: TimeSpan.FromSeconds(10),
kubernetesNamespace: "not-applicable"), "datafiles");
var monitor = new DeploymentsFilesMonitor();
var ci = entryPoint.CreateInterface();
var associateCommand = new UserAssociateCommand();
var handler = new CommandHandler(client,
new ClearUserAssociationCommand(),
new GetBalanceCommand(monitor, ci, associateCommand),
new MintCommand(monitor, ci, associateCommand),
new ReportHistoryCommand(),
associateCommand,
new DeploymentsCommand(monitor)
);
await client.LoginAsync(TokenType.Bot, Config.ApplicationToken);
await client.StartAsync();
Console.WriteLine("Running...");
await Task.Delay(-1);
}
private static void PrintHelp()
{
Console.WriteLine("BiblioTech - Codex Discord Bot");
}
private Task Log(LogMessage msg)
{
Console.WriteLine(msg.ToString());
return Task.CompletedTask;
}
}
}
+165
View File
@@ -0,0 +1,165 @@
using CodexContractsPlugin;
using GethPlugin;
using Newtonsoft.Json;
namespace BiblioTech
{
public class UserRepo
{
private readonly object repoLock = new object();
public void AssociateUserWithAddress(ulong discordId, EthAddress address)
{
lock (repoLock)
{
SetUserAddress(discordId, address);
}
}
public void ClearUserAssociatedAddress(ulong discordId)
{
lock (repoLock)
{
SetUserAddress(discordId, null);
}
}
public void AddMintEventForUser(ulong discordId, EthAddress usedAddress, Ether eth, TestToken tokens)
{
lock (repoLock)
{
var user = GetOrCreate(discordId);
user.MintEvents.Add(new UserMintEvent(DateTime.UtcNow, usedAddress, eth, tokens));
SaveUser(user);
}
}
public EthAddress? GetCurrentAddressForUser(ulong discordId)
{
lock (repoLock)
{
return GetOrCreate(discordId).CurrentAddress;
}
}
public string[] GetInteractionReport(ulong discordId)
{
var result = new List<string>();
lock (repoLock)
{
var filename = GetFilename(discordId);
if (!File.Exists(filename))
{
result.Add("User has not joined the test net.");
}
else
{
var user = JsonConvert.DeserializeObject<User>(File.ReadAllText(filename));
if (user == null)
{
result.Add("Failed to load user records.");
}
else
{
result.Add("User joined on " + user.CreatedUtc.ToString("o"));
result.Add("Current address: " + user.CurrentAddress);
foreach (var ae in user.AssociateEvents)
{
result.Add($"{ae.Utc.ToString("o")} - Address set to: {ae.NewAddress}");
}
foreach (var me in user.MintEvents)
{
result.Add($"{me.Utc.ToString("o")} - Minted {me.EthReceived} and {me.TestTokensMinted} to {me.UsedAddress}.");
}
}
}
}
return result.ToArray();
}
private void SetUserAddress(ulong discordId, EthAddress? address)
{
var user = GetOrCreate(discordId);
user.CurrentAddress = address;
user.AssociateEvents.Add(new UserAssociateAddressEvent(DateTime.UtcNow, address));
SaveUser(user);
}
private User GetOrCreate(ulong discordId)
{
var filename = GetFilename(discordId);
if (!File.Exists(filename))
{
return CreateAndSaveNewUser(discordId);
}
return JsonConvert.DeserializeObject<User>(File.ReadAllText(filename))!;
}
private User CreateAndSaveNewUser(ulong discordId)
{
var newUser = new User(discordId, DateTime.UtcNow, null, new List<UserAssociateAddressEvent>(), new List<UserMintEvent>());
SaveUser(newUser);
return newUser;
}
private void SaveUser(User user)
{
var filename = GetFilename(user.DiscordId);
if (File.Exists(filename)) File.Delete(filename);
File.WriteAllText(filename, JsonConvert.SerializeObject(user));
}
private static string GetFilename(ulong discordId)
{
return Path.Combine(Program.Config.UserDataPath, discordId.ToString() + ".json");
}
}
public class User
{
public User(ulong discordId, DateTime createdUtc, EthAddress? currentAddress, List<UserAssociateAddressEvent> associateEvents, List<UserMintEvent> mintEvents)
{
DiscordId = discordId;
CreatedUtc = createdUtc;
CurrentAddress = currentAddress;
AssociateEvents = associateEvents;
MintEvents = mintEvents;
}
public ulong DiscordId { get; }
public DateTime CreatedUtc { get; }
public EthAddress? CurrentAddress { get; set; }
public List<UserAssociateAddressEvent> AssociateEvents { get; }
public List<UserMintEvent> MintEvents { get; }
}
public class UserAssociateAddressEvent
{
public UserAssociateAddressEvent(DateTime utc, EthAddress? newAddress)
{
Utc = utc;
NewAddress = newAddress;
}
public DateTime Utc { get; }
public EthAddress? NewAddress { get; }
}
public class UserMintEvent
{
public UserMintEvent(DateTime utc, EthAddress usedAddress, Ether ethReceived, TestToken testTokensMinted)
{
Utc = utc;
UsedAddress = usedAddress;
EthReceived = ethReceived;
TestTokensMinted = testTokensMinted;
}
public DateTime Utc { get; }
public EthAddress UsedAddress { get; }
public Ether EthReceived { get; }
public TestToken TestTokensMinted { get; }
}
}
+2
View File
@@ -0,0 +1,2 @@
docker build -f docker/Dockerfile -t thatbenbierens/codex-discordbot:initial ../..
docker push thatbenbierens/codex-discordbot:initial
+7
View File
@@ -0,0 +1,7 @@
FROM mcr.microsoft.com/dotnet/sdk:7.0
WORKDIR app
COPY ./Tools/BiblioTech ./Tools/BiblioTech
COPY ./Framework ./Framework
COPY ./ProjectPlugins ./ProjectPlugins
CMD ["dotnet", "run", "--project", "Tools/BiblioTech"]
@@ -0,0 +1,7 @@
services:
bibliotech-discordbot:
image: thatbenbierens/codex-discordbot:initial
environment:
- TOKEN=tokenplz
- SERVERNAME=ThatBen's server
- ADMINROLE=adminers
+3
View File
@@ -8,6 +8,9 @@ namespace CodexNetDeployer
public const int SecondsIn1Day = 24 * 60 * 60;
public const int TenMinutes = 10 * 60;
[Uniform("deploy-name", "nm", "DEPLOYNAME", false, "Name of the deployment. (optional)")]
public string DeploymentName { get; set; } = "unnamed";
[Uniform("kube-config", "kc", "KUBECONFIG", false, "Path to Kubeconfig file. Use 'null' (default) to use local cluster.")]
public string KubeConfigFile { get; set; } = "null";
+4 -3
View File
@@ -79,7 +79,7 @@ namespace CodexNetDeployer
CheckContainerRestarts(startResults);
var codexContainers = startResults.Select(s => s.CodexNode.Container).ToArray();
return new CodexDeployment(codexContainers, gethDeployment, metricsService, CreateMetadata(startUtc));
return new CodexDeployment(codexContainers, gethDeployment, contractsDeployment, metricsService, CreateMetadata(startUtc));
}
private EntryPoint CreateEntryPoint(ILog log)
@@ -151,6 +151,7 @@ namespace CodexNetDeployer
private DeploymentMetadata CreateMetadata(DateTime startUtc)
{
return new DeploymentMetadata(
name: config.DeploymentName,
startUtc: startUtc,
finishedUtc: DateTime.UtcNow,
kubeNamespace: config.KubeNamespace,
@@ -180,9 +181,9 @@ namespace CodexNetDeployer
return TimeSpan.FromSeconds(2);
}
public TimeSpan HttpCallRetryTime()
public int HttpMaxNumberOfRetries()
{
return TimeSpan.FromSeconds(2);
return 2;
}
public TimeSpan HttpCallTimeout()
@@ -1,4 +1,5 @@
dotnet run \
--deploy-name=codex-continuous-test-deployment \
--kube-config=/opt/kubeconfig.yaml \
--kube-namespace=codex-continuous-tests \
--deploy-file=codex-deployment.json \
+7
View File
@@ -43,6 +43,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistTestCore", "Tests\DistT
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexNetDeployer", "Tools\CodexNetDeployer\CodexNetDeployer.csproj", "{3417D508-E2F4-4974-8988-BB124046D9E2}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BiblioTech", "Tools\BiblioTech\BiblioTech.csproj", "{078ABA6D-A04E-4F62-A44C-EA66F1B66548}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -113,6 +115,10 @@ Global
{3417D508-E2F4-4974-8988-BB124046D9E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3417D508-E2F4-4974-8988-BB124046D9E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3417D508-E2F4-4974-8988-BB124046D9E2}.Release|Any CPU.Build.0 = Release|Any CPU
{078ABA6D-A04E-4F62-A44C-EA66F1B66548}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{078ABA6D-A04E-4F62-A44C-EA66F1B66548}.Debug|Any CPU.Build.0 = Debug|Any CPU
{078ABA6D-A04E-4F62-A44C-EA66F1B66548}.Release|Any CPU.ActiveCfg = Release|Any CPU
{078ABA6D-A04E-4F62-A44C-EA66F1B66548}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -134,6 +140,7 @@ Global
{562EC700-6984-4C9A-83BF-3BF4E3EB1A64} = {88C2A621-8A98-4D07-8625-7900FC8EF89E}
{E849B7BA-FDCC-4CFF-998F-845ED2F1BF40} = {88C2A621-8A98-4D07-8625-7900FC8EF89E}
{3417D508-E2F4-4974-8988-BB124046D9E2} = {7591C5B3-D86E-4AE4-8ED2-B272D17FE7E3}
{078ABA6D-A04E-4F62-A44C-EA66F1B66548} = {7591C5B3-D86E-4AE4-8ED2-B272D17FE7E3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {237BF0AA-9EC4-4659-AD9A-65DEB974250C}