Merge branch 'master' into feature/proofs-and-frees

# Conflicts:
#	Tests/ExperimentalTests/UtilityTests/DiscordBotTests.cs
#	Tools/AutoClient/Modes/FolderStore/FileSaver.cs
This commit is contained in:
ThatBen 2025-04-17 17:49:28 +02:00
commit 63bd9e5d7d
No known key found for this signature in database
GPG Key ID: 62C543548433D43E
58 changed files with 1766 additions and 1776 deletions

View File

@ -1,21 +0,0 @@
using Utils;
namespace DiscordRewards
{
public class CheckConfig
{
public CheckType Type { get; set; }
public ulong MinNumberOfHosts { get; set; }
public ByteSize MinSlotSize { get; set; } = 0.Bytes();
public TimeSpan MinDuration { get; set; } = TimeSpan.Zero;
}
public enum CheckType
{
Uninitialized,
HostFilledSlot,
HostFinishedSlot,
ClientPostedContract,
ClientStartedContract,
}
}

View File

@ -0,0 +1,34 @@
namespace DiscordRewards
{
public class EventsAndErrors
{
public ChainEventMessage[] EventsOverview { get; set; } = Array.Empty<ChainEventMessage>();
public string[] Errors { get; set; } = Array.Empty<string>();
public ActiveChainAddresses ActiveChainAddresses { get; set; } = new ActiveChainAddresses();
public bool HasAny()
{
return
Errors.Length > 0 ||
EventsOverview.Length > 0 ||
ActiveChainAddresses.HasAny();
}
}
public class ChainEventMessage
{
public ulong BlockNumber { get; set; }
public string Message { get; set; } = string.Empty;
}
public class ActiveChainAddresses
{
public string[] Hosts { get; set; } = Array.Empty<string>();
public string[] Clients { get; set; } = Array.Empty<string>();
public bool HasAny()
{
return Hosts.Length > 0 || Clients.Length > 0;
}
}
}

View File

@ -1,26 +0,0 @@
namespace DiscordRewards
{
public class GiveRewardsCommand
{
public RewardUsersCommand[] Rewards { get; set; } = Array.Empty<RewardUsersCommand>();
public ChainEventMessage[] EventsOverview { get; set; } = Array.Empty<ChainEventMessage>();
public string[] Errors { get; set; } = Array.Empty<string>();
public bool HasAny()
{
return Rewards.Any() || EventsOverview.Any();
}
}
public class RewardUsersCommand
{
public ulong RewardId { get; set; }
public string[] UserAddresses { get; set; } = Array.Empty<string>();
}
public class ChainEventMessage
{
public ulong BlockNumber { get; set; }
public string Message { get; set; } = string.Empty;
}
}

View File

@ -1,18 +0,0 @@
namespace DiscordRewards
{
public class RewardConfig
{
public const string UsernameTag = "<USER>";
public RewardConfig(ulong roleId, string message, CheckConfig checkConfig)
{
RoleId = roleId;
Message = message;
CheckConfig = checkConfig;
}
public ulong RoleId { get; }
public string Message { get; }
public CheckConfig CheckConfig { get; }
}
}

View File

@ -1,53 +0,0 @@
namespace DiscordRewards
{
public class RewardRepo
{
private static string Tag => RewardConfig.UsernameTag;
public RewardConfig[] Rewards { get; } = new RewardConfig[0];
// Example configuration, from test server:
//{
// // Filled any slot
// new RewardConfig(1187039439558541498, $"{Tag} successfully filled their first slot!", new CheckConfig
// {
// Type = CheckType.HostFilledSlot
// }),
// // Finished any slot
// new RewardConfig(1202286165630390339, $"{Tag} successfully finished their first slot!", new CheckConfig
// {
// Type = CheckType.HostFinishedSlot
// }),
// // Finished a sizable slot
// new RewardConfig(1202286218738405418, $"{Tag} finished their first 1GB-24h slot! (10mb/5mins for test)", new CheckConfig
// {
// Type = CheckType.HostFinishedSlot,
// MinSlotSize = 10.MB(),
// MinDuration = TimeSpan.FromMinutes(5.0),
// }),
// // Posted any contract
// new RewardConfig(1202286258370383913, $"{Tag} posted their first contract!", new CheckConfig
// {
// Type = CheckType.ClientPostedContract
// }),
// // Started any contract
// new RewardConfig(1202286330873126992, $"A contract created by {Tag} reached Started state for the first time!", new CheckConfig
// {
// Type = CheckType.ClientStartedContract
// }),
// // Started a sizable contract
// new RewardConfig(1202286381670608909, $"A large contract created by {Tag} reached Started state for the first time! (10mb/5mins for test)", new CheckConfig
// {
// Type = CheckType.ClientStartedContract,
// MinNumberOfHosts = 4,
// MinSlotSize = 10.MB(),
// MinDuration = TimeSpan.FromMinutes(5.0),
// })
//};
}
}

View File

@ -62,7 +62,6 @@ namespace NethereumWorkflow
log.Debug(typeof(TFunction).ToString());
var handler = web3.Eth.GetContractQueryHandler<TFunction>();
var result = Time.Wait(handler.QueryRawAsync(contractAddress, function, new BlockParameter(blockNumber)));
var aaaa = 0;
}
public string SendTransaction<TFunction>(string contractAddress, TFunction function) where TFunction : FunctionMessage, new()

View File

@ -34,10 +34,28 @@
var source = items.ToList();
while (source.Any())
{
result.Add(RandomUtils.PickOneRandom(source));
result.Add(PickOneRandom(source));
}
return result.ToArray();
}
}
public static string GenerateRandomString(long requiredLength)
{
lock (@lock)
{
var result = "";
while (result.Length < requiredLength)
{
var remaining = requiredLength - result.Length;
var len = Math.Min(1024, remaining);
var bytes = new byte[len];
random.NextBytes(bytes);
result += string.Join("", bytes.Select(b => b.ToString()));
}
return result.Substring(0, Convert.ToInt32(requiredLength));
}
}
}
}

View File

@ -20,11 +20,6 @@ namespace WebUtils
private readonly Action<HttpClient> onClientCreated;
private readonly string id;
internal Http(string id, ILog log, IWebCallTimeSet timeSet)
: this(id, log, timeSet, DoNothing)
{
}
internal Http(string id, ILog log, IWebCallTimeSet timeSet, Action<HttpClient> onClientCreated)
{
this.id = id;
@ -89,9 +84,5 @@ namespace WebUtils
onClientCreated(client);
return client;
}
private static void DoNothing(HttpClient client)
{
}
}
}

View File

@ -13,16 +13,28 @@ namespace WebUtils
{
private readonly ILog log;
private readonly IWebCallTimeSet defaultTimeSet;
private readonly Action<HttpClient> factoryOnClientCreated;
public HttpFactory(ILog log)
: this (log, new DefaultWebCallTimeSet())
{
}
public HttpFactory(ILog log, Action<HttpClient> onClientCreated)
: this(log, new DefaultWebCallTimeSet(), onClientCreated)
{
}
public HttpFactory(ILog log, IWebCallTimeSet defaultTimeSet)
: this(log, defaultTimeSet, DoNothing)
{
}
public HttpFactory(ILog log, IWebCallTimeSet defaultTimeSet, Action<HttpClient> onClientCreated)
{
this.log = log;
this.defaultTimeSet = defaultTimeSet;
this.factoryOnClientCreated = onClientCreated;
}
public IHttp CreateHttp(string id, Action<HttpClient> onClientCreated)
@ -32,12 +44,20 @@ namespace WebUtils
public IHttp CreateHttp(string id, Action<HttpClient> onClientCreated, IWebCallTimeSet ts)
{
return new Http(id, log, ts, onClientCreated);
return new Http(id, log, ts, (c) =>
{
factoryOnClientCreated(c);
onClientCreated(c);
});
}
public IHttp CreateHttp(string id)
{
return new Http(id, log, defaultTimeSet);
return new Http(id, log, defaultTimeSet, factoryOnClientCreated);
}
private static void DoNothing(HttpClient client)
{
}
}
}

View File

@ -1,370 +0,0 @@
using CodexClient;
using CodexContractsPlugin;
using CodexDiscordBotPlugin;
using CodexPlugin;
using CodexTests;
using Core;
using DiscordRewards;
using DistTestCore;
using GethPlugin;
using KubernetesWorkflow.Types;
using Logging;
using Newtonsoft.Json;
using NUnit.Framework;
using Utils;
namespace ExperimentalTests.UtilityTests
{
[TestFixture]
public class DiscordBotTests : AutoBootstrapDistTest
{
private readonly RewardRepo repo = new RewardRepo();
private readonly TestToken hostInitialBalance = 3000000.TstWei();
private readonly TestToken clientInitialBalance = 1000000000.TstWei();
private readonly EthAccount clientAccount = EthAccountGenerator.GenerateNew();
private readonly List<EthAccount> hostAccounts = new List<EthAccount>();
private readonly List<ulong> rewardsSeen = new List<ulong>();
private readonly TimeSpan rewarderInterval = TimeSpan.FromMinutes(1);
private readonly List<ChainEventMessage> receivedEvents = new List<ChainEventMessage>();
[Test]
[DontDownloadLogs]
[Ignore("Used to debug testnet bots.")]
public void BotRewardTest()
{
var geth = StartGethNode(s => s.IsMiner().WithName("disttest-geth"));
var contracts = Ci.StartCodexContracts(geth);
var gethInfo = CreateGethInfo(geth, contracts);
var botContainer = StartDiscordBot(gethInfo);
var rewarderContainer = StartRewarderBot(gethInfo, botContainer);
StartHosts(geth, contracts);
var client = StartClient(geth, contracts);
var apiCalls = new RewardApiCalls(GetTestLog(), Ci, botContainer);
apiCalls.Start(OnCommand);
var purchaseContract = ClientPurchasesStorage(client);
purchaseContract.WaitForStorageContractStarted();
purchaseContract.WaitForStorageContractFinished();
// todo: removed from codexclient:
//contracts.WaitUntilNextPeriod();
//contracts.WaitUntilNextPeriod();
//var blocks = 3;
//Log($"Waiting {blocks} blocks for nodes to process payouts...");
//Thread.Sleep(GethContainerRecipe.BlockInterval * blocks);
Thread.Sleep(rewarderInterval * 3);
apiCalls.Stop();
AssertEventOccurance("Created as New.", 1);
AssertEventOccurance("SlotFilled", Convert.ToInt32(GetNumberOfRequiredHosts()));
AssertEventOccurance("Transit: New -> Started", 1);
AssertEventOccurance("Transit: Started -> Finished", 1);
foreach (var r in repo.Rewards)
{
var seen = rewardsSeen.Any(s => r.RoleId == s);
Log($"{Lookup(r.RoleId)} = {seen}");
}
Assert.That(repo.Rewards.All(r => rewardsSeen.Contains(r.RoleId)));
}
private string Lookup(ulong rewardId)
{
var reward = repo.Rewards.Single(r => r.RoleId == rewardId);
return $"({rewardId})'{reward.Message}'";
}
private void AssertEventOccurance(string msg, int expectedCount)
{
Assert.That(receivedEvents.Count(e => e.Message.Contains(msg)), Is.EqualTo(expectedCount),
$"Event '{msg}' did not occure correct number of times.");
}
private void OnCommand(string timestamp, GiveRewardsCommand call)
{
Log($"<API call {timestamp}>");
foreach (var e in call.EventsOverview)
{
Assert.That(receivedEvents.All(r => r.BlockNumber < e.BlockNumber), "Received event out of order.");
}
receivedEvents.AddRange(call.EventsOverview);
foreach (var e in call.EventsOverview)
{
Log("\tEvent: " + e);
}
foreach (var r in call.Rewards)
{
var reward = repo.Rewards.Single(a => a.RoleId == r.RewardId);
if (r.UserAddresses.Any()) rewardsSeen.Add(reward.RoleId);
foreach (var address in r.UserAddresses)
{
var user = IdentifyAccount(address);
Log("\tReward: " + user + ": " + reward.Message);
}
}
Log($"</API call>");
}
private IStoragePurchaseContract ClientPurchasesStorage(ICodexNode client)
{
var testFile = GenerateTestFile(GetMinFileSize());
var contentId = client.UploadFile(testFile);
var purchase = new StoragePurchaseRequest(contentId)
{
PricePerBytePerSecond = 2.TstWei(),
CollateralPerByte = 10.TstWei(),
MinRequiredNumberOfNodes = GetNumberOfRequiredHosts(),
NodeFailureTolerance = 2,
ProofProbability = 5,
Duration = GetMinRequiredRequestDuration(),
Expiry = GetMinRequiredRequestDuration() - TimeSpan.FromMinutes(1)
};
return client.Marketplace.RequestStorage(purchase);
}
private ICodexNode StartClient(IGethNode geth, ICodexContracts contracts)
{
var node = StartCodex(s => s
.WithName("Client")
.EnableMarketplace(geth, contracts, m => m
.WithAccount(clientAccount)
.WithInitial(10.Eth(), clientInitialBalance)));
Log($"Client {node.EthAccount.EthAddress}");
return node;
}
private RunningPod StartRewarderBot(DiscordBotGethInfo gethInfo, RunningContainer botContainer)
{
return Ci.DeployRewarderBot(new RewarderBotStartupConfig(
name: "rewarder-bot",
discordBotHost: botContainer.GetInternalAddress(DiscordBotContainerRecipe.RewardsPort).Host,
discordBotPort: botContainer.GetInternalAddress(DiscordBotContainerRecipe.RewardsPort).Port,
intervalMinutes: Convert.ToInt32(Math.Round(rewarderInterval.TotalMinutes)),
historyStartUtc: DateTime.UtcNow,
gethInfo: gethInfo,
dataPath: null
));
}
private DiscordBotGethInfo CreateGethInfo(IGethNode geth, ICodexContracts contracts)
{
return new DiscordBotGethInfo(
host: geth.Container.GetInternalAddress(GethContainerRecipe.HttpPortTag).Host,
port: geth.Container.GetInternalAddress(GethContainerRecipe.HttpPortTag).Port,
privKey: geth.StartResult.Account.PrivateKey,
marketplaceAddress: contracts.Deployment.MarketplaceAddress,
tokenAddress: contracts.Deployment.TokenAddress,
abi: contracts.Deployment.Abi
);
}
private RunningContainer StartDiscordBot(DiscordBotGethInfo gethInfo)
{
var bot = Ci.DeployCodexDiscordBot(new DiscordBotStartupConfig(
name: "discord-bot",
token: "aaa",
serverName: "ThatBen's server",
adminRoleName: "bottest-admins",
adminChannelName: "admin-channel",
rewardChannelName: "rewards-channel",
kubeNamespace: "notneeded",
gethInfo: gethInfo
));
return bot.Containers.Single();
}
private void StartHosts(IGethNode geth, ICodexContracts contracts)
{
var hosts = StartCodex(GetNumberOfLiveHosts(), s => s
.WithName("Host")
.WithLogLevel(CodexLogLevel.Trace, new CodexLogCustomTopics(CodexLogLevel.Error, CodexLogLevel.Error, CodexLogLevel.Warn)
{
ContractClock = CodexLogLevel.Trace,
})
.WithStorageQuota(Mult(GetMinFileSizePlus(50), GetNumberOfLiveHosts()))
.EnableMarketplace(geth, contracts, m => m
.WithInitial(10.Eth(), hostInitialBalance)
.AsStorageNode()
.AsValidator()));
var availability = new CreateStorageAvailability(
totalSpace: Mult(GetMinFileSize(), GetNumberOfLiveHosts()),
maxDuration: TimeSpan.FromMinutes(30),
minPricePerBytePerSecond: 1.TstWei(),
totalCollateral: hostInitialBalance
);
foreach (var host in hosts)
{
hostAccounts.Add(host.EthAccount);
host.Marketplace.MakeStorageAvailable(availability);
}
}
private int GetNumberOfLiveHosts()
{
return Convert.ToInt32(GetNumberOfRequiredHosts()) + 3;
}
private ByteSize Mult(ByteSize size, int mult)
{
return new ByteSize(size.SizeInBytes * mult);
}
private ByteSize GetMinFileSizePlus(int plusMb)
{
return new ByteSize(GetMinFileSize().SizeInBytes + plusMb.MB().SizeInBytes);
}
private ByteSize GetMinFileSize()
{
ulong minSlotSize = 0;
ulong minNumHosts = 0;
foreach (var r in repo.Rewards)
{
var s = Convert.ToUInt64(r.CheckConfig.MinSlotSize.SizeInBytes);
var h = r.CheckConfig.MinNumberOfHosts;
if (s > minSlotSize) minSlotSize = s;
if (h > minNumHosts) minNumHosts = h;
}
var minFileSize = (minSlotSize + 1024) * minNumHosts;
return new ByteSize(Convert.ToInt64(minFileSize));
}
private uint GetNumberOfRequiredHosts()
{
return Convert.ToUInt32(repo.Rewards.Max(r => r.CheckConfig.MinNumberOfHosts));
}
private TimeSpan GetMinRequiredRequestDuration()
{
return repo.Rewards.Max(r => r.CheckConfig.MinDuration) + TimeSpan.FromSeconds(10);
}
private string IdentifyAccount(string address)
{
if (address == clientAccount.EthAddress.Address) return "Client";
try
{
var index = hostAccounts.FindIndex(a => a.EthAddress.Address == address);
return "Host" + index;
}
catch
{
return "UNKNOWN";
}
}
public class RewardApiCalls
{
private readonly ContainerFileMonitor monitor;
public RewardApiCalls(ILog log, CoreInterface ci, RunningContainer botContainer)
{
monitor = new ContainerFileMonitor(log, ci, botContainer, "/app/datapath/logs/discordbot.log");
}
public void Start(Action<string, GiveRewardsCommand> onCommand)
{
monitor.Start(line => ParseLine(line, onCommand));
}
public void Stop()
{
monitor.Stop();
}
private void ParseLine(string line, Action<string, GiveRewardsCommand> onCommand)
{
try
{
var timestamp = line.Substring(0, 30);
var json = line.Substring(31);
var cmd = JsonConvert.DeserializeObject<GiveRewardsCommand>(json);
if (cmd != null)
{
onCommand(timestamp, cmd);
}
}
catch
{
}
}
}
public class ContainerFileMonitor
{
private readonly ILog log;
private readonly CoreInterface ci;
private readonly RunningContainer botContainer;
private readonly string filePath;
private readonly CancellationTokenSource cts = new CancellationTokenSource();
private readonly List<string> seenLines = new List<string>();
private Task worker = Task.CompletedTask;
private Action<string> onNewLine = c => { };
public ContainerFileMonitor(ILog log, CoreInterface ci, RunningContainer botContainer, string filePath)
{
this.log = log;
this.ci = ci;
this.botContainer = botContainer;
this.filePath = filePath;
}
public void Start(Action<string> onNewLine)
{
this.onNewLine = onNewLine;
worker = Task.Run(Worker);
}
public void Stop()
{
cts.Cancel();
worker.Wait();
}
// did any container crash? that's why it repeats?
private void Worker()
{
while (!cts.IsCancellationRequested)
{
Update();
}
}
private void Update()
{
Thread.Sleep(TimeSpan.FromSeconds(10));
if (cts.IsCancellationRequested) return;
var botLog = ci.ExecuteContainerCommand(botContainer, "cat", filePath);
var lines = botLog.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
// log.Log("line: " + line);
if (!seenLines.Contains(line))
{
seenLines.Add(line);
onNewLine(line);
}
}
}
}
}
}

View File

@ -0,0 +1,19 @@
using NUnit.Framework;
using Utils;
namespace FrameworkTests.Utils
{
[TestFixture]
public class RandomUtilsTests
{
[Test]
[Combinatorial]
public void TestRandomStringLength(
[Values(1, 5, 10, 1023, 1024, 1025, 2222)] int length)
{
var str = RandomUtils.GenerateRandomString(length);
Assert.That(str.Length, Is.EqualTo(length));
}
}
}

View File

@ -20,10 +20,6 @@ namespace AutoClient
new FileLog(Path.Combine(config.LogPath, "performance")),
new ConsoleLog()
));
var httpFactory = new HttpFactory(Log, new AutoClientWebTimeSet());
CodexNodeFactory = new CodexNodeFactory(log: Log, httpFactory: httpFactory, dataDir: Config.DataPath);
}
public Configuration Config { get; }
@ -31,7 +27,6 @@ namespace AutoClient
public IFileGenerator Generator { get; }
public CancellationTokenSource Cts { get; } = new CancellationTokenSource();
public Performance Performance { get; }
public CodexNodeFactory CodexNodeFactory { get; }
private IFileGenerator CreateGenerator()
{

View File

@ -1,145 +0,0 @@
using Logging;
namespace AutoClient
{
public class AutomaticPurchaser
{
private readonly App app;
private readonly ILog log;
private readonly CodexWrapper node;
private Task workerTask = Task.CompletedTask;
public AutomaticPurchaser(App app, ILog log, CodexWrapper node)
{
this.app = app;
this.log = log;
this.node = node;
}
public void Start()
{
workerTask = Task.Run(Worker);
}
public void Stop()
{
workerTask.Wait();
}
private async Task Worker()
{
log.Log("Worker started.");
while (!app.Cts.Token.IsCancellationRequested)
{
try
{
var pid = await StartNewPurchase();
await WaitTillFinished(pid);
}
catch (Exception ex)
{
log.Error("Worker failed with: " + ex);
await Task.Delay(TimeSpan.FromHours(6));
}
}
}
private async Task<string> StartNewPurchase()
{
var file = await CreateFile();
try
{
var cid = node.UploadFile(file);
var response = node.RequestStorage(cid);
return response.PurchaseId;
}
finally
{
DeleteFile(file);
}
}
private async Task<string> CreateFile()
{
return await app.Generator.Generate();
}
private void DeleteFile(string file)
{
try
{
File.Delete(file);
}
catch (Exception exc)
{
log.Error($"Failed to delete file '{file}': {exc}");
}
}
private async Task WaitTillFinished(string pid)
{
try
{
var emptyResponseTolerance = 10;
while (!app.Cts.Token.IsCancellationRequested)
{
var purchase = node.GetStoragePurchase(pid);
if (purchase == null)
{
await FixedShortDelay();
emptyResponseTolerance--;
if (emptyResponseTolerance == 0)
{
log.Log("Received 10 empty responses. Stop tracking this purchase.");
await ExpiryTimeDelay();
return;
}
continue;
}
if (purchase.IsCancelled)
{
app.Performance.StorageContractCancelled();
return;
}
if (purchase.IsError)
{
app.Performance.StorageContractErrored(purchase.Error);
return;
}
if (purchase.IsFinished)
{
app.Performance.StorageContractFinished();
return;
}
if (purchase.IsStarted)
{
app.Performance.StorageContractStarted();
await FixedDurationDelay();
}
await FixedShortDelay();
}
}
catch (Exception ex)
{
log.Log($"Wait failed with exception: {ex}. Assume contract will expire: Wait expiry time.");
await ExpiryTimeDelay();
}
}
private async Task FixedDurationDelay()
{
await Task.Delay(app.Config.ContractDurationMinutes * 60 * 1000, app.Cts.Token);
}
private async Task ExpiryTimeDelay()
{
await Task.Delay(app.Config.ContractExpiryMinutes * 60 * 1000, app.Cts.Token);
}
private async Task FixedShortDelay()
{
await Task.Delay(15 * 1000, app.Cts.Token);
}
}
}

View File

@ -6,7 +6,14 @@ namespace AutoClient
{
[Uniform("codex-endpoints", "ce", "CODEXENDPOINTS", false, "Codex endpoints. Semi-colon separated. (default 'http://localhost:8080')")]
public string CodexEndpoints { get; set; } =
"http://localhost:8080";
"http://localhost:8080" + ";" +
"http://localhost:8081" + ";" +
"http://localhost:8082" + ";" +
"http://localhost:8083" + ";" +
"http://localhost:8084" + ";" +
"http://localhost:8085" + ";" +
"http://localhost:8086" + ";" +
"http://localhost:8087";
[Uniform("datapath", "dp", "DATAPATH", false, "Root path where all data files will be saved.")]
public string DataPath { get; set; } = "datapath";
@ -21,7 +28,7 @@ namespace AutoClient
// Cluster nodes configured for max 7-day storage.
[Uniform("contract-expiry", "ce", "CONTRACTEXPIRY", false, "contract expiry in minutes. (default 15 minutes)")]
public int ContractExpiryMinutes { get; set; } = 60;
public int ContractExpiryMinutes { get; set; } = 15;
[Uniform("num-hosts", "nh", "NUMHOSTS", false, "Number of hosts for contract. (default 10)")]
public int NumHosts { get; set; } = 5;
@ -41,8 +48,16 @@ namespace AutoClient
[Uniform("folderToStore", "fts", "FOLDERTOSTORE", false, "When set, autoclient will attempt to upload and purchase storage for every non-JSON file in the provided folder.")]
public string FolderToStore { get; set; } = "/data/EthereumMainnetPreMergeEraFiles";
[Uniform("ethAddressFile", "eaf", "ETHADDRESSFILE", false, "File with eth address used by codex node. Used for balance checking if geth/contracts information is provided.")]
public string EthAddressFile { get; set; } = "/root/codex-testnet-starter/scripts/eth.address";
[Uniform("ethAddressFile", "eaf", "ETHADDRESSFILE", false, "File(s) with eth address used by codex node. Used for balance checking if geth/contracts information is provided. Semi-colon separated.")]
public string EthAddressFile { get; set; } =
"/root/codex-testnet-starter/scripts/eth.address" + ";" +
"/root/codex-testnet-starter/scripts/eth_2.address" + ";" +
"/root/codex-testnet-starter/scripts/eth_3.address" + ";" +
"/root/codex-testnet-starter/scripts/eth_4.address" + ";" +
"/root/codex-testnet-starter/scripts/eth_5.address" + ";" +
"/root/codex-testnet-starter/scripts/eth_6.address" + ";" +
"/root/codex-testnet-starter/scripts/eth_7.address" + ";" +
"/root/codex-testnet-starter/scripts/eth_8.address";
public string LogPath
{

View File

@ -0,0 +1,131 @@
using Logging;
namespace AutoClient
{
public class LoadBalancer
{
private readonly List<Cdx> instances;
private readonly object instanceLock = new object();
private class Cdx
{
private readonly ILog log;
private readonly CodexWrapper instance;
private readonly List<Action<CodexWrapper>> queue = new List<Action<CodexWrapper>>();
private readonly object queueLock = new object();
private bool running = true;
private Task worker = Task.CompletedTask;
public Cdx(App app, CodexWrapper instance)
{
Id = instance.Node.GetName();
log = new LogPrefixer(app.Log, $"[Queue-{Id}]");
this.instance = instance;
}
public string Id { get; }
public void Start()
{
worker = Task.Run(Worker);
}
public void Stop()
{
running = false;
worker.Wait();
}
public void CheckErrors()
{
if (worker.IsFaulted) throw worker.Exception;
}
public void Queue(Action<CodexWrapper> action)
{
if (queue.Count > 2) log.Log("Queue full. Waiting...");
while (queue.Count > 2)
{
Thread.Sleep(TimeSpan.FromSeconds(5.0));
}
lock (queueLock)
{
queue.Add(action);
}
}
private void Worker()
{
try
{
while (running)
{
while (queue.Count == 0) Thread.Sleep(TimeSpan.FromSeconds(5.0));
Action<CodexWrapper> action = w => { };
lock (queueLock)
{
action = queue[0];
queue.RemoveAt(0);
}
action(instance);
}
}
catch (Exception ex)
{
log.Error("Exception in worker: " + ex);
throw;
}
}
}
public LoadBalancer(App app, CodexWrapper[] instances)
{
this.instances = instances.Select(i => new Cdx(app, i)).ToList();
}
public void Start()
{
foreach (var i in instances) i.Start();
}
public void Stop()
{
foreach (var i in instances) i.Stop();
}
public void DispatchOnCodex(Action<CodexWrapper> action)
{
lock (instanceLock)
{
var i = instances.First();
instances.RemoveAt(0);
instances.Add(i);
i.Queue(action);
}
}
public void DispatchOnSpecificCodex(Action<CodexWrapper> action, string id)
{
lock (instanceLock)
{
var i = instances.Single(a => a.Id == id);
instances.Remove(i);
instances.Add(i);
i.Queue(action);
}
}
public void CheckErrors()
{
lock (instanceLock)
{
foreach (var i in instances) i.CheckErrors();
}
}
}
}

View File

@ -1,5 +1,4 @@
using GethConnector;
using Logging;
using Logging;
using Utils;
namespace AutoClient.Modes.FolderStore
@ -8,37 +7,45 @@ namespace AutoClient.Modes.FolderStore
{
private readonly LogPrefixer log;
private readonly GethConnector.GethConnector? connector;
private readonly EthAddress? address;
private readonly EthAddress[] addresses;
public BalanceChecker(App app)
{
log = new LogPrefixer(app.Log, "(Balance) ");
connector = GethConnector.GethConnector.Initialize(app.Log);
address = LoadAddress(app);
addresses = LoadAddresses(app);
log.Log($"Loaded Eth-addresses for checking: {addresses.Length}");
foreach (var addr in addresses) log.Log(" - " + addr);
}
private EthAddress? LoadAddress(App app)
private EthAddress[] LoadAddresses(App app)
{
try
{
if (string.IsNullOrEmpty(app.Config.EthAddressFile)) return null;
if (!File.Exists(app.Config.EthAddressFile)) return null;
if (string.IsNullOrEmpty(app.Config.EthAddressFile)) return Array.Empty<EthAddress>();
return new EthAddress(
File.ReadAllText(app.Config.EthAddressFile)
.Trim()
.Replace("\n", "")
.Replace(Environment.NewLine, "")
);
var tokens = app.Config.EthAddressFile.Split(";", StringSplitOptions.RemoveEmptyEntries);
return tokens.Select(ConvertToAddress).Where(a => a != null).Cast<EthAddress>().ToArray();
}
catch (Exception exc)
{
log.Error($"Failed to load eth address from file: {exc}");
return null;
return Array.Empty<EthAddress>();
}
}
private EthAddress? ConvertToAddress(string t)
{
if (!File.Exists(t)) return null;
return new EthAddress(
File.ReadAllText(t)
.Trim()
.Replace("\n", "")
.Replace(Environment.NewLine, ""));
}
public void Check()
{
if (connector == null)
@ -46,35 +53,32 @@ namespace AutoClient.Modes.FolderStore
Log("Connector not configured. Can't check balances.");
return;
}
if (address == null)
{
Log("EthAddress not found. Can't check balances.");
return;
}
try
foreach (var address in addresses)
{
PerformCheck();
}
catch (Exception exc)
{
Log($"Exception while checking balances: {exc}");
try
{
PerformCheck(address);
}
catch (Exception exc)
{
Log($"Exception while checking balances: {exc}");
}
}
}
private void PerformCheck()
private void PerformCheck(EthAddress address)
{
var geth = connector!.GethNode;
var contracts = connector!.CodexContracts;
var addr = address!;
var eth = geth.GetEthBalance(addr);
var tst = contracts.GetTestTokenBalance(addr);
var eth = geth.GetEthBalance(address);
var tst = contracts.GetTestTokenBalance(address);
Log($"Balances: [{eth}] - [{tst}]");
if (eth.Eth < 1) TryAddEth(geth, addr);
if (tst.Tst < 1) TryAddTst(contracts, addr);
if (eth.Eth < 1) TryAddEth(geth, address);
if (tst.Tst < 1) TryAddTst(contracts, address);
}
private void TryAddEth(GethPlugin.IGethNode geth, EthAddress addr)

View File

@ -4,32 +4,88 @@ using Utils;
namespace AutoClient.Modes.FolderStore
{
public interface IFileSaverEventHandler
{
void SaveChanges();
void OnFailure();
}
public class FileSaver
{
private readonly ILog log;
private readonly LoadBalancer loadBalancer;
private readonly Stats stats;
private readonly string folderFile;
private readonly FileStatus entry;
private readonly IFileSaverEventHandler handler;
public FileSaver(ILog log, LoadBalancer loadBalancer, Stats stats, string folderFile, FileStatus entry, IFileSaverEventHandler handler)
{
this.log = log;
this.loadBalancer = loadBalancer;
this.stats = stats;
this.folderFile = folderFile;
this.entry = entry;
this.handler = handler;
}
public void Process()
{
if (string.IsNullOrEmpty(entry.CodexNodeId))
{
DispatchToAny();
}
else
{
DispatchToSpecific();
}
}
private void DispatchToAny()
{
loadBalancer.DispatchOnCodex(instance =>
{
entry.CodexNodeId = instance.Node.GetName();
handler.SaveChanges();
var run = new FileSaverRun(log, instance, stats, folderFile, entry, handler);
run.Process();
});
}
private void DispatchToSpecific()
{
loadBalancer.DispatchOnSpecificCodex(instance =>
{
var run = new FileSaverRun(log, instance, stats, folderFile, entry, handler);
run.Process();
}, entry.CodexNodeId);
}
}
public class FileSaverRun
{
private readonly ILog log;
private readonly CodexWrapper instance;
private readonly Stats stats;
private readonly string folderFile;
private readonly FileStatus entry;
private readonly Action saveChanges;
private readonly IFileSaverEventHandler handler;
private readonly QuotaCheck quotaCheck;
public FileSaver(ILog log, CodexWrapper instance, Stats stats, string folderFile, FileStatus entry, Action saveChanges)
public FileSaverRun(ILog log, CodexWrapper instance, Stats stats, string folderFile, FileStatus entry, IFileSaverEventHandler handler)
{
this.log = log;
this.instance = instance;
this.stats = stats;
this.folderFile = folderFile;
this.entry = entry;
this.saveChanges = saveChanges;
this.handler = handler;
quotaCheck = new QuotaCheck(log, folderFile, instance);
}
public bool HasFailed { get; private set; }
public void Process()
{
HasFailed = false;
if (HasRecentPurchase())
{
Log($"Purchase running: '{entry.PurchaseId}'");
@ -71,7 +127,7 @@ namespace AutoClient.Modes.FolderStore
Thread.Sleep(TimeSpan.FromMinutes(1.0));
}
Log("Could not upload: Insufficient local storage quota.");
HasFailed = true;
handler.OnFailure();
return false;
}
@ -108,7 +164,7 @@ namespace AutoClient.Modes.FolderStore
var result = instance.Node.LocalFiles();
if (result == null) return false;
if (result.Content == null) return false;
var localCids = result.Content.Where(c => !string.IsNullOrEmpty(c.Cid.Id)).Select(c => c.Cid.Id).ToArray();
var isFound = localCids.Any(c => c.ToLowerInvariant() == entry.BasicCid.ToLowerInvariant());
if (isFound)
@ -150,9 +206,9 @@ namespace AutoClient.Modes.FolderStore
entry.BasicCid = string.Empty;
stats.FailedUploads++;
log.Error("Failed to upload: " + exc);
HasFailed = true;
handler.OnFailure();
}
saveChanges();
handler.SaveChanges();
}
private void CreateNewPurchase()
@ -168,16 +224,17 @@ namespace AutoClient.Modes.FolderStore
WaitForStarted(request);
stats.StorageRequestStats.SuccessfullyStarted++;
saveChanges();
handler.SaveChanges();
Log($"Successfully started new purchase: '{entry.PurchaseId}' for {Time.FormatDuration(request.Purchase.Duration)}");
}
catch (Exception exc)
{
entry.ClearPurchase();
saveChanges();
handler.SaveChanges();
log.Error("Failed to start new purchase: " + exc);
HasFailed = true;
handler.OnFailure();
}
}
@ -196,7 +253,7 @@ namespace AutoClient.Modes.FolderStore
throw new Exception("CID received from storage request was not protected.");
}
saveChanges();
handler.SaveChanges();
Log("Saved new purchaseId: " + entry.PurchaseId);
return request;
}
@ -230,8 +287,9 @@ namespace AutoClient.Modes.FolderStore
else if (!update.IsSubmitted)
{
Log("Request failed to start. State: " + update.State);
entry.ClearPurchase();
saveChanges();
handler.SaveChanges();
return;
}
}
@ -239,10 +297,10 @@ namespace AutoClient.Modes.FolderStore
}
catch (Exception exc)
{
HasFailed = true;
handler.OnFailure();
Log($"Exception in {nameof(WaitForSubmittedToStarted)}: {exc}");
throw;
}
}
}
private void WaitForSubmitted(IStoragePurchaseContract request)

View File

@ -1,29 +1,30 @@
using Logging;
using Utils;
namespace AutoClient.Modes.FolderStore
{
public class FolderSaver
public class FolderSaver : IFileSaverEventHandler
{
private const string FolderSaverFilename = "foldersaver.json";
private readonly App app;
private readonly CodexWrapper instance;
private readonly LoadBalancer loadBalancer;
private readonly JsonFile<FolderStatus> statusFile;
private readonly FolderStatus status;
private readonly BalanceChecker balanceChecker;
private int changeCounter = 0;
private int failureCount = 0;
public FolderSaver(App app, CodexWrapper instance)
public FolderSaver(App app, LoadBalancer loadBalancer)
{
this.app = app;
this.instance = instance;
this.loadBalancer = loadBalancer;
balanceChecker = new BalanceChecker(app);
statusFile = new JsonFile<FolderStatus>(app, Path.Combine(app.Config.FolderToStore, FolderSaverFilename));
status = statusFile.Load();
}
public void Run(CancellationTokenSource cts)
public void Run()
{
var folderFiles = Directory.GetFiles(app.Config.FolderToStore);
if (!folderFiles.Any()) throw new Exception("No files found in " + app.Config.FolderToStore);
@ -32,7 +33,8 @@ namespace AutoClient.Modes.FolderStore
balanceChecker.Check();
foreach (var folderFile in folderFiles)
{
if (cts.IsCancellationRequested) return;
if (app.Cts.IsCancellationRequested) return;
loadBalancer.CheckErrors();
if (!folderFile.ToLowerInvariant().EndsWith(FolderSaverFilename))
{
@ -42,7 +44,7 @@ namespace AutoClient.Modes.FolderStore
if (failureCount > 3)
{
app.Log.Error("Failure count reached threshold. Stopping...");
cts.Cancel();
app.Cts.Cancel();
return;
}
@ -87,7 +89,6 @@ namespace AutoClient.Modes.FolderStore
{
var fileSaver = CreateFileSaver(folderFile, entry);
fileSaver.Process();
if (fileSaver.HasFailed) failureCount++;
}
private void SaveFolderSaverJsonFile()
@ -101,7 +102,6 @@ namespace AutoClient.Modes.FolderStore
ApplyPadding(folderFile);
var fileSaver = CreateFileSaver(folderFile, entry);
fileSaver.Process();
if (fileSaver.HasFailed) failureCount++;
if (!string.IsNullOrEmpty(entry.EncodedCid))
{
@ -126,33 +126,27 @@ namespace AutoClient.Modes.FolderStore
if (info.Length < min)
{
var required = Math.Max(1024, min - info.Length);
status.Padding = paddingMessage + GenerateRandomString(required);
status.Padding = paddingMessage + RandomUtils.GenerateRandomString(required);
statusFile.Save(status);
}
}
private string GenerateRandomString(long required)
{
var result = "";
while (result.Length < required)
{
var bytes = new byte[1024];
random.NextBytes(bytes);
result += string.Join("", bytes.Select(b => b.ToString()));
}
return result;
}
private FileSaver CreateFileSaver(string folderFile, FileStatus entry)
{
var fixedLength = entry.Filename.PadRight(35);
var prefix = $"[{fixedLength}] ";
return new FileSaver(new LogPrefixer(app.Log, prefix), instance, status.Stats, folderFile, entry, saveChanges: () =>
{
statusFile.Save(status);
changeCounter++;
});
return new FileSaver(new LogPrefixer(app.Log, prefix), loadBalancer, status.Stats, folderFile, entry, this);
}
public void SaveChanges()
{
statusFile.Save(status);
changeCounter++;
}
public void OnFailure()
{
failureCount++;
}
}
}

View File

@ -11,6 +11,7 @@
[Serializable]
public class FileStatus
{
public string CodexNodeId { get; set; } = string.Empty;
public string Filename { get; set; } = string.Empty;
public string BasicCid { get; set; } = string.Empty;
public string EncodedCid { get; set; } = string.Empty;

View File

@ -6,6 +6,7 @@ namespace AutoClient.Modes.FolderStore
{
private readonly App app;
private readonly string filePath;
private readonly object fileLock = new object();
public JsonFile(App app, string filePath)
{
@ -15,35 +16,41 @@ namespace AutoClient.Modes.FolderStore
public T Load()
{
try
lock (fileLock)
{
if (!File.Exists(filePath))
try
{
var state = new T();
Save(state);
return state;
if (!File.Exists(filePath))
{
var state = new T();
Save(state);
return state;
}
var text = File.ReadAllText(filePath);
return JsonConvert.DeserializeObject<T>(text)!;
}
catch (Exception exc)
{
app.Log.Error("Failed to load state: " + exc);
throw;
}
var text = File.ReadAllText(filePath);
return JsonConvert.DeserializeObject<T>(text)!;
}
catch (Exception exc)
{
app.Log.Error("Failed to load state: " + exc);
throw;
}
}
public void Save(T state)
{
try
lock (fileLock)
{
var json = JsonConvert.SerializeObject(state, Formatting.Indented);
File.WriteAllText(filePath, json);
}
catch (Exception exc)
{
app.Log.Error("Failed to save state: " + exc);
throw;
try
{
var json = JsonConvert.SerializeObject(state, Formatting.Indented);
File.WriteAllText(filePath, json);
}
catch (Exception exc)
{
app.Log.Error("Failed to save state: " + exc);
throw;
}
}
}
}

View File

@ -2,31 +2,28 @@
namespace AutoClient.Modes
{
public class FolderStoreMode : IMode
public class FolderStoreMode
{
private readonly App app;
private readonly string folder;
private readonly PurchaseInfo purchaseInfo;
private readonly CancellationTokenSource cts = new CancellationTokenSource();
private Task checkTask = Task.CompletedTask;
private readonly LoadBalancer loadBalancer;
public FolderStoreMode(App app, string folder, PurchaseInfo purchaseInfo)
public FolderStoreMode(App app, LoadBalancer loadBalancer)
{
this.app = app;
this.folder = folder;
this.purchaseInfo = purchaseInfo;
this.loadBalancer = loadBalancer;
}
public void Start(CodexWrapper instance, int index)
public void Start()
{
checkTask = Task.Run(() =>
{
try
{
var saver = new FolderSaver(app, instance);
while (!cts.IsCancellationRequested)
var saver = new FolderSaver(app, loadBalancer);
while (!app.Cts.IsCancellationRequested)
{
saver.Run(cts);
saver.Run();
}
}
catch (Exception ex)
@ -39,7 +36,7 @@ namespace AutoClient.Modes
public void Stop()
{
cts.Cancel();
app.Cts.Cancel();
checkTask.Wait();
}
}

View File

@ -1,8 +0,0 @@
namespace AutoClient.Modes
{
public interface IMode
{
void Start(CodexWrapper node, int index);
void Stop();
}
}

View File

@ -1,48 +0,0 @@
using Logging;
namespace AutoClient.Modes
{
public class PurchasingMode : IMode
{
private readonly List<AutomaticPurchaser> purchasers = new List<AutomaticPurchaser>();
private readonly App app;
private Task starterTask = Task.CompletedTask;
public PurchasingMode(App app)
{
this.app = app;
}
public void Start(CodexWrapper node, int index)
{
for (var i = 0; i < app.Config.NumConcurrentPurchases; i++)
{
purchasers.Add(new AutomaticPurchaser(app, new LogPrefixer(app.Log, $"({i}) "), node));
}
var delayPerPurchaser =
TimeSpan.FromSeconds(10 * index) +
TimeSpan.FromMinutes(app.Config.ContractDurationMinutes) / app.Config.NumConcurrentPurchases;
starterTask = Task.Run(() => StartPurchasers(delayPerPurchaser));
}
private async Task StartPurchasers(TimeSpan delayPerPurchaser)
{
foreach (var purchaser in purchasers)
{
purchaser.Start();
await Task.Delay(delayPerPurchaser);
}
}
public void Stop()
{
starterTask.Wait();
foreach (var purchaser in purchasers)
{
purchaser.Stop();
}
}
}
}

View File

@ -1,22 +1,22 @@
using ArgsUniform;
using AutoClient;
using AutoClient.Modes;
using AutoClient.Modes.FolderStore;
using CodexClient;
using GethPlugin;
using Utils;
using WebUtils;
using Logging;
public class Program
{
private readonly App app;
private readonly List<IMode> modes = new List<IMode>();
public Program(Configuration config)
{
app = new App(config);
}
public static async Task Main(string[] args)
public static void Main(string[] args)
{
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (sender, args) => cts.Cancel();
@ -30,60 +30,37 @@ public class Program
}
var p = new Program(config);
await p.Run();
p.Run();
}
public async Task Run()
public void Run()
{
await Task.CompletedTask;
if (app.Config.ContractDurationMinutes - 1 < 5) throw new Exception("Contract duration config option not long enough!");
var codexNodes = CreateCodexWrappers();
var loadBalancer = new LoadBalancer(app, codexNodes);
loadBalancer.Start();
var i = 0;
foreach (var cdx in codexNodes)
{
var mode = CreateMode();
modes.Add(mode);
mode.Start(cdx, i);
i++;
}
var folderStore = new FolderStoreMode(app, loadBalancer);
folderStore.Start();
app.Cts.Token.WaitHandle.WaitOne();
foreach (var mode in modes) mode.Stop();
modes.Clear();
folderStore.Stop();
loadBalancer.Stop();
app.Log.Log("Done");
}
private IMode CreateMode()
{
if (!string.IsNullOrEmpty(app.Config.FolderToStore))
{
return CreateFolderStoreMode();
}
return new PurchasingMode(app);
}
private IMode CreateFolderStoreMode()
{
if (app.Config.ContractDurationMinutes - 1 < 5) throw new Exception("Contract duration config option not long enough!");
return new FolderStoreMode(app, app.Config.FolderToStore, new PurchaseInfo(
purchaseDurationTotal: TimeSpan.FromMinutes(app.Config.ContractDurationMinutes),
purchaseDurationSafe: TimeSpan.FromMinutes(app.Config.ContractDurationMinutes - 120)
));
}
private CodexWrapper[] CreateCodexWrappers()
{
var endpointStrs = app.Config.CodexEndpoints.Split(";", StringSplitOptions.RemoveEmptyEntries);
var result = new List<CodexWrapper>();
var i = 1;
foreach (var e in endpointStrs)
{
result.Add(CreateCodexWrapper(e));
result.Add(CreateCodexWrapper(e, i));
i++;
}
return result.ToArray();
@ -91,7 +68,7 @@ public class Program
private readonly string LogLevel = "TRACE;info:discv5,providers,routingtable,manager,cache;warn:libp2p,multistream,switch,transport,tcptransport,semaphore,asyncstreamwrapper,lpstream,mplex,mplexchannel,noise,bufferstream,mplexcoder,secure,chronosstream,connection,websock,ws-session,muxedupgrade,upgrade,identify,contracts,clock,serde,json,serialization,JSONRPC-WS-CLIENT,JSONRPC-HTTP-CLIENT,repostore";
private CodexWrapper CreateCodexWrapper(string endpoint)
private CodexWrapper CreateCodexWrapper(string endpoint, int number)
{
var splitIndex = endpoint.LastIndexOf(':');
var host = endpoint.Substring(0, splitIndex);
@ -103,11 +80,14 @@ public class Program
port: port
);
var instance = CodexInstance.CreateFromApiEndpoint("[AutoClient]", address, EthAccountGenerator.GenerateNew());
var node = app.CodexNodeFactory.CreateCodexNode(instance);
var numberStr = number.ToString().PadLeft(3, '0');
var log = new LogPrefixer(app.Log, $"[{numberStr}] ");
var httpFactory = new HttpFactory(log, new AutoClientWebTimeSet());
var codexNodeFactory = new CodexNodeFactory(log: log, httpFactory: httpFactory, dataDir: app.Config.DataPath);
var instance = CodexInstance.CreateFromApiEndpoint($"[AC-{numberStr}]", address, EthAccountGenerator.GenerateNew());
var node = codexNodeFactory.CreateCodexNode(instance);
node.SetLogLevel(LogLevel);
return new CodexWrapper(app, node);
}

View File

@ -1,7 +1,6 @@
using BiblioTech.Options;
using Discord;
using Discord.WebSocket;
using Org.BouncyCastle.Utilities;
namespace BiblioTech
{

View File

@ -0,0 +1,66 @@
using Logging;
namespace BiblioTech
{
public class CallDispatcher
{
private readonly ILog log;
private readonly object _lock = new object();
private readonly List<Action> queue = new List<Action>();
private readonly AutoResetEvent autoResetEvent = new AutoResetEvent(false);
public CallDispatcher(ILog log)
{
this.log = log;
}
public void Add(Action call)
{
lock (_lock)
{
queue.Add(call);
autoResetEvent.Set();
if (queue.Count > 100)
{
log.Error("Queue overflow!");
queue.Clear();
}
}
}
public void Start()
{
Task.Run(() =>
{
while (true)
{
try
{
Worker();
}
catch (Exception ex)
{
log.Error("Exception in CallDispatcher: " + ex);
}
}
});
}
private void Worker()
{
autoResetEvent.WaitOne();
var tasks = Array.Empty<Action>();
lock (_lock)
{
tasks = queue.ToArray();
queue.Clear();
}
foreach (var task in tasks)
{
task();
}
}
}
}

View File

@ -0,0 +1,78 @@
using BiblioTech.Rewards;
using Discord;
using Logging;
using System.Threading.Tasks;
namespace BiblioTech.CodexChecking
{
public class ActiveP2pRoleRemover
{
private readonly Configuration config;
private readonly ILog log;
private readonly CheckRepo repo;
public ActiveP2pRoleRemover(Configuration config, ILog log, CheckRepo repo)
{
this.config = config;
this.log = log;
this.repo = repo;
}
public void Start()
{
if (config.ActiveP2pRoleDurationMinutes > 0)
{
Task.Run(Worker);
}
}
private void Worker()
{
var loopDelay = TimeSpan.FromMinutes(config.ActiveP2pRoleDurationMinutes) / 60;
var min = TimeSpan.FromMinutes(10.0);
if (loopDelay < min) loopDelay = min;
try
{
while (true)
{
Thread.Sleep(loopDelay);
CheckP2pRoleRemoval();
}
}
catch (Exception ex)
{
log.Error($"Exception in {nameof(ActiveP2pRoleRemover)}: {ex}");
Environment.Exit(1);
}
}
private void CheckP2pRoleRemoval()
{
var expiryMoment = DateTime.UtcNow - TimeSpan.FromMinutes(config.ActiveP2pRoleDurationMinutes);
Program.RoleDriver.IterateUsersWithRoles(
(g, u, r) => OnUserWithRole(g, u, r, expiryMoment),
Program.Config.ActiveP2pParticipantRoleId);
}
private async Task OnUserWithRole(IRoleGiver giver, IUser user, ulong roleId, DateTime expiryMoment)
{
var report = repo.GetOrCreate(user.Id);
if (report.UploadCheck.CompletedUtc > expiryMoment) return;
if (report.DownloadCheck.CompletedUtc > expiryMoment) return;
await giver.RemoveActiveP2pParticipant(user.Id);
}
private bool ShouldRemoveRole(IUser user, DateTime expiryMoment)
{
var report = repo.GetOrCreate(user.Id);
if (report.UploadCheck.CompletedUtc > expiryMoment) return false;
if (report.DownloadCheck.CompletedUtc > expiryMoment) return false;
return true;
}
}
}

View File

@ -0,0 +1,78 @@
using Newtonsoft.Json;
namespace BiblioTech.CodexChecking
{
public class CheckRepo
{
private const string modelFilename = "model.json";
private readonly Configuration config;
private readonly object _lock = new object();
private CheckRepoModel? model = null;
public CheckRepo(Configuration config)
{
this.config = config;
}
public CheckReport GetOrCreate(ulong userId)
{
lock (_lock)
{
if (model == null) LoadModel();
var existing = model.Reports.SingleOrDefault(r => r.UserId == userId);
if (existing == null)
{
var newEntry = new CheckReport
{
UserId = userId,
};
model.Reports.Add(newEntry);
SaveChanges();
return newEntry;
}
return existing;
}
}
public void SaveChanges()
{
File.WriteAllText(GetModelFilepath(), JsonConvert.SerializeObject(model, Formatting.Indented));
}
private void LoadModel()
{
if (!File.Exists(GetModelFilepath()))
{
model = new CheckRepoModel();
SaveChanges();
return;
}
model = JsonConvert.DeserializeObject<CheckRepoModel>(File.ReadAllText(GetModelFilepath()));
}
private string GetModelFilepath()
{
return Path.Combine(config.ChecksDataPath, modelFilename);
}
}
public class CheckRepoModel
{
public List<CheckReport> Reports { get; set; } = new List<CheckReport>();
}
public class CheckReport
{
public ulong UserId { get; set; }
public TransferCheck UploadCheck { get; set; } = new TransferCheck();
public TransferCheck DownloadCheck { get; set; } = new TransferCheck();
}
public class TransferCheck
{
public DateTime CompletedUtc { get; set; } = DateTime.MinValue;
public string UniqueData { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,219 @@
using CodexClient;
using FileUtils;
using Logging;
using Utils;
namespace BiblioTech.CodexChecking
{
public interface ICheckResponseHandler
{
Task CheckNotStarted();
Task NowCompleted(ulong userId, string checkName);
Task GiveRoleReward();
Task InvalidData();
Task CouldNotDownloadCid();
Task GiveCidToUser(string cid);
Task GiveDataFileToUser(string fileContent);
Task ToAdminChannel(string msg);
}
public class CodexTwoWayChecker
{
private readonly ILog log;
private readonly Configuration config;
private readonly CheckRepo repo;
private readonly CodexWrapper codexWrapper;
public CodexTwoWayChecker(ILog log, Configuration config, CheckRepo repo, CodexWrapper codexWrapper)
{
this.log = log;
this.config = config;
this.repo = repo;
this.codexWrapper = codexWrapper;
}
public async Task StartDownloadCheck(ICheckResponseHandler handler, ulong userId)
{
var check = repo.GetOrCreate(userId).DownloadCheck;
if (string.IsNullOrEmpty(check.UniqueData))
{
check.UniqueData = GenerateUniqueData();
repo.SaveChanges();
}
var cid = UploadData(check.UniqueData);
await handler.GiveCidToUser(cid);
}
public async Task VerifyDownloadCheck(ICheckResponseHandler handler, ulong userId, string receivedData)
{
var check = repo.GetOrCreate(userId).DownloadCheck;
if (string.IsNullOrEmpty(check.UniqueData))
{
await handler.CheckNotStarted();
return;
}
Log($"Verifying for downloadCheck: received: '{receivedData}' check: '{check.UniqueData}'");
if (string.IsNullOrEmpty(receivedData) || receivedData != check.UniqueData)
{
await handler.InvalidData();
return;
}
await CheckNowCompleted(handler, check, userId, "DownloadCheck");
}
public async Task StartUploadCheck(ICheckResponseHandler handler, ulong userId)
{
var check = repo.GetOrCreate(userId).UploadCheck;
if (string.IsNullOrEmpty(check.UniqueData))
{
check.UniqueData = GenerateUniqueData();
repo.SaveChanges();
}
await handler.GiveDataFileToUser(check.UniqueData);
}
public async Task VerifyUploadCheck(ICheckResponseHandler handler, ulong userId, string receivedCid)
{
var check = repo.GetOrCreate(userId).UploadCheck;
if (string.IsNullOrEmpty(receivedCid))
{
await handler.InvalidData();
return;
}
var manifest = GetManifest(receivedCid);
if (manifest == null)
{
await handler.CouldNotDownloadCid();
return;
}
if (IsManifestLengthCompatible(handler, check, manifest))
{
if (IsContentCorrect(handler, check, receivedCid))
{
await CheckNowCompleted(handler, check, userId, "UploadCheck");
return;
}
}
await handler.InvalidData();
}
private string GenerateUniqueData()
{
return $"{RandomBusyMessage.Get().Substring(5)}{RandomUtils.GenerateRandomString(12)}";
}
private string UploadData(string uniqueData)
{
var filePath = Path.Combine(config.ChecksDataPath, Guid.NewGuid().ToString());
try
{
File.WriteAllText(filePath, uniqueData);
var file = new TrackedFile(log, filePath, "checkData");
return codexWrapper.OnCodex(node =>
{
return node.UploadFile(file).Id;
});
}
catch (Exception ex)
{
log.Error("Exception when uploading data: " + ex);
throw;
}
finally
{
if (File.Exists(filePath)) File.Delete(filePath);
}
}
private Manifest? GetManifest(string receivedCid)
{
try
{
return codexWrapper.OnCodex(node =>
{
return node.DownloadManifestOnly(new ContentId(receivedCid)).Manifest;
});
}
catch
{
return null;
}
}
private bool IsManifestLengthCompatible(ICheckResponseHandler handler, TransferCheck check, Manifest manifest)
{
var dataLength = check.UniqueData.Length;
var manifestLength = manifest.OriginalBytes.SizeInBytes;
Log($"Checking manifest length: dataLength={dataLength},manifestLength={manifestLength}");
return
manifestLength > (dataLength - 1) &&
manifestLength < (dataLength + 1);
}
private bool IsContentCorrect(ICheckResponseHandler handler, TransferCheck check, string receivedCid)
{
try
{
var content = codexWrapper.OnCodex(node =>
{
var file = node.DownloadContent(new ContentId(receivedCid));
if (file == null) return string.Empty;
try
{
return File.ReadAllText(file.Filename).Trim();
}
finally
{
if (File.Exists(file.Filename)) File.Delete(file.Filename);
}
});
Log($"Checking content: content={content},check={check.UniqueData}");
return content == check.UniqueData;
}
catch
{
return false;
}
}
private async Task CheckNowCompleted(ICheckResponseHandler handler, TransferCheck check, ulong userId, string checkName)
{
await handler.NowCompleted(userId, checkName);
check.CompletedUtc = DateTime.UtcNow;
repo.SaveChanges();
await CheckUserForRoleRewards(handler, userId);
}
private async Task CheckUserForRoleRewards(ICheckResponseHandler handler, ulong userId)
{
var check = repo.GetOrCreate(userId);
if (check.UploadCheck.CompletedUtc != DateTime.MinValue &&
check.DownloadCheck.CompletedUtc != DateTime.MinValue)
{
await handler.GiveRoleReward();
}
}
private void Log(string msg)
{
log.Log(msg);
}
}
}

View File

@ -0,0 +1,85 @@
using CodexClient;
using IdentityModel.Client;
using Logging;
using Utils;
using WebUtils;
namespace BiblioTech.CodexChecking
{
public class CodexWrapper
{
private readonly CodexNodeFactory factory;
private readonly ILog log;
private readonly Configuration config;
private readonly object codexLock = new object();
private ICodexNode? currentCodexNode;
public CodexWrapper(ILog log, Configuration config)
{
this.log = log;
this.config = config;
var httpFactory = CreateHttpFactory();
factory = new CodexNodeFactory(log, httpFactory, dataDir: config.DataPath);
}
public void OnCodex(Action<ICodexNode> action)
{
lock (codexLock)
{
action(Get());
}
}
public T OnCodex<T>(Func<ICodexNode, T> func)
{
lock (codexLock)
{
return func(Get());
}
}
private ICodexNode Get()
{
if (currentCodexNode == null)
{
currentCodexNode = CreateCodex();
}
return currentCodexNode;
}
private ICodexNode CreateCodex()
{
var endpoint = config.CodexEndpoint;
var splitIndex = endpoint.LastIndexOf(':');
var host = endpoint.Substring(0, splitIndex);
var port = Convert.ToInt32(endpoint.Substring(splitIndex + 1));
var address = new Address(
logName: $"cdx@{host}:{port}",
host: host,
port: port
);
var instance = CodexInstance.CreateFromApiEndpoint("ac", address);
return factory.CreateCodexNode(instance);
}
private HttpFactory CreateHttpFactory()
{
if (string.IsNullOrEmpty(config.CodexEndpointAuth) || !config.CodexEndpointAuth.Contains(":"))
{
return new HttpFactory(log);
}
var tokens = config.CodexEndpointAuth.Split(':');
if (tokens.Length != 2) throw new Exception("Expected '<username>:<password>' in CodexEndpointAuth parameter.");
return new HttpFactory(log, onClientCreated: client =>
{
client.SetBasicAuthentication(tokens[0], tokens[1]);
});
}
}
}

View File

@ -1,204 +0,0 @@
using CodexClient;
using Logging;
using Utils;
namespace BiblioTech
{
public class CodexCidChecker
{
private static readonly string nl = Environment.NewLine;
private readonly Configuration config;
private readonly ILog log;
private readonly Mutex checkMutex = new Mutex();
private readonly CodexNodeFactory factory;
private ICodexNode? currentCodexNode;
public CodexCidChecker(Configuration config, ILog log)
{
this.config = config;
this.log = log;
factory = new CodexNodeFactory(log, dataDir: config.DataPath);
if (!string.IsNullOrEmpty(config.CodexEndpointAuth) && config.CodexEndpointAuth.Contains(":"))
{
throw new Exception("Todo: codexnodefactory httpfactory support basicauth!");
//var tokens = config.CodexEndpointAuth.Split(':');
//if (tokens.Length != 2) throw new Exception("Expected '<username>:<password>' in CodexEndpointAuth parameter.");
//client.SetBasicAuthentication(tokens[0], tokens[1]);
}
}
public CheckResponse PerformCheck(string cid)
{
if (string.IsNullOrEmpty(config.CodexEndpoint))
{
return new CheckResponse(false, "Codex CID checker is not (yet) available.", "");
}
try
{
checkMutex.WaitOne();
var codex = GetCodex();
var nodeCheck = CheckCodex(codex);
if (!nodeCheck) return new CheckResponse(false, "Codex node is not available. Cannot perform check.", $"Codex node at '{config.CodexEndpoint}' did not respond correctly to debug/info.");
return PerformCheck(codex, cid);
}
catch (Exception ex)
{
return new CheckResponse(false, "Internal server error", ex.ToString());
}
finally
{
checkMutex.ReleaseMutex();
}
}
private CheckResponse PerformCheck(ICodexNode codex, string cid)
{
try
{
var manifest = codex.DownloadManifestOnly(new ContentId(cid));
return SuccessMessage(manifest);
}
catch (Exception ex)
{
return UnexpectedException(ex);
}
}
#region Response formatting
private CheckResponse SuccessMessage(LocalDataset content)
{
return FormatResponse(
success: true,
title: $"Success: '{content.Cid}'",
error: "",
$"size: {content.Manifest.OriginalBytes} bytes",
$"blockSize: {content.Manifest.BlockSize} bytes",
$"protected: {content.Manifest.Protected}"
);
}
private CheckResponse UnexpectedException(Exception ex)
{
return FormatResponse(
success: false,
title: "Unexpected error",
error: ex.ToString(),
content: "Details will be sent to the bot-admin channel."
);
}
private CheckResponse UnexpectedReturnCode(string response)
{
var msg = "Unexpected return code. Response: " + response;
return FormatResponse(
success: false,
title: "Unexpected return code",
error: msg,
content: msg
);
}
private CheckResponse FailedToFetch(string response)
{
var msg = "Failed to download content. Response: " + response;
return FormatResponse(
success: false,
title: "Could not download content",
error: msg,
msg,
$"Connection trouble? See 'https://docs.codex.storage/learn/troubleshoot'"
);
}
private CheckResponse CidFormatInvalid(string response)
{
return FormatResponse(
success: false,
title: "Invalid format",
error: "",
content: "Provided CID is not formatted correctly."
);
}
private CheckResponse FormatResponse(bool success, string title, string error, params string[] content)
{
var msg = string.Join(nl,
new string[]
{
title,
"```"
}
.Concat(content)
.Concat(new string[]
{
"```"
})
) + nl + nl;
return new CheckResponse(success, msg, error);
}
#endregion
#region Codex Node API
private ICodexNode GetCodex()
{
if (currentCodexNode == null) currentCodexNode = CreateCodex();
return currentCodexNode;
}
private bool CheckCodex(ICodexNode node)
{
try
{
var info = node.GetDebugInfo();
if (info == null || string.IsNullOrEmpty(info.Id)) return false;
return true;
}
catch (Exception e)
{
log.Error(e.ToString());
return false;
}
}
private ICodexNode CreateCodex()
{
var endpoint = config.CodexEndpoint;
var splitIndex = endpoint.LastIndexOf(':');
var host = endpoint.Substring(0, splitIndex);
var port = Convert.ToInt32(endpoint.Substring(splitIndex + 1));
var address = new Address(
logName: $"cdx@{host}:{port}",
host: host,
port: port
);
var instance = CodexInstance.CreateFromApiEndpoint("ac", address);
return factory.CreateCodexNode(instance);
}
#endregion
}
public class CheckResponse
{
public CheckResponse(bool success, string message, string error)
{
Success = success;
Message = message;
Error = error;
}
public bool Success { get; }
public string Message { get; }
public string Error { get; }
}
}

View File

@ -4,6 +4,9 @@ using Discord;
using Newtonsoft.Json;
using BiblioTech.Rewards;
using Logging;
using BiblioTech.CodexChecking;
using Nethereum.Model;
using static Org.BouncyCastle.Math.EC.ECCurve;
namespace BiblioTech
{
@ -11,13 +14,15 @@ namespace BiblioTech
{
private readonly DiscordSocketClient client;
private readonly CustomReplacement replacement;
private readonly ActiveP2pRoleRemover roleRemover;
private readonly BaseCommand[] commands;
private readonly ILog log;
public CommandHandler(ILog log, DiscordSocketClient client, CustomReplacement replacement, params BaseCommand[] commands)
public CommandHandler(ILog log, DiscordSocketClient client, CustomReplacement replacement, ActiveP2pRoleRemover roleRemover, params BaseCommand[] commands)
{
this.client = client;
this.replacement = replacement;
this.roleRemover = roleRemover;
this.commands = commands;
this.log = log;
client.Ready += Client_Ready;
@ -30,10 +35,15 @@ namespace BiblioTech
Program.AdminChecker.SetGuild(guild);
log.Log($"Initializing for guild: '{guild.Name}'");
var adminChannels = guild.TextChannels.Where(Program.AdminChecker.IsAdminChannel).ToArray();
if (adminChannels == null || !adminChannels.Any()) throw new Exception("No admin message channel");
Program.AdminChecker.SetAdminChannel(adminChannels.First());
Program.RoleDriver = new RoleDriver(client, log, replacement);
var adminChannel = GetChannel(guild, Program.Config.AdminChannelId);
if (adminChannel == null) throw new Exception("No admin message channel");
var chainEventsChannel = GetChannel(guild, Program.Config.ChainEventsChannelId);
var rewardsChannel = GetChannel(guild, Program.Config.RewardsChannelId);
Program.AdminChecker.SetAdminChannel(adminChannel);
Program.RoleDriver = new RoleDriver(client, Program.UserRepo, log, rewardsChannel);
Program.ChainActivityHandler = new ChainActivityHandler(log, Program.UserRepo);
Program.EventsSender = new ChainEventsSender(log, replacement, chainEventsChannel);
var builders = commands.Select(c =>
{
@ -65,6 +75,8 @@ namespace BiblioTech
{
log.Log($"{cmd.Name} ({cmd.Description}) [{DescribOptions(cmd.Options)}]");
}
roleRemover.Start();
}
catch (HttpException exception)
{
@ -72,9 +84,16 @@ namespace BiblioTech
log.Error(json);
throw;
}
Program.Dispatcher.Start();
log.Log("Initialized.");
}
private SocketTextChannel? GetChannel(SocketGuild guild, ulong id)
{
if (id == 0) return null;
return guild.TextChannels.SingleOrDefault(c => c.Id == id);
}
private string DescribOptions(IReadOnlyCollection<SocketApplicationCommandOption> options)
{
return string.Join(",", options.Select(DescribeOption).ToArray());

View File

@ -8,16 +8,10 @@ namespace BiblioTech.Commands
private readonly ClearUserAssociationCommand clearCommand = new ClearUserAssociationCommand();
private readonly ReportCommand reportCommand = new ReportCommand();
private readonly WhoIsCommand whoIsCommand = new WhoIsCommand();
private readonly AddSprCommand addSprCommand;
private readonly ClearSprsCommand clearSprsCommand;
private readonly GetSprCommand getSprCommand;
private readonly LogReplaceCommand logReplaceCommand;
public AdminCommand(SprCommand sprCommand, CustomReplacement replacement)
public AdminCommand(CustomReplacement replacement)
{
addSprCommand = new AddSprCommand(sprCommand);
clearSprsCommand = new ClearSprsCommand(sprCommand);
getSprCommand = new GetSprCommand(sprCommand);
logReplaceCommand = new LogReplaceCommand(replacement);
}
@ -30,9 +24,6 @@ namespace BiblioTech.Commands
clearCommand,
reportCommand,
whoIsCommand,
addSprCommand,
clearSprsCommand,
getSprCommand,
logReplaceCommand
};
@ -53,9 +44,6 @@ namespace BiblioTech.Commands
await clearCommand.CommandHandler(context);
await reportCommand.CommandHandler(context);
await whoIsCommand.CommandHandler(context);
await addSprCommand.CommandHandler(context);
await clearSprsCommand.CommandHandler(context);
await getSprCommand.CommandHandler(context);
await logReplaceCommand.CommandHandler(context);
}
@ -144,78 +132,6 @@ namespace BiblioTech.Commands
}
}
public class AddSprCommand : SubCommandOption
{
private readonly SprCommand sprCommand;
private readonly StringOption stringOption = new StringOption("spr", "Codex SPR", true);
public AddSprCommand(SprCommand sprCommand)
: base(name: "addspr",
description: "Adds a Codex SPR, to be given to users with '/boot'.")
{
this.sprCommand = sprCommand;
}
public override CommandOption[] Options => new[] { stringOption };
protected override async Task onSubCommand(CommandContext context)
{
var spr = await stringOption.Parse(context);
if (!string.IsNullOrEmpty(spr) )
{
sprCommand.Add(spr);
await context.Followup("A-OK!");
}
else
{
await context.Followup("SPR is null or empty.");
}
}
}
public class ClearSprsCommand : SubCommandOption
{
private readonly SprCommand sprCommand;
private readonly StringOption stringOption = new StringOption("areyousure", "set to 'true' if you are.", true);
public ClearSprsCommand(SprCommand sprCommand)
: base(name: "clearsprs",
description: "Clears all Codex SPRs in the bot. Users won't be able to use '/boot' till new ones are added.")
{
this.sprCommand = sprCommand;
}
public override CommandOption[] Options => new[] { stringOption };
protected override async Task onSubCommand(CommandContext context)
{
var areyousure = await stringOption.Parse(context);
if (areyousure != "true") return;
sprCommand.Clear();
await context.Followup("Cleared all SPRs.");
}
}
public class GetSprCommand : SubCommandOption
{
private readonly SprCommand sprCommand;
public GetSprCommand(SprCommand sprCommand)
: base(name: "getsprs",
description: "Shows all Codex SPRs in the bot.")
{
this.sprCommand = sprCommand;
}
protected override async Task onSubCommand(CommandContext context)
{
await context.Followup("SPRs: " + string.Join(", ", sprCommand.Get().Select(s => $"'{s}'")));
}
}
public class LogReplaceCommand : SubCommandOption
{
private readonly CustomReplacement replacement;

View File

@ -1,111 +0,0 @@
using BiblioTech.Options;
using Discord;
namespace BiblioTech.Commands
{
public class CheckCidCommand : BaseCommand
{
private readonly StringOption cidOption = new StringOption(
name: "cid",
description: "Codex Content-Identifier",
isRequired: true);
private readonly CodexCidChecker checker;
private readonly CidStorage cidStorage;
public CheckCidCommand(CodexCidChecker checker)
{
this.checker = checker;
this.cidStorage = new CidStorage(Path.Combine(Program.Config.DataPath, "valid_cids.txt"));
}
public override string Name => "check";
public override string StartingMessage => RandomBusyMessage.Get();
public override string Description => "Checks if content is available in the testnet.";
public override CommandOption[] Options => new[] { cidOption };
protected override async Task Invoke(CommandContext context)
{
var user = context.Command.User;
var cid = await cidOption.Parse(context);
if (string.IsNullOrEmpty(cid))
{
await context.Followup("Option 'cid' was not received.");
return;
}
var response = checker.PerformCheck(cid);
await Program.AdminChecker.SendInAdminChannel($"User {Mention(user)} used '/{Name}' for cid '{cid}'. Lookup-success: {response.Success}. Message: '{response.Message}' Error: '{response.Error}'");
if (response.Success)
{
await CheckAltruisticRole(context, user, cid, response.Message);
return;
}
await context.Followup(response.Message);
}
private async Task CheckAltruisticRole(CommandContext context, IUser user, string cid, string responseMessage)
{
if (cidStorage.TryAddCid(cid, user.Id))
{
if (await GiveAltruisticRole(context, user, responseMessage))
{
return;
}
}
else
{
await context.Followup($"{responseMessage}\n\nThis CID has already been used by another user. No role will be granted.");
return;
}
await context.Followup(responseMessage);
}
private async Task<bool> GiveAltruisticRole(CommandContext context, IUser user, string responseMessage)
{
try
{
await Program.RoleDriver.GiveAltruisticRole(user);
await context.Followup($"{responseMessage}\n\nCongratulations! You've been granted the Altruistic Mode role for checking a valid CID!");
return true;
}
catch (Exception ex)
{
await Program.AdminChecker.SendInAdminChannel($"Failed to grant Altruistic Mode role to user {Mention(user)}: {ex.Message}");
return false;
}
}
}
public class CidStorage
{
private readonly string filePath;
private static readonly object _lock = new object();
public CidStorage(string filePath)
{
this.filePath = filePath;
if (!File.Exists(filePath))
{
File.WriteAllText(filePath, string.Empty);
}
}
public bool TryAddCid(string cid, ulong userId)
{
lock (_lock)
{
var existingEntries = File.ReadAllLines(filePath);
if (existingEntries.Any(line => line.Split(',')[0] == cid))
{
return false;
}
File.AppendAllLines(filePath, new[] { $"{cid},{userId}" });
return true;
}
}
}
}

View File

@ -0,0 +1,58 @@
using BiblioTech.CodexChecking;
using BiblioTech.Options;
namespace BiblioTech.Commands
{
public class CheckDownloadCommand : BaseCommand
{
private readonly CodexTwoWayChecker checker;
private readonly StringOption contentOption = new StringOption(
name: "content",
description: "Content of the downloaded file",
isRequired: false);
public CheckDownloadCommand(CodexTwoWayChecker checker)
{
this.checker = checker;
}
public override string Name => "checkdownload";
public override string StartingMessage => RandomBusyMessage.Get();
public override string Description => "Checks the download connectivity of your Codex node.";
public override CommandOption[] Options => [contentOption];
protected override async Task Invoke(CommandContext context)
{
var user = context.Command.User;
var content = await contentOption.Parse(context);
try
{
var handler = new CheckResponseHandler(context, user);
if (string.IsNullOrEmpty(content))
{
await checker.StartDownloadCheck(handler, user.Id);
}
else
{
if (content.Length > 1024)
{
await context.Followup("Provided content is too long!");
return;
}
await checker.VerifyDownloadCheck(handler, user.Id, content);
}
}
catch (Exception ex)
{
await RespondWithError(context, ex);
}
}
private async Task RespondWithError(CommandContext context, Exception ex)
{
await Program.AdminChecker.SendInAdminChannel("Exception during CheckDownloadCommand: " + ex);
await context.Followup("I'm sorry to report something has gone wrong in an unexpected way. Error details are already posted in the admin channel.");
}
}
}

View File

@ -0,0 +1,88 @@
using System.Linq;
using BiblioTech.CodexChecking;
using BiblioTech.Options;
using Discord;
namespace BiblioTech.Commands
{
public class CheckResponseHandler : ICheckResponseHandler
{
private CommandContext context;
private readonly IUser user;
public CheckResponseHandler(CommandContext context, IUser user)
{
this.context = context;
this.user = user;
}
public async Task CheckNotStarted()
{
await context.Followup("Run this command without any arguments first, to begin the check process.");
}
public async Task CouldNotDownloadCid()
{
await context.Followup("Could not download the CID.");
}
public async Task GiveCidToUser(string cid)
{
await context.Followup(
FormatCatchyMessage("[💾] Please download this CID using your Codex node.",
$"👉 `{cid}`.",
"👉 Then provide the *content of the downloaded file* as argument to this command."));
}
public async Task GiveDataFileToUser(string fileContent)
{
await context.SendFile(fileContent,
FormatCatchyMessage("[💿] Please download the attached file.",
"👉 Upload it to your Codex node.",
"👉 Then provide the *CID* as argument to this command."));
}
private string FormatCatchyMessage(string title, params string[] content)
{
var entries = new List<string>();
entries.Add(title);
entries.Add("```");
entries.AddRange(content);
entries.Add("```");
return string.Join(Environment.NewLine, entries.ToArray());
}
public async Task GiveRoleReward()
{
try
{
await Program.RoleDriver.RunRoleGiver(async r =>
{
await r.GiveAltruisticRole(user.Id);
await r.GiveActiveP2pParticipant(user.Id);
});
await context.Followup($"Congratulations! You've been granted the Altruistic Mode role!");
}
catch (Exception ex)
{
await Program.AdminChecker.SendInAdminChannel($"Failed to grant Altruistic Mode role to user <@{user.Id}>: {ex.Message}");
}
}
public async Task InvalidData()
{
await context.Followup("The received data didn't match. Check has failed.");
}
public async Task NowCompleted(ulong userId, string checkName)
{
await context.Followup("Successfully completed the check!");
await Program.AdminChecker.SendInAdminChannel($"User <@{userId}> has completed check: {checkName}");
}
public async Task ToAdminChannel(string msg)
{
await Program.AdminChecker.SendInAdminChannel(msg);
}
}
}

View File

@ -0,0 +1,53 @@
using BiblioTech.CodexChecking;
using BiblioTech.Options;
namespace BiblioTech.Commands
{
public class CheckUploadCommand : BaseCommand
{
private readonly CodexTwoWayChecker checker;
private readonly StringOption cidOption = new StringOption(
name: "cid",
description: "Codex Content-Identifier",
isRequired: false);
public CheckUploadCommand(CodexTwoWayChecker checker)
{
this.checker = checker;
}
public override string Name => "checkupload";
public override string StartingMessage => RandomBusyMessage.Get();
public override string Description => "Checks the upload connectivity of your Codex node.";
public override CommandOption[] Options => [cidOption];
protected override async Task Invoke(CommandContext context)
{
var user = context.Command.User;
var cid = await cidOption.Parse(context);
try
{
var handler = new CheckResponseHandler(context, user);
if (string.IsNullOrEmpty(cid))
{
await checker.StartUploadCheck(handler, user.Id);
}
else
{
await checker.VerifyUploadCheck(handler, user.Id, cid);
}
}
catch (Exception ex)
{
await RespondWithError(context, ex);
}
}
private async Task RespondWithError(CommandContext context, Exception ex)
{
await Program.AdminChecker.SendInAdminChannel("Exception during CheckUploadCommand: " + ex);
await context.Followup("I'm sorry to report something has gone wrong in an unexpected way. Error details are already posted in the admin channel.");
}
}
}

View File

@ -1,48 +0,0 @@
using BiblioTech.Options;
namespace BiblioTech.Commands
{
public class SprCommand : BaseCommand
{
private readonly Random random = new Random();
private readonly List<string> knownSprs = new List<string>();
public override string Name => "boot";
public override string StartingMessage => RandomBusyMessage.Get();
public override string Description => "Gets an SPR. (Signed peer record, used for bootstrapping.)";
protected override async Task Invoke(CommandContext context)
{
await ReplyWithRandomSpr(context);
}
public void Add(string spr)
{
if (knownSprs.Contains(spr)) return;
knownSprs.Add(spr);
}
public void Clear()
{
knownSprs.Clear();
}
public string[] Get()
{
return knownSprs.ToArray();
}
private async Task ReplyWithRandomSpr(CommandContext context)
{
if (!knownSprs.Any())
{
await context.Followup("I'm sorry, no SPRs are available... :c");
return;
}
var i = random.Next(0, knownSprs.Count);
var spr = knownSprs[i];
await context.Followup($"Your SPR: `{spr}`");
}
}
}

View File

@ -69,8 +69,8 @@ namespace BiblioTech.Commands
{
await context.Followup(new string[]
{
"Done! Thank you for joining the test net!",
"By default, the bot will @-mention you with test-net related notifications.",
"Done! Thank you for joining!",
"By default, the bot will @-mention you with discord role notifications.",
$"You can enable/disable this behavior with the '/{notifyCommand.Name}' command."
});

View File

@ -26,9 +26,6 @@ namespace BiblioTech
[Uniform("chain-events-channel-id", "cc", "CHAINEVENTSCHANNELID", false, "ID of the Discord server channel where chain events will be posted.")]
public ulong ChainEventsChannelId { get; set; }
[Uniform("altruistic-role-id", "ar", "ALTRUISTICROLE", true, "ID of the Discord server role for Altruistic Mode.")]
public ulong AltruisticRoleId { get; set; }
[Uniform("reward-api-port", "rp", "REWARDAPIPORT", true, "TCP listen port for the reward API.")]
public int RewardApiPort { get; set; } = 31080;
@ -47,8 +44,40 @@ namespace BiblioTech
[Uniform("codex-endpoint-auth", "cea", "CODEXENDPOINTAUTH", false, "Codex endpoint basic auth. Colon separated username and password. (default: empty, no auth used.)")]
public string CodexEndpointAuth { get; set; } = "";
#region Role Rewards
/// <summary>
/// Awarded when both checkupload and checkdownload have been completed.
/// </summary>
[Uniform("altruistic-role-id", "ar", "ALTRUISTICROLE", true, "ID of the Discord server role for Altruistic Mode.")]
public ulong AltruisticRoleId { get; set; }
/// <summary>
/// Awarded as long as either checkupload or checkdownload were completed within the last ActiveP2pRoleDuration minutes.
/// </summary>
[Uniform("active-p2p-role-id", "apri", "ACTIVEP2PROLEID", false, "ID of discord server role for active p2p participants.")]
public ulong ActiveP2pParticipantRoleId { get; set; }
[Uniform("active-p2p-role-duration", "aprd", "ACTIVEP2PROLEDURATION", false, "Duration in minutes for the active p2p participant role from the last successful check command.")]
public int ActiveP2pRoleDurationMinutes { get; set; }
/// <summary>
/// Awarded as long as the user is hosting at least 1 slot.
/// </summary>
[Uniform("active-host-role-id", "ahri", "ACTIVEHOSTROLEID", false, "Id of discord server role for active slot hosters.")]
public ulong ActiveHostRoleId { get; set; }
/// <summary>
/// Awarded as long as the user has at least 1 active storage purchase contract.
/// </summary>
[Uniform("active-client-role-id", "acri", "ACTIVECLIENTROLEID", false, "Id of discord server role for users with at least 1 active purchase contract.")]
public ulong ActiveClientRoleId { get; set; }
#endregion
public string EndpointsPath => Path.Combine(DataPath, "endpoints");
public string UserDataPath => Path.Combine(DataPath, "users");
public string ChecksDataPath => Path.Combine(DataPath, "checks");
public string LogPath => Path.Combine(DataPath, "logs");
public bool DebugNoDiscord => NoDiscord == 1;
}

View File

@ -15,18 +15,72 @@ namespace BiblioTech
this.log = log;
}
public async Task GiveAltruisticRole(IUser user)
public async Task RunRoleGiver(Func<IRoleGiver, Task> action)
{
await Task.CompletedTask;
log.Log($"Give altruistic role to {user.Id}");
await action(new LoggingRoleGiver(log));
}
public async Task GiveRewards(GiveRewardsCommand rewards)
public async Task IterateUsersWithRoles(Func<IRoleGiver, IUser, ulong, Task> onUserWithRole, params ulong[] rolesToIterate)
{
await Task.CompletedTask;
}
log.Log(JsonConvert.SerializeObject(rewards, Formatting.None));
public async Task IterateUsersWithRoles(Func<IRoleGiver, IUser, ulong, Task> onUserWithRole, Func<IRoleGiver, Task> whenDone, params ulong[] rolesToIterate)
{
await Task.CompletedTask;
}
private class LoggingRoleGiver : IRoleGiver
{
private readonly ILog log;
public LoggingRoleGiver(ILog log)
{
this.log = log;
}
public async Task GiveActiveClient(ulong userId)
{
log.Log($"Giving ActiveClient role to " + userId);
await Task.CompletedTask;
}
public async Task GiveActiveHost(ulong userId)
{
log.Log($"Giving ActiveHost role to " + userId);
await Task.CompletedTask;
}
public async Task GiveActiveP2pParticipant(ulong userId)
{
log.Log($"Giving ActiveP2p role to " + userId);
await Task.CompletedTask;
}
public async Task RemoveActiveP2pParticipant(ulong userId)
{
log.Log($"Removing ActiveP2p role from " + userId);
await Task.CompletedTask;
}
public async Task GiveAltruisticRole(ulong userId)
{
log.Log($"Giving Altruistic role to " + userId);
await Task.CompletedTask;
}
public async Task RemoveActiveClient(ulong userId)
{
log.Log($"Removing ActiveClient role from " + userId);
await Task.CompletedTask;
}
public async Task RemoveActiveHost(ulong userId)
{
log.Log($"Removing ActiveHost role from " + userId);
await Task.CompletedTask;
}
}
}
}

View File

@ -49,6 +49,23 @@ namespace BiblioTech.Options
}
}
public async Task SendFile(string fileContent, string message)
{
if (fileContent.Length < 1) throw new Exception("File content is empty.");
var filename = Guid.NewGuid().ToString() + ".tmp";
File.WriteAllText(filename, fileContent);
await Command.FollowupWithFileAsync(filename, "Codex_UploadCheckFile.txt", text: message, ephemeral: true);
// Detached task for cleaning up the stream resources.
_ = Task.Run(() =>
{
Thread.Sleep(TimeSpan.FromMinutes(2));
File.Delete(filename);
});
}
private string FormatChunk(string[] chunk)
{
return string.Join(Environment.NewLine, chunk);

View File

@ -12,11 +12,12 @@ namespace BiblioTech.Options
public async Task<string?> Parse(CommandContext context)
{
var strData = context.Options.SingleOrDefault(o => o.Name == Name);
if (strData == null)
if (strData == null && IsRequired)
{
await context.Followup("String option not received.");
return null;
}
if (strData == null) return null;
return strData.Value as string;
}
}

View File

@ -1,9 +1,13 @@
using ArgsUniform;
using BiblioTech.CodexChecking;
using BiblioTech.Commands;
using BiblioTech.Rewards;
using Discord;
using Discord.WebSocket;
using DiscordRewards;
using Logging;
using Nethereum.Model;
using Newtonsoft.Json;
namespace BiblioTech
{
@ -12,16 +16,17 @@ namespace BiblioTech
private DiscordSocketClient client = null!;
private CustomReplacement replacement = null!;
public static CallDispatcher Dispatcher { get; private set; } = null!;
public static Configuration Config { get; private set; } = null!;
public static UserRepo UserRepo { get; } = new UserRepo();
public static AdminChecker AdminChecker { get; private set; } = null!;
public static IDiscordRoleDriver RoleDriver { get; set; } = null!;
public static ChainActivityHandler ChainActivityHandler { get; set; } = null!;
public static ChainEventsSender EventsSender { get; set; } = null!;
public static ILog Log { get; private set; } = null!;
public static Task Main(string[] args)
{
Log = new ConsoleLog();
var uniformArgs = new ArgsUniform<Configuration>(PrintHelp, args);
Config = uniformArgs.Parse();
@ -30,9 +35,12 @@ namespace BiblioTech
new ConsoleLog()
);
Dispatcher = new CallDispatcher(Log);
EnsurePath(Config.DataPath);
EnsurePath(Config.UserDataPath);
EnsurePath(Config.EndpointsPath);
EnsurePath(Config.ChecksDataPath);
return new Program().MainAsync(args);
}
@ -80,18 +88,20 @@ namespace BiblioTech
client = new DiscordSocketClient();
client.Log += ClientLog;
var checker = new CodexCidChecker(Config, Log);
var checkRepo = new CheckRepo(Config);
var codexWrapper = new CodexWrapper(Log, Config);
var checker = new CodexTwoWayChecker(Log, Config, checkRepo, codexWrapper);
var notifyCommand = new NotifyCommand();
var associateCommand = new UserAssociateCommand(notifyCommand);
var sprCommand = new SprCommand();
var handler = new CommandHandler(Log, client, replacement,
var roleRemover = new ActiveP2pRoleRemover(Config, Log, checkRepo);
var handler = new CommandHandler(Log, client, replacement, roleRemover,
new GetBalanceCommand(associateCommand),
new MintCommand(associateCommand),
sprCommand,
associateCommand,
notifyCommand,
new CheckCidCommand(checker),
new AdminCommand(sprCommand, replacement)
new CheckUploadCommand(checker),
new CheckDownloadCommand(checker),
new AdminCommand(replacement)
);
await client.LoginAsync(TokenType.Bot, Config.ApplicationToken);

View File

@ -14,7 +14,9 @@
"Analyzing the wavelengths...",
"Charging the flux-capacitor...",
"Jumping to hyperspace...",
"Computing the ultimate answer..."
"Computing the ultimate answer...",
"Turning it off and on again...",
"Compiling from sources..."
};
public static string Get()

View File

@ -0,0 +1,134 @@
using Discord;
using DiscordRewards;
using Logging;
namespace BiblioTech.Rewards
{
public class ChainActivityHandler
{
private readonly ILog log;
private readonly UserRepo repo;
private ActiveUserIds? previousIds = null;
public ChainActivityHandler(ILog log, UserRepo repo)
{
this.log = log;
this.repo = repo;
}
public async Task ProcessChainActivity(ActiveChainAddresses activeChainAddresses)
{
var activeUserIds = ConvertToUserIds(activeChainAddresses);
if (!HasChanged(activeUserIds)) return;
await GiveAndRemoveRoles(activeUserIds);
}
private async Task GiveAndRemoveRoles(ActiveUserIds activeUserIds)
{
await Program.RoleDriver.IterateUsersWithRoles(
(g, u, r) => OnUserWithRole(g, u, r, activeUserIds),
whenDone: g => GiveRolesToRemaining(g, activeUserIds),
Program.Config.ActiveClientRoleId,
Program.Config.ActiveHostRoleId);
}
private async Task OnUserWithRole(IRoleGiver giver, IUser user, ulong roleId, ActiveUserIds activeIds)
{
if (roleId == Program.Config.ActiveClientRoleId)
{
await CheckUserWithRole(user, activeIds.Clients, giver.RemoveActiveClient);
}
else if (roleId == Program.Config.ActiveHostRoleId)
{
await CheckUserWithRole(user, activeIds.Hosts, giver.RemoveActiveHost);
}
else
{
throw new Exception("Unknown roleId received!");
}
}
private async Task CheckUserWithRole(IUser user, List<ulong> activeUsers, Func<ulong, Task> removeActiveRole)
{
if (ShouldUserHaveRole(user, activeUsers))
{
activeUsers.Remove(user.Id);
}
else
{
await removeActiveRole(user.Id);
}
}
private bool ShouldUserHaveRole(IUser user, List<ulong> activeUsers)
{
return activeUsers.Any(id => id == user.Id);
}
private async Task GiveRolesToRemaining(IRoleGiver giver, ActiveUserIds ids)
{
foreach (var client in ids.Clients) await giver.GiveActiveClient(client);
foreach (var host in ids.Hosts) await giver.GiveActiveHost(host);
}
private bool HasChanged(ActiveUserIds activeUserIds)
{
if (previousIds == null)
{
previousIds = activeUserIds;
return true;
}
if (!IsEquivalent(previousIds.Hosts, activeUserIds.Hosts)) return true;
if (!IsEquivalent(previousIds.Clients, activeUserIds.Clients)) return true;
return false;
}
private static bool IsEquivalent(IEnumerable<ulong> a, IEnumerable<ulong> b)
{
return a.SequenceEqual(b);
}
private ActiveUserIds ConvertToUserIds(ActiveChainAddresses activeChainAddresses)
{
return new ActiveUserIds
(
hosts: Map(activeChainAddresses.Hosts),
clients: Map(activeChainAddresses.Clients)
);
}
private ulong[] Map(string[] ethAddresses)
{
var result = new List<ulong>();
foreach (var ethAddress in ethAddresses)
{
var userMaybe = repo.GetUserDataForAddressMaybe(new Utils.EthAddress(ethAddress));
if (userMaybe != null)
{
result.Add(userMaybe.DiscordId);
}
}
return result.Order().ToArray();
}
private void Log(string msg)
{
log.Log(msg);
}
private class ActiveUserIds
{
public ActiveUserIds(IEnumerable<ulong> hosts, IEnumerable<ulong> clients)
{
Hosts = hosts.ToList();
Clients = clients.ToList();
}
public List<ulong> Hosts { get; }
public List<ulong> Clients { get; }
}
}
}

View File

@ -28,14 +28,14 @@ namespace BiblioTech.Rewards
public void Add(string from, string to)
{
if (replacements.ContainsKey(from))
AddOrUpdate(from, to);
var lower = from.ToLowerInvariant();
if (lower != from)
{
replacements[from] = to;
}
else
{
replacements.Add(from, to);
AddOrUpdate(lower, to);
}
Save();
}
@ -55,6 +55,18 @@ namespace BiblioTech.Rewards
return result;
}
private void AddOrUpdate(string from, string to)
{
if (replacements.ContainsKey(from))
{
replacements[from] = to;
}
else
{
replacements.Add(from, to);
}
}
private void Save()
{
ReplaceJson[] replaces = replacements.Select(pair =>

View File

@ -1,102 +0,0 @@
using Discord.WebSocket;
using Discord;
using DiscordRewards;
namespace BiblioTech.Rewards
{
public class RewardContext
{
private readonly Dictionary<ulong, IGuildUser> users;
private readonly Dictionary<ulong, RoleReward> roles;
private readonly SocketTextChannel? rewardsChannel;
public RewardContext(Dictionary<ulong, IGuildUser> users, Dictionary<ulong, RoleReward> roles, SocketTextChannel? rewardsChannel)
{
this.users = users;
this.roles = roles;
this.rewardsChannel = rewardsChannel;
}
public async Task ProcessGiveRewardsCommand(UserReward[] rewards)
{
foreach (var rewardCommand in rewards)
{
if (roles.ContainsKey(rewardCommand.RewardCommand.RewardId))
{
var role = roles[rewardCommand.RewardCommand.RewardId];
await ProcessRewardCommand(role, rewardCommand);
}
else
{
Program.Log.Error($"RoleID not found on guild: {rewardCommand.RewardCommand.RewardId}");
}
}
}
private async Task ProcessRewardCommand(RoleReward role, UserReward reward)
{
foreach (var user in reward.Users)
{
await GiveReward(role, user);
}
}
private async Task GiveReward(RoleReward role, UserData user)
{
if (!users.ContainsKey(user.DiscordId))
{
Program.Log.Log($"User by id '{user.DiscordId}' not found.");
return;
}
var guildUser = users[user.DiscordId];
var alreadyHas = guildUser.RoleIds.ToArray();
var logMessage = $"Giving reward '{role.SocketRole.Id}' to user '{user.DiscordId}'({user.Name})[" +
$"alreadyHas:{string.Join(",", alreadyHas.Select(a => a.ToString()))}]: ";
if (alreadyHas.Any(r => r == role.Reward.RoleId))
{
logMessage += "Already has role";
Program.Log.Log(logMessage);
return;
}
await GiveRole(guildUser, role.SocketRole);
await SendNotification(role, user, guildUser);
await Task.Delay(1000);
logMessage += "Role given. Notification sent.";
Program.Log.Log(logMessage);
}
private async Task GiveRole(IGuildUser user, SocketRole role)
{
try
{
Program.Log.Log($"Giving role {role.Name}={role.Id} to user {user.DisplayName}");
await user.AddRoleAsync(role);
}
catch (Exception ex)
{
Program.Log.Error($"Failed to give role '{role.Name}' to user '{user.DisplayName}': {ex}");
}
}
private async Task SendNotification(RoleReward reward, UserData userData, IGuildUser user)
{
try
{
if (userData.NotificationsEnabled && rewardsChannel != null)
{
var msg = reward.Reward.Message.Replace(RewardConfig.UsernameTag, $"<@{user.Id}>");
await rewardsChannel.SendMessageAsync(msg);
}
}
catch (Exception ex)
{
Program.Log.Error($"Failed to notify user '{user.DisplayName}' about role '{reward.SocketRole.Name}': {ex}");
}
}
}
}

View File

@ -4,10 +4,26 @@ using Microsoft.AspNetCore.Mvc;
namespace BiblioTech.Rewards
{
/// <summary>
/// We like callbacks in this interface because we're trying to batch role-modifying operations,
/// So that we're not poking the server lots of times very quickly.
/// </summary>
public interface IDiscordRoleDriver
{
Task GiveRewards(GiveRewardsCommand rewards);
Task GiveAltruisticRole(IUser user);
Task RunRoleGiver(Func<IRoleGiver, Task> action);
Task IterateUsersWithRoles(Func<IRoleGiver, IUser, ulong, Task> onUserWithRole, params ulong[] rolesToIterate);
Task IterateUsersWithRoles(Func<IRoleGiver, IUser, ulong, Task> onUserWithRole, Func<IRoleGiver, Task> whenDone, params ulong[] rolesToIterate);
}
public interface IRoleGiver
{
Task GiveAltruisticRole(ulong userId);
Task GiveActiveP2pParticipant(ulong userId);
Task RemoveActiveP2pParticipant(ulong userId);
Task GiveActiveHost(ulong userId);
Task RemoveActiveHost(ulong userId);
Task GiveActiveClient(ulong userId);
Task RemoveActiveClient(ulong userId);
}
[Route("api/[controller]")]
@ -21,16 +37,19 @@ namespace BiblioTech.Rewards
}
[HttpPost]
public async Task<string> Give(GiveRewardsCommand cmd)
public async Task<string> Give(EventsAndErrors cmd)
{
try
Program.Dispatcher.Add(() =>
{
await Program.RoleDriver.GiveRewards(cmd);
}
catch (Exception ex)
Program.ChainActivityHandler.ProcessChainActivity(cmd.ActiveChainAddresses).Wait();
});
Program.Dispatcher.Add(() =>
{
Program.Log.Error("Exception: " + ex);
}
Program.EventsSender.ProcessChainEvents(cmd.EventsOverview, cmd.Errors).Wait();
});
await Task.CompletedTask;
return "OK";
}
}

View File

@ -1,6 +1,7 @@
using Discord;
using Discord.WebSocket;
using DiscordRewards;
using k8s.KubeConfigModels;
using Logging;
using Newtonsoft.Json;
using Utils;
@ -10,145 +11,52 @@ namespace BiblioTech.Rewards
public class RoleDriver : IDiscordRoleDriver
{
private readonly DiscordSocketClient client;
private readonly UserRepo userRepo;
private readonly ILog log;
private readonly SocketTextChannel? rewardsChannel;
private readonly ChainEventsSender eventsSender;
private readonly RewardRepo repo = new RewardRepo();
public RoleDriver(DiscordSocketClient client, ILog log, CustomReplacement replacement)
public RoleDriver(DiscordSocketClient client, UserRepo userRepo, ILog log, SocketTextChannel? rewardsChannel)
{
this.client = client;
this.userRepo = userRepo;
this.log = log;
rewardsChannel = GetChannel(Program.Config.RewardsChannelId);
eventsSender = new ChainEventsSender(log, replacement, GetChannel(Program.Config.ChainEventsChannelId));
this.rewardsChannel = rewardsChannel;
}
public async Task GiveRewards(GiveRewardsCommand rewards)
public async Task RunRoleGiver(Func<IRoleGiver, Task> action)
{
log.Log($"Processing rewards command: '{JsonConvert.SerializeObject(rewards)}'");
var context = OpenRoleModifyContext();
var mapper = new RoleMapper(context);
await action(mapper);
}
if (rewards.Rewards.Any())
public async Task IterateUsersWithRoles(Func<IRoleGiver, IUser, ulong, Task> onUserWithRole, params ulong[] rolesToIterate)
{
await IterateUsersWithRoles(onUserWithRole, g => Task.CompletedTask, rolesToIterate);
}
public async Task IterateUsersWithRoles(Func<IRoleGiver, IUser, ulong, Task> onUserWithRole, Func<IRoleGiver, Task> whenDone, params ulong[] rolesToIterate)
{
var context = OpenRoleModifyContext();
var mapper = new RoleMapper(context);
foreach (var user in context.Users)
{
await ProcessRewards(rewards);
}
await eventsSender.ProcessChainEvents(rewards.EventsOverview, rewards.Errors);
}
public async Task GiveAltruisticRole(IUser user)
{
var guild = GetGuild();
var role = guild.Roles.SingleOrDefault(r => r.Id == Program.Config.AltruisticRoleId);
if (role == null) return;
var guildUser = guild.Users.SingleOrDefault(u => u.Id == user.Id);
if (guildUser == null) return;
await guildUser.AddRoleAsync(role);
}
private async Task ProcessRewards(GiveRewardsCommand rewards)
{
try
{
var guild = GetGuild();
// We load all role and user information first,
// so we don't ask the server for the same info multiple times.
var context = new RewardContext(
await LoadAllUsers(guild),
LookUpAllRoles(guild, rewards),
rewardsChannel);
await context.ProcessGiveRewardsCommand(LookUpUsers(rewards));
}
catch (Exception ex)
{
log.Error("Failed to process rewards: " + ex);
}
}
private SocketTextChannel? GetChannel(ulong id)
{
if (id == 0) return null;
return GetGuild().TextChannels.SingleOrDefault(c => c.Id == id);
}
private async Task<Dictionary<ulong, IGuildUser>> LoadAllUsers(SocketGuild guild)
{
log.Log("Loading all users..");
var result = new Dictionary<ulong, IGuildUser>();
var users = guild.GetUsersAsync();
await foreach (var ulist in users)
{
foreach (var u in ulist)
foreach (var role in rolesToIterate)
{
result.Add(u.Id, u);
//var roleIds = string.Join(",", u.RoleIds.Select(r => r.ToString()).ToArray());
//log.Log($" > {u.Id}({u.DisplayName}) has [{roleIds}]");
}
}
return result;
}
private Dictionary<ulong, RoleReward> LookUpAllRoles(SocketGuild guild, GiveRewardsCommand rewards)
{
var result = new Dictionary<ulong, RoleReward>();
foreach (var r in rewards.Rewards)
{
if (!result.ContainsKey(r.RewardId))
{
var rewardConfig = repo.Rewards.SingleOrDefault(rr => rr.RoleId == r.RewardId);
if (rewardConfig == null)
if (user.RoleIds.Contains(role))
{
log.Log($"No Reward is configured for id '{r.RewardId}'.");
}
else
{
var socketRole = guild.GetRole(r.RewardId);
if (socketRole == null)
{
log.Log($"Guild Role by id '{r.RewardId}' not found.");
}
else
{
result.Add(r.RewardId, new RoleReward(socketRole, rewardConfig));
}
await onUserWithRole(mapper, user, role);
}
}
}
return result;
await whenDone(mapper);
}
private UserReward[] LookUpUsers(GiveRewardsCommand rewards)
private RoleModifyContext OpenRoleModifyContext()
{
return rewards.Rewards.Select(LookUpUserData).ToArray();
}
private UserReward LookUpUserData(RewardUsersCommand command)
{
return new UserReward(command,
command.UserAddresses
.Select(LookUpUserDataForAddress)
.Where(d => d != null)
.Cast<UserData>()
.ToArray());
}
private UserData? LookUpUserDataForAddress(string address)
{
try
{
var userData = Program.UserRepo.GetUserDataForAddress(new EthAddress(address));
if (userData != null) log.Log($"User '{userData.Name}' was looked up.");
else log.Log($"Lookup for user was unsuccessful. EthAddress: '{address}'");
return userData;
}
catch (Exception ex)
{
log.Error("Error during UserData lookup: " + ex);
return null;
}
var context = new RoleModifyContext(GetGuild(), userRepo, log, rewardsChannel);
context.Initialize();
return context;
}
private SocketGuild GetGuild()
@ -163,27 +71,48 @@ namespace BiblioTech.Rewards
}
}
public class RoleReward
public class RoleMapper : IRoleGiver
{
public RoleReward(SocketRole socketRole, RewardConfig reward)
private readonly RoleModifyContext context;
public RoleMapper(RoleModifyContext context)
{
SocketRole = socketRole;
Reward = reward;
this.context = context;
}
public SocketRole SocketRole { get; }
public RewardConfig Reward { get; }
}
public class UserReward
{
public UserReward(RewardUsersCommand rewardCommand, UserData[] users)
public async Task GiveActiveClient(ulong userId)
{
RewardCommand = rewardCommand;
Users = users;
await context.GiveRole(userId, Program.Config.ActiveClientRoleId);
}
public RewardUsersCommand RewardCommand { get; }
public UserData[] Users { get; }
public async Task GiveActiveHost(ulong userId)
{
await context.GiveRole(userId, Program.Config.ActiveHostRoleId);
}
public async Task GiveActiveP2pParticipant(ulong userId)
{
await context.GiveRole(userId, Program.Config.ActiveP2pParticipantRoleId);
}
public async Task RemoveActiveP2pParticipant(ulong userId)
{
await context.RemoveRole(userId, Program.Config.ActiveP2pParticipantRoleId);
}
public async Task GiveAltruisticRole(ulong userId)
{
await context.GiveRole(userId, Program.Config.AltruisticRoleId);
}
public async Task RemoveActiveClient(ulong userId)
{
await context.RemoveRole(userId, Program.Config.ActiveClientRoleId);
}
public async Task RemoveActiveHost(ulong userId)
{
await context.RemoveRole(userId, Program.Config.ActiveHostRoleId);
}
}
}

View File

@ -0,0 +1,135 @@
using Discord.WebSocket;
using Discord;
using DiscordRewards;
using Nethereum.Model;
using Logging;
namespace BiblioTech.Rewards
{
public class RoleModifyContext
{
private Dictionary<ulong, IGuildUser> users = new();
private Dictionary<ulong, SocketRole> roles = new();
private DateTime lastLoad = DateTime.MinValue;
private readonly object _lock = new object();
private readonly SocketGuild guild;
private readonly UserRepo userRepo;
private readonly ILog log;
private readonly SocketTextChannel? rewardsChannel;
public RoleModifyContext(SocketGuild guild, UserRepo userRepo, ILog log, SocketTextChannel? rewardsChannel)
{
this.guild = guild;
this.userRepo = userRepo;
this.log = log;
this.rewardsChannel = rewardsChannel;
}
public void Initialize()
{
lock (_lock)
{
var span = DateTime.UtcNow - lastLoad;
if (span > TimeSpan.FromMinutes(10))
{
lastLoad = DateTime.UtcNow;
log.Log("Loading all users and roles...");
var task = LoadAllUsers(guild);
task.Wait();
this.users = task.Result;
this.roles = LoadAllRoles(guild);
}
}
}
public IGuildUser[] Users => users.Values.ToArray();
public async Task GiveRole(ulong userId, ulong roleId)
{
Log($"Giving role {roleId} to user {userId}");
var role = GetRole(roleId);
var guildUser = GetUser(userId);
if (role == null) return;
if (guildUser == null) return;
await guildUser.AddRoleAsync(role);
await Program.AdminChecker.SendInAdminChannel($"Added role '{role.Name}' for user <@{userId}>.");
await SendNotification(guildUser, role);
}
public async Task RemoveRole(ulong userId, ulong roleId)
{
Log($"Removing role {roleId} from user {userId}");
var role = GetRole(roleId);
var guildUser = GetUser(userId);
if (role == null) return;
if (guildUser == null) return;
await guildUser.RemoveRoleAsync(role);
await Program.AdminChecker.SendInAdminChannel($"Removed role '{role.Name}' for user <@{userId}>.");
}
private SocketRole? GetRole(ulong roleId)
{
if (roles.ContainsKey(roleId)) return roles[roleId];
return null;
}
private IGuildUser? GetUser(ulong userId)
{
if (users.ContainsKey(userId)) return users[userId];
return null;
}
private void Log(string msg)
{
log.Log(msg);
}
private async Task<Dictionary<ulong, IGuildUser>> LoadAllUsers(SocketGuild guild)
{
var result = new Dictionary<ulong, IGuildUser>();
var users = guild.GetUsersAsync();
await foreach (var ulist in users)
{
foreach (var u in ulist)
{
result.Add(u.Id, u);
}
}
return result;
}
private Dictionary<ulong, SocketRole> LoadAllRoles(SocketGuild guild)
{
var result = new Dictionary<ulong, SocketRole>();
var roles = guild.Roles.ToArray();
foreach (var role in roles)
{
result.Add(role.Id, role);
}
return result;
}
private async Task SendNotification(IGuildUser user, SocketRole role)
{
try
{
var userData = userRepo.GetUser(user);
if (userData == null) return;
if (userData.NotificationsEnabled && rewardsChannel != null)
{
var msg = $"<@{user.Id}> has received '{role.Name}'.";
await rewardsChannel.SendMessageAsync(msg);
}
}
catch (Exception ex)
{
log.Error($"Failed to notify user '{user.DisplayName}' about role '{role.Name}': {ex}");
}
}
}
}

View File

@ -41,6 +41,12 @@ namespace BiblioTech
return cache.Values.ToArray();
}
public UserData GetUser(IUser user)
{
if (cache.Count == 0) LoadAllUserData();
return GetOrCreate(user);
}
public void AddMintEventForUser(IUser user, EthAddress usedAddress, Transaction<Ether>? eth, Transaction<TestToken>? tokens)
{
lock (repoLock)
@ -68,10 +74,10 @@ namespace BiblioTech
lock (repoLock)
{
var userData = GetUserData(user);
var userData = GetUserDataMaybe(user);
if (userData == null)
{
result.Add("User has not joined the test net.");
result.Add("User has not interacted with bot.");
}
else
{
@ -100,36 +106,33 @@ namespace BiblioTech
public string[] GetUserReport(IUser user)
{
var userData = GetUserData(user);
var userData = GetUserDataMaybe(user);
if (userData == null) return new[] { "User has not joined the test net." };
return userData.CreateOverview();
}
public string[] GetUserReport(EthAddress ethAddress)
{
var userData = GetUserDataForAddress(ethAddress);
var userData = GetUserDataForAddressMaybe(ethAddress);
if (userData == null) return new[] { "No user is using this eth address." };
return userData.CreateOverview();
}
public UserData? GetUserDataForAddress(EthAddress? address)
public UserData? GetUserDataForAddressMaybe(EthAddress? address)
{
if (address == null) return null;
// If this becomes a performance problem, switch to in-memory cached list.
var files = Directory.GetFiles(Program.Config.UserDataPath);
foreach (var file in files)
var lower = address.Address.ToLowerInvariant();
if (string.IsNullOrEmpty(lower)) return null;
if (cache.Count == 0) LoadAllUserData();
foreach (var item in cache.Values)
{
try
if (item.CurrentAddress != null &&
item.CurrentAddress.Address.ToLowerInvariant() == lower)
{
var user = JsonConvert.DeserializeObject<UserData>(File.ReadAllText(file))!;
if (user.CurrentAddress != null &&
user.CurrentAddress.Address == address.Address)
{
return user;
}
return item;
}
catch { }
}
return null;
@ -137,7 +140,7 @@ namespace BiblioTech
private SetAddressResponse SetUserAddress(IUser user, EthAddress? address)
{
if (GetUserDataForAddress(address) != null)
if (GetUserDataForAddressMaybe(address) != null)
{
return SetAddressResponse.AddressAlreadyInUse;
}
@ -152,13 +155,12 @@ namespace BiblioTech
private void SetUserNotification(IUser user, bool notifyEnabled)
{
var userData = GetUserData(user);
if (userData == null) return;
var userData = GetOrCreate(user);
userData.NotificationsEnabled = notifyEnabled;
SaveUserData(userData);
}
private UserData? GetUserData(IUser user)
private UserData? GetUserDataMaybe(IUser user)
{
if (cache.ContainsKey(user.Id))
{
@ -177,7 +179,7 @@ namespace BiblioTech
private UserData GetOrCreate(IUser user)
{
var userData = GetUserData(user);
var userData = GetUserDataMaybe(user);
if (userData == null)
{
return CreateAndSaveNewUserData(user);

View File

@ -21,7 +21,7 @@ namespace TestNetRewarder
return result == "Pong";
}
public async Task<bool> SendRewards(GiveRewardsCommand command)
public async Task<bool> SendRewards(EventsAndErrors command)
{
if (command == null) return false;
var result = await HttpPostJson(command);

View File

@ -8,7 +8,6 @@ namespace TestNetRewarder
public class Processor : ITimeSegmentHandler
{
private readonly RequestBuilder builder;
private readonly RewardChecker rewardChecker;
private readonly EventsFormatter eventsFormatter;
private readonly ChainState chainState;
private readonly Configuration config;
@ -24,22 +23,16 @@ namespace TestNetRewarder
lastPeriodUpdateUtc = DateTime.UtcNow;
builder = new RequestBuilder();
rewardChecker = new RewardChecker(builder);
eventsFormatter = new EventsFormatter(config);
var handler = new ChainStateChangeHandlerMux(
rewardChecker.Handler,
eventsFormatter
);
chainState = new ChainState(log, contracts, handler, config.HistoryStartUtc,
chainState = new ChainState(log, contracts, eventsFormatter, config.HistoryStartUtc,
doProofPeriodMonitoring: config.ShowProofPeriodReports > 0);
}
public async Task Initialize()
{
var events = eventsFormatter.GetInitializationEvents(config);
var request = builder.Build(events, Array.Empty<string>());
var request = builder.Build(chainState, events, Array.Empty<string>());
if (request.HasAny())
{
await client.SendRewards(request);
@ -54,9 +47,8 @@ namespace TestNetRewarder
var numberOfChainEvents = await ProcessEvents(timeRange);
var duration = sw.Elapsed;
if (numberOfChainEvents == 0) return TimeSegmentResponse.Underload;
if (numberOfChainEvents > 10) return TimeSegmentResponse.Overload;
if (duration > TimeSpan.FromSeconds(1)) return TimeSegmentResponse.Overload;
if (duration > TimeSpan.FromSeconds(1)) return TimeSegmentResponse.Underload;
if (duration > TimeSpan.FromSeconds(3)) return TimeSegmentResponse.Overload;
return TimeSegmentResponse.OK;
}
catch (Exception ex)
@ -76,7 +68,7 @@ namespace TestNetRewarder
var events = eventsFormatter.GetEvents();
var errors = eventsFormatter.GetErrors();
var request = builder.Build(events, errors);
var request = builder.Build(chainState, events, errors);
if (request.HasAny())
{
await client.SendRewards(request);

View File

@ -45,6 +45,7 @@ namespace TestNetRewarder
Log.Log("Starting TestNet Rewarder...");
var segmenter = new TimeSegmenter(Log, Config.Interval, Config.HistoryStartUtc, processor);
await EnsureBotOnline();
await processor.Initialize();
while (!CancellationToken.IsCancellationRequested)

View File

@ -1,40 +1,55 @@
using DiscordRewards;
using CodexContractsPlugin.ChainMonitor;
using DiscordRewards;
using Utils;
namespace TestNetRewarder
{
public class RequestBuilder : IRewardGiver
public class RequestBuilder
{
private readonly Dictionary<ulong, List<EthAddress>> rewards = new Dictionary<ulong, List<EthAddress>>();
public void Give(RewardConfig reward, EthAddress receiver)
public EventsAndErrors Build(ChainState chainState, ChainEventMessage[] lines, string[] errors)
{
if (rewards.ContainsKey(reward.RoleId))
var activeChainAddresses = CollectActiveAddresses(chainState);
return new EventsAndErrors
{
rewards[reward.RoleId].Add(receiver);
EventsOverview = lines,
Errors = errors,
ActiveChainAddresses = activeChainAddresses
};
}
private ActiveChainAddresses CollectActiveAddresses(ChainState chainState)
{
var hosts = new List<string>();
var clients = new List<string>();
foreach (var request in chainState.Requests)
{
CollectAddresses(request, hosts, clients);
}
else
return new ActiveChainAddresses
{
rewards.Add(reward.RoleId, new List<EthAddress> { receiver });
Hosts = hosts.ToArray(),
Clients = clients.ToArray()
};
}
private void CollectAddresses(IChainStateRequest request, List<string> hosts, List<string> clients)
{
if (request.State != CodexContractsPlugin.RequestState.Started) return;
AddIfNew(clients, request.Client);
foreach (var host in request.Hosts.GetHosts())
{
AddIfNew(hosts, host);
}
}
public GiveRewardsCommand Build(ChainEventMessage[] lines, string[] errors)
private void AddIfNew(List<string> list, EthAddress address)
{
var result = new GiveRewardsCommand
{
Rewards = rewards.Select(p => new RewardUsersCommand
{
RewardId = p.Key,
UserAddresses = p.Value.Select(v => v.Address).ToArray()
}).ToArray(),
EventsOverview = lines,
Errors = errors
};
rewards.Clear();
return result;
var addr = address.Address;
if (!list.Contains(addr)) list.Add(addr);
}
}
}

View File

@ -1,113 +0,0 @@
using BlockchainUtils;
using CodexContractsPlugin.ChainMonitor;
using DiscordRewards;
using System.Numerics;
using Utils;
namespace TestNetRewarder
{
public interface IRewardGiver
{
void Give(RewardConfig reward, EthAddress receiver);
}
public class RewardCheck : IChainStateChangeHandler
{
private readonly RewardConfig reward;
private readonly IRewardGiver giver;
public RewardCheck(RewardConfig reward, IRewardGiver giver)
{
this.reward = reward;
this.giver = giver;
}
public void OnNewRequest(RequestEvent requestEvent)
{
if (MeetsRequirements(CheckType.ClientPostedContract, requestEvent))
{
GiveReward(reward, requestEvent.Request.Client);
}
}
public void OnRequestCancelled(RequestEvent requestEvent)
{
}
public void OnRequestFailed(RequestEvent requestEvent)
{
}
public void OnRequestFinished(RequestEvent requestEvent)
{
if (MeetsRequirements(CheckType.HostFinishedSlot, requestEvent))
{
foreach (var host in requestEvent.Request.Hosts.GetHosts())
{
GiveReward(reward, host);
}
}
}
public void OnRequestFulfilled(RequestEvent requestEvent)
{
if (MeetsRequirements(CheckType.ClientStartedContract, requestEvent))
{
GiveReward(reward, requestEvent.Request.Client);
}
}
public void OnSlotFilled(RequestEvent requestEvent, EthAddress host, BigInteger slotIndex)
{
if (MeetsRequirements(CheckType.HostFilledSlot, requestEvent))
{
if (host != null)
{
GiveReward(reward, host);
}
}
}
public void OnSlotFreed(RequestEvent requestEvent, BigInteger slotIndex)
{
}
public void OnSlotReservationsFull(RequestEvent requestEvent, BigInteger slotIndex)
{
}
public void OnError(string msg)
{
}
public void OnProofSubmitted(BlockTimeEntry block, string id)
{
}
private void GiveReward(RewardConfig reward, EthAddress receiver)
{
giver.Give(reward, receiver);
}
private bool MeetsRequirements(CheckType type, RequestEvent requestEvent)
{
return
reward.CheckConfig.Type == type &&
MeetsDurationRequirement(requestEvent.Request) &&
MeetsSizeRequirement(requestEvent.Request);
}
private bool MeetsSizeRequirement(IChainStateRequest r)
{
var slotSize = r.Request.Ask.SlotSize;
ulong min = Convert.ToUInt64(reward.CheckConfig.MinSlotSize.SizeInBytes);
return slotSize >= min;
}
private bool MeetsDurationRequirement(IChainStateRequest r)
{
var duration = TimeSpan.FromSeconds((double)r.Request.Ask.Duration);
return duration >= reward.CheckConfig.MinDuration;
}
}
}

View File

@ -1,17 +0,0 @@
using CodexContractsPlugin.ChainMonitor;
using DiscordRewards;
namespace TestNetRewarder
{
public class RewardChecker
{
public RewardChecker(IRewardGiver giver)
{
var repo = new RewardRepo();
var checks = repo.Rewards.Select(r => new RewardCheck(r, giver)).ToArray();
Handler = new ChainStateChangeHandlerMux(checks);
}
public IChainStateChangeHandler Handler { get; }
}
}