Add webhook support for card transactions and notifications.
Java CI / build (push) Successful in 50s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 33s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 30s

Some optimizations and fixes

Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 2 hours 4 minutes
This commit is contained in:
Dmitrii
2026-07-12 06:27:28 +03:00
parent 0e53d7dd8c
commit f19a5d3586
37 changed files with 1155 additions and 950 deletions
@@ -71,13 +71,35 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
var dto = await resp.Content.ReadFromJsonAsync<MojangDto>(); var dto = await resp.Content.ReadFromJsonAsync<MojangDto>();
if (dto == null || dto.Name != session.UserName || Guid.Parse(dto.Id) != session.UserId) throw new Exception("Session expired, or dto is not acceptable."); if (dto == null || dto.Name != session.UserName || Guid.Parse(dto.Id) != session.UserId) throw new Exception("Session expired, or dto is not acceptable.");
var token = tokenService.GenerateAccessToken(session.UserName, body.userUUID); var token = tokenService.GenerateAccessToken(session.UserName, body.userUUID);
var user = await dbContext.Users.FirstOrDefaultAsync(k => k.Id == session.UserId); var user = await dbContext.Users.FirstOrDefaultAsync(k => k.Id == session.UserId);
var shortId = string.Empty;
if (user?.ShortId == null)
{
shortId = Program.GenerateRandomString(2);
while (true)
{
var a = await dbContext.Users.FirstOrDefaultAsync(k => k.ShortId == shortId);
if (a != null)
{
shortId = Program.GenerateRandomString(2);
}
else
{
break;
}
}
}
if (user != null) if (user != null)
{ {
user.Token = token; user.Token = token;
user.Username = session.UserName; user.Username = session.UserName;
user.UpdatedAt = DateTime.UtcNow; user.UpdatedAt = DateTime.UtcNow;
user.ShortId ??= shortId;
dbContext.Update(user); dbContext.Update(user);
} }
@@ -88,7 +110,8 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
Id = body.userUUID, Id = body.userUUID,
Username = session.UserName, Username = session.UserName,
Token = token, Token = token,
UpdatedAt = DateTime.UtcNow UpdatedAt = DateTime.UtcNow,
ShortId = shortId,
}; };
await dbContext.AddAsync(user); await dbContext.AddAsync(user);
} }
@@ -14,7 +14,7 @@ public class TelemetryController : ControllerBase
private static readonly ActivitySource Source = new("SpMega.ModTelemetry"); private static readonly ActivitySource Source = new("SpMega.ModTelemetry");
[HttpPost] [HttpPost]
[Authorize] [AllowAnonymous]
public IActionResult Post([FromBody] ModTelemetryBatchDto batch) public IActionResult Post([FromBody] ModTelemetryBatchDto batch)
{ {
if (batch?.Events == null || batch.Events.Count == 0) if (batch?.Events == null || batch.Events.Count == 0)
@@ -28,7 +28,7 @@ public class TelemetryController : ControllerBase
foreach (var e in batch.Events) foreach (var e in batch.Events)
{ {
using var activity = Source.StartActivity("mod.telemetry.event", ActivityKind.Internal); using var activity = Source.StartActivity("mod.telemetry.event", ActivityKind.Client);
if (activity is null) continue; if (activity is null) continue;
activity.SetTag("user.id", userId); activity.SetTag("user.id", userId);
@@ -38,6 +38,7 @@ public class TelemetryController : ControllerBase
activity.SetTag("event.timestamp", e.Timestamp.ToString("O")); activity.SetTag("event.timestamp", e.Timestamp.ToString("O"));
activity.SetTag("event.payload", JsonSerializer.Serialize(e.Payload)); activity.SetTag("event.payload", JsonSerializer.Serialize(e.Payload));
activity.SetTag("batch.sent_at", batch.SentAt.ToString("O")); activity.SetTag("batch.sent_at", batch.SentAt.ToString("O"));
} }
return Ok(new { received = batch.Events.Count }); return Ok(new { received = batch.Events.Count });
@@ -37,12 +37,7 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
ShortId = "00000000", ShortId = "00000000",
ReceiverName = "yawaflua", ReceiverName = "yawaflua",
ReceiverCardNumber = "00000", ReceiverCardNumber = "00000",
Sender = new User SenderMinecraftName = "yawaflua",
{
Id = default,
Username = "yawaflua",
Token = "123",
},
SenderCardNumber = "010101", SenderCardNumber = "010101",
Amount = 42, Amount = 42,
Comment = "API FIRST", Comment = "API FIRST",
@@ -76,13 +71,13 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
} }
var shortId = Program.GenerateRandomString(5); var shortId = Program.GenerateRandomString(2);
while (true) while (true)
{ {
var a = await context.Transactions.FirstOrDefaultAsync(k => k.ShortId == shortId); var a = await context.Transactions.FirstOrDefaultAsync(k => k.ShortId == shortId && EF.Property<Guid>(k, "SenderId") == user.Id);
if (a != null) if (a != null)
{ {
shortId = Program.GenerateRandomString(5); shortId = Program.GenerateRandomString(2);
} }
else else
{ {
@@ -92,24 +87,23 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
var transaction = new Transaction var transaction = new Transaction
{ {
ReceiverName = body.receiverName, ReceiverName = body.receiverName,
ShortId = shortId, ShortId = $"{user.ShortId}{shortId}",
ReceiverCardNumber = body.receiverCard, ReceiverCardNumber = body.receiverCard,
Sender = user, Sender = user,
SenderCardNumber = body.cardId, SenderCardNumber = cardToUse.SpworldsID,
SenderMinecraftName = user.Username,
Amount = body.amount, Amount = body.amount,
Comment = body.comment, Comment = body.comment,
}; };
try try
{ {
var uri = "s.ywfl.dev" + "/" + shortId; var uri = "ywfl.dev" + "/s" + shortId;
var transitionInfo = new Dictionary<string, object> var transitionInfo = new Dictionary<string, object>
{ {
{ "receiver", body.receiverCard }, { "receiver", body.receiverCard },
{ "amount", body.amount }, { "amount", body.amount },
{ "comment", (body.comment)[8..] + "..;Чек:"+ uri } { "comment", (body.comment)[10..] + "..;Чек:"+ uri }
}; };
Console.WriteLine(((body.comment)[8..] + "..;Чек:"+ uri).Length);
Console.WriteLine(((body.comment)[8..] + "..;Чек:"+ uri));
var resp = await SendRequest(endpoint: "transactions", body: transitionInfo, AuthHeader: new("Bearer", cardToUse.Token)); var resp = await SendRequest(endpoint: "transactions", body: transitionInfo, AuthHeader: new("Bearer", cardToUse.Token));
var balance = (int?)JsonNode.Parse(resp)?["balance"]; var balance = (int?)JsonNode.Parse(resp)?["balance"];
if (balance == null) if (balance == null)
@@ -131,6 +125,12 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
{ {
Id = transaction.Id, Id = transaction.Id,
ReceiverName = transaction.ReceiverName, ReceiverName = transaction.ReceiverName,
ReceiverCardNumber = transaction.ReceiverCardNumber,
SenderMinecraftName = transaction.SenderMinecraftName,
SenderCardNumber = transaction.SenderCardNumber,
Amount = transaction.Amount,
Comment = transaction.Comment,
TransactionDate = transaction.TransactionDate
}); });
} }
@@ -0,0 +1,195 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver.Linq;
using SpMega.Backend.Persistent.Database;
using SpMega.Backend.Persistent.Models.Transactions;
using SpMega.Backend.Persistent.Models.Users;
namespace SpMega.Backend.Controllers.v1;
[ApiController]
[Route("/api/v1/webhook")]
public class WebhookController( AppDbContext dbContext, ILogger<WebhookController> logger, IConfiguration config) : ControllerBase
{
private const string BASE_URL = "https://spworlds.ru/api/public/";
[HttpPut("{cardId}")]
[Authorize]
public async Task<IActionResult> RegisterWebhookForCard(Guid cardId)
{
var user = HttpContext.Items["@me"] as User;
if (user == null) return Unauthorized();
var cardToUse = user.Cards.FirstOrDefault(c => c.Id == cardId);
if (cardToUse == null)
return BadRequest("Card not found");
var shortId = Program.GenerateRandomString(6);
while (true)
{
var a = user.Cards.FirstOrDefault(k => k.ShortId == shortId);
if (a != null)
{
shortId = Program.GenerateRandomString(6);
}
else
{
break;
}
}
cardToUse.ShortId = shortId;
cardToUse.WebhookConnected = true;
await dbContext.SaveChangesAsync();
var webhookUrl = new UriBuilder();
webhookUrl.Scheme = "https";
webhookUrl.Host = config["Url"];
webhookUrl.Path = $"api/v1/webhook/{user.ShortId}/{cardToUse.ShortId}/{cardId}";
var resp = await SendRequest($"card/webhook", new AuthenticationHeaderValue("Bearer", cardToUse.Token), HttpMethod.Put, new { url = webhookUrl.ToString() });
var jsonResponse = JsonSerializer.Deserialize<JsonObject>(resp);
if (jsonResponse != null && jsonResponse.TryGetPropertyValue("id", out var respId)
&& respId?.GetValue<Guid>() == cardId) return Ok();
logger.LogError("Failed to register webhook for card {CardId}. Response: {Response}", cardId, resp);
cardToUse.WebhookConnected = false;
await dbContext.SaveChangesAsync();
return BadRequest("Failed to register webhook for card");
}
[HttpPost("{userShortId}/{cardShortId}/{cardId}")]
public async Task<IActionResult> ReceiveWebhook(string userShortId, string cardShortId, Guid cardId)
{
var user = await dbContext.Users.FirstOrDefaultAsync(k => k.ShortId == userShortId);
if (user == null)
return BadRequest("User not found");
var cardToUse = user.Cards.FirstOrDefault(c => c.ShortId == cardShortId && c.Id == cardId && c.WebhookConnected);
if (cardToUse == null)
return BadRequest("Card not found");
var bodyHash = Request.Headers["X-Body-Hash"].ToString();
var rawBody = await new StreamReader(Request.Body).ReadToEndAsync();
var keyBytes = Encoding.UTF8.GetBytes(cardToUse.Token);
var messageBytes = Encoding.UTF8.GetBytes(rawBody);
using var hmac = new HMACSHA256(keyBytes);
var hashBytes = hmac.ComputeHash(messageBytes);
byte[] receivedHash;
try
{
receivedHash = Convert.FromBase64String(bodyHash);
}
catch (FormatException)
{
return BadRequest("Invalid body hash");
}
if (!CryptographicOperations.FixedTimeEquals(hashBytes, receivedHash))
{
logger.LogError("Invalid body hash for webhook. Expected: {ExpectedHash}, Received: {ReceivedHash}", Convert.ToBase64String(hashBytes), bodyHash);
return BadRequest("Invalid body hash");
}
var body = JsonSerializer.Deserialize<Webhook>(rawBody, new JsonSerializerOptions(JsonSerializerDefaults.Web));
if (body == null || !Guid.TryParse(body.Id, out var notificationId))
return BadRequest("Invalid webhook body");
if (await dbContext.Notifications.FirstOrDefaultAsync(notification => notification.Id == notificationId) != null)
return Ok();
var notify = new Notification
{
Id = notificationId,
ReceiverId = user.Id,
ReceiverName = body.Receiver.Username,
ReceiverNumber = body.Receiver.Number,
SenderName = body.Sender.Username,
SenderNumber = body.Sender.Number,
Comment = body.Comment,
Amount = body.Amount,
Type = body.Type,
IsRead = false,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
await dbContext.Notifications.AddAsync(notify);
await dbContext.SaveChangesAsync();
return Ok();
}
[HttpGet("read")]
[Authorize]
public async Task<ActionResult<List<Notification>>> ReadAllNotifications()
{
var user = HttpContext.Items["@me"] as User;
if (user == null) return Unauthorized();
var notifications = await dbContext.Notifications
.Where(notification => notification.ReceiverId == user.Id && !notification.IsRead)
.ToListAsync();
foreach (var notification in notifications)
{
notification.IsRead = true;
notification.UpdatedAt = DateTime.UtcNow;
}
if (notifications.Count > 0) await dbContext.SaveChangesAsync();
return Ok(notifications);
}
[HttpGet("all")]
[Authorize]
public async Task<ActionResult<List<Notification>>> GetAllNotifications([FromQuery] int after, [FromQuery] int limit = 20)
{
var user = HttpContext.Items["@me"] as User;
if (user == null) return Unauthorized();
var notification = await dbContext.Notifications.Where(k => k.ReceiverId == user.Id).Skip(after).Take(limit).ToListAsync();
return Ok(notification);
}
[NonAction]
internal async Task<string> SendRequest(string endpoint, AuthenticationHeaderValue AuthHeader, HttpMethod method = null, object body = null)
{
method ??= body == null ? HttpMethod.Get : HttpMethod.Post;
HttpResponseMessage message;
var client = new System.Net.Http.HttpClient();
using (var requestMessage = new HttpRequestMessage(method, BASE_URL + endpoint))
{
requestMessage.Content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json"
);
requestMessage.Headers.Authorization = AuthHeader;
message = await client.SendAsync(requestMessage);
}
client.Dispose();
return await message.Content.ReadAsStringAsync();
}
}
public record Webhook(
string Id,
int Amount,
string Type,
WebhookUser Sender,
WebhookUser Receiver,
string Comment,
string CreatedAt
);
public record WebhookUser(string Username, string Number);
@@ -12,8 +12,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
internal DbSet<Transaction> Transactions { get; set; } internal DbSet<Transaction> Transactions { get; set; }
internal DbSet<User> Users { get; set; } internal DbSet<User> Users { get; set; }
internal DbSet<UserSession> UserSessions { get; set; } internal DbSet<UserSession> UserSessions { get; set; }
internal DbSet<ModTelemetryDocument> ModTelemetry { get; set; } internal DbSet<Notification> Notifications { get; set; }
internal DbSet<BackendRequestTelemetryDocument> BackendRequestTelemetry { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@@ -0,0 +1,21 @@
using SpMega.Backend.Persistent.Models.Users;
namespace SpMega.Backend.Persistent.Models.Transactions;
public class Notification
{
public Guid Id { get; set; }
public Guid ReceiverId { get; set; }
public string ReceiverName { get; set; }
public string ReceiverNumber { get; set; }
public string SenderName { get; set; }
public string SenderNumber { get; set; }
public string Comment { get; set; }
public int Amount { get; set; }
public string Type { get; set; }
public bool IsRead { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using SpMega.Backend.Persistent.Models.Users; using SpMega.Backend.Persistent.Models.Users;
@@ -7,13 +8,15 @@ public class Transaction
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public string ShortId { get; set; } = Program.GenerateRandomString(8); public string ShortId { get; set; } = Program.GenerateRandomString(8);
public string ReceiverName { get; set; } [MaxLength(2048)] public string ReceiverName { get; set; }
public string ReceiverCardNumber { get; set; } [MaxLength(2048)] public string ReceiverCardNumber { get; set; }
[JsonIgnore] public User Sender { get; set; } [JsonIgnore] public User Sender { get; set; }
public string SenderCardNumber { get; set; }
[MaxLength(2048)] public string SenderMinecraftName { get; set; }
[MaxLength(2048)] public string SenderCardNumber { get; set; }
public int Amount { get; set; } = 0; public int Amount { get; set; } = 0;
public string Comment { get; set; } = ""; [MaxLength(2048)] public string Comment { get; set; } = "";
public DateTime TransactionDate { get; set; } = DateTime.UtcNow; public DateTime TransactionDate { get; set; } = DateTime.UtcNow;
} }
@@ -9,6 +9,8 @@ public class Card
public string Name { get; set; } public string Name { get; set; }
public string SpworldsID { get; set; } public string SpworldsID { get; set; }
public string Token { get; set; } public string Token { get; set; }
public string ShortId { get; set; } = "";
public bool WebhookConnected { get; set; } = false;
public int Balance { get; set; } = 0; public int Balance { get; set; } = 0;
@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using SpMega.Backend.Persistent.Models.Transactions; using SpMega.Backend.Persistent.Models.Transactions;
namespace SpMega.Backend.Persistent.Models.Users; namespace SpMega.Backend.Persistent.Models.Users;
@@ -5,8 +6,10 @@ namespace SpMega.Backend.Persistent.Models.Users;
public class User public class User
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public string Username { get; set; } [MaxLength(2048)] public string Username { get; set; }
public string Token { get; set; } [MaxLength(2048)] public string Token { get; set; }
[MaxLength(2048)] public string? ShortId { get; set; }
public List<Card> Cards { get; set; } = []; public List<Card> Cards { get; set; } = [];
public List<Transaction> Transactions { get; set; } = []; public List<Transaction> Transactions { get; set; } = [];
@@ -1,12 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace SpMega.Backend.Persistent.Models.Users; namespace SpMega.Backend.Persistent.Models.Users;
public class UserSession public class UserSession
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public string Token { get; set; } [MaxLength(2048)] public string Token { get; set; }
public User? User { get; set; } = null; public User? User { get; set; } = null;
public Guid UserId { get; set; } public Guid UserId { get; set; }
public string UserName { get; set; } [MaxLength(2048)] public string UserName { get; set; }
public DateTime CreatedAt { get; init; } = DateTime.UtcNow; public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } public DateTime UpdatedAt { get; set; }
+24 -4
View File
@@ -3,6 +3,8 @@ using System.Text;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MongoDB.Driver; using MongoDB.Driver;
using OpenTelemetry.Exporter;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources; using OpenTelemetry.Resources;
using OpenTelemetry.Trace; using OpenTelemetry.Trace;
using SpMega.Backend.Authetication; using SpMega.Backend.Authetication;
@@ -68,7 +70,10 @@ public class Program
options.UseMongoDB(mongoClient, "spmega"); options.UseMongoDB(mongoClient, "spmega");
}); });
var otlpEndpoint = conf["Otlp:Endpoint"] ?? "http://localhost:4317"; var otlpEndpoint = (conf["Otlp__Endpoint"] ?? "http://curiosity:5080/api/default").TrimEnd('/');
var otlpHeaders = conf["Otlp__Headers"] ?? throw new InvalidOperationException("Otlp__Headers is not configured.");
var tracesEndpoint = new Uri($"{otlpEndpoint}/v1/traces");
var metricsEndpoint = new Uri($"{otlpEndpoint}/v1/metrics");
builder.Services.AddOpenTelemetry() builder.Services.AddOpenTelemetry()
.ConfigureResource(res => res.AddService("SpMega.Backend")) .ConfigureResource(res => res.AddService("SpMega.Backend"))
.WithTracing(tracing => .WithTracing(tracing =>
@@ -79,9 +84,24 @@ public class Program
.AddAspNetCoreInstrumentation() .AddAspNetCoreInstrumentation()
.AddOtlpExporter(opts => .AddOtlpExporter(opts =>
{ {
opts.Endpoint = new Uri(otlpEndpoint); opts.Protocol = OtlpExportProtocol.HttpProtobuf;
opts.Endpoint = tracesEndpoint;
opts.Headers = otlpHeaders;
}); });
}); })
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddMeter(
"Microsoft.AspNetCore.Hosting",
"Microsoft.AspNetCore.Server.Kestrel",
"System.Net.Http",
"System.Runtime")
.AddOtlpExporter(opts =>
{
opts.Protocol = OtlpExportProtocol.HttpProtobuf;
opts.Endpoint = metricsEndpoint;
opts.Headers = otlpHeaders;
}));
var app = builder.Build(); var app = builder.Build();
@@ -113,4 +133,4 @@ public class Program
return result.ToString(); return result.ToString();
} }
} }
@@ -1,317 +0,0 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v10.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v10.0": {
"SpMega.Backend/1.0.0": {
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.9",
"Microsoft.EntityFrameworkCore.Relational": "10.0.9",
"Microsoft.IdentityModel.Tokens": "8.19.1",
"MongoDB.EntityFrameworkCore": "10.0.2",
"System.IdentityModel.Tokens.Jwt": "8.19.1",
"spworlds-api": "1.0.3"
},
"runtime": {
"SpMega.Backend.dll": {}
}
},
"DnsClient/1.6.1": {
"runtime": {
"lib/net5.0/DnsClient.dll": {
"assemblyVersion": "1.6.1.0",
"fileVersion": "1.6.1.0"
}
}
},
"Microsoft.Bcl.Cryptography/10.0.2": {
"runtime": {
"lib/net10.0/Microsoft.Bcl.Cryptography.dll": {
"assemblyVersion": "10.0.0.2",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.EntityFrameworkCore/10.0.9": {
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.9"
},
"runtime": {
"lib/net10.0/Microsoft.EntityFrameworkCore.dll": {
"assemblyVersion": "10.0.9.0",
"fileVersion": "10.0.926.27113"
}
}
},
"Microsoft.EntityFrameworkCore.Abstractions/10.0.9": {
"runtime": {
"lib/net10.0/Microsoft.EntityFrameworkCore.Abstractions.dll": {
"assemblyVersion": "10.0.9.0",
"fileVersion": "10.0.926.27113"
}
}
},
"Microsoft.EntityFrameworkCore.Relational/10.0.9": {
"dependencies": {
"Microsoft.EntityFrameworkCore": "10.0.9"
},
"runtime": {
"lib/net10.0/Microsoft.EntityFrameworkCore.Relational.dll": {
"assemblyVersion": "10.0.9.0",
"fileVersion": "10.0.926.27113"
}
}
},
"Microsoft.IdentityModel.Abstractions/8.19.1": {
"runtime": {
"lib/net10.0/Microsoft.IdentityModel.Abstractions.dll": {
"assemblyVersion": "8.19.1.0",
"fileVersion": "8.19.1.26153"
}
}
},
"Microsoft.IdentityModel.JsonWebTokens/8.19.1": {
"dependencies": {
"Microsoft.IdentityModel.Tokens": "8.19.1"
},
"runtime": {
"lib/net10.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
"assemblyVersion": "8.19.1.0",
"fileVersion": "8.19.1.26153"
}
}
},
"Microsoft.IdentityModel.Logging/8.19.1": {
"dependencies": {
"Microsoft.IdentityModel.Abstractions": "8.19.1"
},
"runtime": {
"lib/net10.0/Microsoft.IdentityModel.Logging.dll": {
"assemblyVersion": "8.19.1.0",
"fileVersion": "8.19.1.26153"
}
}
},
"Microsoft.IdentityModel.Tokens/8.19.1": {
"dependencies": {
"Microsoft.Bcl.Cryptography": "10.0.2",
"Microsoft.IdentityModel.Logging": "8.19.1"
},
"runtime": {
"lib/net10.0/Microsoft.IdentityModel.Tokens.dll": {
"assemblyVersion": "8.19.1.0",
"fileVersion": "8.19.1.26153"
}
}
},
"MongoDB.Bson/3.9.0": {
"runtime": {
"lib/net6.0/MongoDB.Bson.dll": {
"assemblyVersion": "3.9.0.0",
"fileVersion": "3.9.0.0"
}
}
},
"MongoDB.Driver/3.9.0": {
"dependencies": {
"DnsClient": "1.6.1",
"MongoDB.Bson": "3.9.0",
"SharpCompress": "0.48.1",
"Snappier": "1.3.1",
"ZstdSharp.Port": "0.7.3"
},
"runtime": {
"lib/net6.0/MongoDB.Driver.dll": {
"assemblyVersion": "3.9.0.0",
"fileVersion": "3.9.0.0"
}
}
},
"MongoDB.EntityFrameworkCore/10.0.2": {
"dependencies": {
"Microsoft.EntityFrameworkCore": "10.0.9",
"MongoDB.Driver": "3.9.0"
},
"runtime": {
"lib/net10.0/MongoDB.EntityFrameworkCore.dll": {
"assemblyVersion": "10.0.2.0",
"fileVersion": "10.0.2.0"
}
}
},
"SharpCompress/0.48.1": {
"runtime": {
"lib/net10.0/SharpCompress.dll": {
"assemblyVersion": "0.48.1.0",
"fileVersion": "0.48.1.0"
}
}
},
"Snappier/1.3.1": {
"runtime": {
"lib/net8.0/Snappier.dll": {
"assemblyVersion": "1.3.1.0",
"fileVersion": "1.3.1.0"
}
}
},
"spworlds-api/1.0.3": {
"runtime": {
"lib/net8.0/spworlds-api.dll": {
"assemblyVersion": "1.0.3.0",
"fileVersion": "1.0.3.0"
}
}
},
"System.IdentityModel.Tokens.Jwt/8.19.1": {
"dependencies": {
"Microsoft.IdentityModel.JsonWebTokens": "8.19.1",
"Microsoft.IdentityModel.Tokens": "8.19.1"
},
"runtime": {
"lib/net10.0/System.IdentityModel.Tokens.Jwt.dll": {
"assemblyVersion": "8.19.1.0",
"fileVersion": "8.19.1.26153"
}
}
},
"ZstdSharp.Port/0.7.3": {
"runtime": {
"lib/net7.0/ZstdSharp.dll": {
"assemblyVersion": "0.7.3.0",
"fileVersion": "0.7.3.0"
}
}
}
}
},
"libraries": {
"SpMega.Backend/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"DnsClient/1.6.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==",
"path": "dnsclient/1.6.1",
"hashPath": "dnsclient.1.6.1.nupkg.sha512"
},
"Microsoft.Bcl.Cryptography/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==",
"path": "microsoft.bcl.cryptography/10.0.2",
"hashPath": "microsoft.bcl.cryptography.10.0.2.nupkg.sha512"
},
"Microsoft.EntityFrameworkCore/10.0.9": {
"type": "package",
"serviceable": true,
"sha512": "sha512-tu85SRzOT021V7EQlViCiAE7TqldVn469Y6lt5TEn/+XC4/MeNCHgMRSxqYuWqvF4zAQZUhCmtNEZuM3ss4LeA==",
"path": "microsoft.entityframeworkcore/10.0.9",
"hashPath": "microsoft.entityframeworkcore.10.0.9.nupkg.sha512"
},
"Microsoft.EntityFrameworkCore.Abstractions/10.0.9": {
"type": "package",
"serviceable": true,
"sha512": "sha512-GRMaiPkqYna/gCsyDffYDWmefGPC3hDrdMw+2rrGcQwhs6uZOsaMQXMJnoXQ35tx9SkBV2ieRRU9N/jLOO6BZw==",
"path": "microsoft.entityframeworkcore.abstractions/10.0.9",
"hashPath": "microsoft.entityframeworkcore.abstractions.10.0.9.nupkg.sha512"
},
"Microsoft.EntityFrameworkCore.Relational/10.0.9": {
"type": "package",
"serviceable": true,
"sha512": "sha512-dEoyYKgiaZHHgOFm1WMWm1sFEsEuhPWufX4L9PekKtqd/RaIcPjkCjvbrVvJtApErb5wPSJhYvnTlxhH+p9h2g==",
"path": "microsoft.entityframeworkcore.relational/10.0.9",
"hashPath": "microsoft.entityframeworkcore.relational.10.0.9.nupkg.sha512"
},
"Microsoft.IdentityModel.Abstractions/8.19.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-gFA8THIk23uNF/vMdOHnjIdXD1LyA2g12cHzMJ+Xag6WpgWLw6E/6uCXxvA0gp9d2yAvkRt3xzFzMUiO/hofnQ==",
"path": "microsoft.identitymodel.abstractions/8.19.1",
"hashPath": "microsoft.identitymodel.abstractions.8.19.1.nupkg.sha512"
},
"Microsoft.IdentityModel.JsonWebTokens/8.19.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-6eeY+y2QFyjj3XnCz/8gJdoP5smYHTS9ow1bw2nsZzDIPjPhBZlackYTIduSMipVpxnoT/B62LkrXX2jPggOXg==",
"path": "microsoft.identitymodel.jsonwebtokens/8.19.1",
"hashPath": "microsoft.identitymodel.jsonwebtokens.8.19.1.nupkg.sha512"
},
"Microsoft.IdentityModel.Logging/8.19.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-H+sMrMpdbWnwkQnpb/ESkQovtOgdefmj0ecGCcP40mDKzE5i4dUYkH6599M9mWYFNGNJnTp92l/9wLubYXWimw==",
"path": "microsoft.identitymodel.logging/8.19.1",
"hashPath": "microsoft.identitymodel.logging.8.19.1.nupkg.sha512"
},
"Microsoft.IdentityModel.Tokens/8.19.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-KDiuSLXud2AFVNAOottd8ztVysfPeHyr4r8gofU3/VKUXlI7oytzGTnPsNJ/B3nui17rgz8wAdWNJOtzPjkUxw==",
"path": "microsoft.identitymodel.tokens/8.19.1",
"hashPath": "microsoft.identitymodel.tokens.8.19.1.nupkg.sha512"
},
"MongoDB.Bson/3.9.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-J6vB61zKwMSfxbkN+/amGvj1qVMDKrKjV3kmoOWttMcv8JJScLDCFh89FyL3f2lDUyJrnfgZCR6/KX+07e99eg==",
"path": "mongodb.bson/3.9.0",
"hashPath": "mongodb.bson.3.9.0.nupkg.sha512"
},
"MongoDB.Driver/3.9.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-XKUa+y5RtNH1iInfxj3Y7c1FN1BQ16/7hFxoqU6fzc3+BKM1D3mGa+pB/yBbAk8jNxf7+JWEnCfuQOyCo7dQLg==",
"path": "mongodb.driver/3.9.0",
"hashPath": "mongodb.driver.3.9.0.nupkg.sha512"
},
"MongoDB.EntityFrameworkCore/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-GBN9+OAoIyBEgVPlYTXW3tyIsnpGPCA5AWZPy+X7ca3zJu+2Lli9FODZWl//w0yZHqwGwsLUL6XhcPPEaBYvBQ==",
"path": "mongodb.entityframeworkcore/10.0.2",
"hashPath": "mongodb.entityframeworkcore.10.0.2.nupkg.sha512"
},
"SharpCompress/0.48.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A==",
"path": "sharpcompress/0.48.1",
"hashPath": "sharpcompress.0.48.1.nupkg.sha512"
},
"Snappier/1.3.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA==",
"path": "snappier/1.3.1",
"hashPath": "snappier.1.3.1.nupkg.sha512"
},
"spworlds-api/1.0.3": {
"type": "package",
"serviceable": true,
"sha512": "sha512-rLQsC3MiXBfPKf3v45Qcrj0yypfZktTPuT/eGUJpcAlSZix1FBSRUbQVtfXbA8BHGIqn7nhZMVetaP3dmk+n1g==",
"path": "spworlds-api/1.0.3",
"hashPath": "spworlds-api.1.0.3.nupkg.sha512"
},
"System.IdentityModel.Tokens.Jwt/8.19.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-2VHcRtT95GAcW1E3aVBLvL2rAAMxKHXKMXKXFyWzwgkdFXZPMMvP8tVOfnRydL4vTr1RirNuGC6T8VSEF2YsPQ==",
"path": "system.identitymodel.tokens.jwt/8.19.1",
"hashPath": "system.identitymodel.tokens.jwt.8.19.1.nupkg.sha512"
},
"ZstdSharp.Port/0.7.3": {
"type": "package",
"serviceable": true,
"sha512": "sha512-U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==",
"path": "zstdsharp.port/0.7.3",
"hashPath": "zstdsharp.port.0.7.3.nupkg.sha512"
}
}
}
@@ -1,20 +0,0 @@
{
"runtimeOptions": {
"tfm": "net10.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "10.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "10.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}
@@ -1,5 +0,0 @@
{
"Version": 1,
"ManifestType": "Build",
"Endpoints": []
}
+1 -1
View File
@@ -6,7 +6,7 @@ minecraft_version=1.21.11
yarn_mappings=1.21.11+build.4 yarn_mappings=1.21.11+build.4
loader_version=0.18.1 loader_version=0.18.1
# Mod Properties # Mod Properties
mod_version=0.4-pre-alpha mod_version=0.5-beta
maven_group=git.yawaflua.tech maven_group=git.yawaflua.tech
archives_base_name=SPMega archives_base_name=SPMega
# Dependencies # Dependencies
@@ -34,6 +34,7 @@ public class ModMenuIntegration implements ModMenuApi {
final boolean[] allowBackend = {current.allowBackend()}; final boolean[] allowBackend = {current.allowBackend()};
final boolean[] signQuickPayEnabled = {current.signQuickPayEnabled()}; final boolean[] signQuickPayEnabled = {current.signQuickPayEnabled()};
final GpsHudPosition[] gpsPosition = {current.gpsPosition()}; final GpsHudPosition[] gpsPosition = {current.gpsPosition()};
final GpsHudPosition[] notificationPosition = {current.notificationPosition()};
final boolean[] telemetryEnabled = {current.telemetryEnabled()}; final boolean[] telemetryEnabled = {current.telemetryEnabled()};
final int[] telemetryIntervalSeconds = {current.telemetryIntervalSeconds()}; final int[] telemetryIntervalSeconds = {current.telemetryIntervalSeconds()};
@@ -59,10 +60,12 @@ public class ModMenuIntegration implements ModMenuApi {
.setTooltip(Text.literal("Запустить процесс авторизации через Mojang и ваш бэкенд")) .setTooltip(Text.literal("Запустить процесс авторизации через Mojang и ваш бэкенд"))
.setSaveConsumer(newValue -> { .setSaveConsumer(newValue -> {
if (newValue) { if (newValue) {
BackendAuthenticator.authenticate(MinecraftClient.getInstance()); BackendAuthenticator.authenticateAsync(MinecraftClient.getInstance())
if (SPMega.getConfig() != null) { .thenAccept(authenticated -> {
apiToken[0] = SPMega.getConfig().apiToken(); if (authenticated && SPMega.getConfig() != null) {
} apiToken[0] = SPMega.getConfig().apiToken();
}
});
} else { } else {
apiToken[0] = ModConfig.DEFAULT_API_TOKEN; apiToken[0] = ModConfig.DEFAULT_API_TOKEN;
} }
@@ -84,6 +87,17 @@ public class ModMenuIntegration implements ModMenuApi {
.setSaveConsumer(newValue -> gpsPosition[0] = newValue) .setSaveConsumer(newValue -> gpsPosition[0] = newValue)
.build()); .build());
general.addEntry(entryBuilder.startEnumSelector(
Text.translatable("option.spmega.notification_position"),
GpsHudPosition.class,
current.notificationPosition()
)
.setDefaultValue(ModConfig.DEFAULT_NOTIFICATION_POSITION)
.setEnumNameProvider(position -> Text.translatable(
"option.spmega.notification_position." + position.name().toLowerCase()))
.setSaveConsumer(newValue -> notificationPosition[0] = newValue)
.build());
// Telemetry settings // Telemetry settings
ConfigCategory telemetry = builder.getOrCreateCategory(Text.translatable("category.spmega.telemetry")); ConfigCategory telemetry = builder.getOrCreateCategory(Text.translatable("category.spmega.telemetry"));
@@ -119,6 +133,7 @@ public class ModMenuIntegration implements ModMenuApi {
signQuickPayEnabled[0], signQuickPayEnabled[0],
gpsEnabledVal, gpsEnabledVal,
gpsPosition[0], gpsPosition[0],
notificationPosition[0],
telemetryEnabled[0], telemetryEnabled[0],
telemetryIntervalSeconds[0], telemetryIntervalSeconds[0],
telemetryCollectSystemInfo[0] telemetryCollectSystemInfo[0]
@@ -27,7 +27,6 @@ import net.minecraft.text.Text;
import net.minecraft.util.ActionResult; import net.minecraft.util.ActionResult;
import org.lwjgl.glfw.GLFW; import org.lwjgl.glfw.GLFW;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -107,7 +106,7 @@ public class SPMegaClient implements ClientModInitializer {
UiOpeners.openMainMenu(client); UiOpeners.openMainMenu(client);
} }
while (scanQrKeyBinding.wasPressed()) { while (scanQrKeyBinding.wasPressed()) {
QRCodeScanner.ScanQrCode(client); QRCodeScanner.scanQrCode(client);
} }
while (toggleGpsKeyBinding.wasPressed()) { while (toggleGpsKeyBinding.wasPressed()) {
GpsHudRenderer.instance().toggle(); GpsHudRenderer.instance().toggle();
@@ -120,6 +119,7 @@ public class SPMegaClient implements ClientModInitializer {
current.signQuickPayEnabled(), current.signQuickPayEnabled(),
GpsHudRenderer.instance().isEnabled(), GpsHudRenderer.instance().isEnabled(),
current.gpsPosition(), current.gpsPosition(),
current.notificationPosition(),
current.telemetryEnabled(), current.telemetryEnabled(),
current.telemetryIntervalSeconds(), current.telemetryIntervalSeconds(),
current.telemetryCollectSystemInfo() current.telemetryCollectSystemInfo()
@@ -232,7 +232,8 @@ public class SPMegaClient implements ClientModInitializer {
}); });
new ChatListener().register(); new ChatListener().register();
CompletableFuture.runAsync(() -> BackendAuthenticator.authenticate(MinecraftClient.getInstance())) WebhookNotificationPoller.instance().start();
BackendAuthenticator.authenticateAsync(MinecraftClient.getInstance())
.exceptionally(throwable -> { .exceptionally(throwable -> {
System.err.println("[SPMEGA] Error during async authenticate: " + throwable.getMessage()); System.err.println("[SPMEGA] Error during async authenticate: " + throwable.getMessage());
return null; return null;
@@ -0,0 +1,72 @@
package git.yawaflua.tech.spmega.client;
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
import git.yawaflua.tech.spmega.client.ui.service.BackendAuthenticator;
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
import net.minecraft.client.MinecraftClient;
import net.minecraft.text.Text;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public final class WebhookNotificationPoller {
private static final WebhookNotificationPoller INSTANCE = new WebhookNotificationPoller();
private static final long POLL_INTERVAL_SECONDS = 15;
private final AtomicBoolean started = new AtomicBoolean();
private final AtomicBoolean requestInFlight = new AtomicBoolean();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "SPMega-Webhook-Poller");
thread.setDaemon(true);
return thread;
});
private WebhookNotificationPoller() {
}
public static WebhookNotificationPoller instance() {
return INSTANCE;
}
private static String format(BackendAuthenticator.PaymentNotification notification) {
String sender = notification.senderName().isBlank()
? notification.senderNumber()
: notification.senderName();
String comment = notification.comment().isBlank() ? "" : "" + notification.comment();
return "Получено " + notification.amount() + " АР от " + sender + comment;
}
public void start() {
if (started.compareAndSet(false, true)) {
scheduler.scheduleAtFixedRate(this::poll, 0, POLL_INTERVAL_SECONDS, TimeUnit.SECONDS);
}
}
private void poll() {
try {
if (!BankUiService.instance().hasWebhookEnabledCards()
|| !requestInFlight.compareAndSet(false, true)) {
return;
}
} catch (Exception exception) {
System.err.println("[SPMEGA] Failed to read local webhook state: " + exception.getMessage());
return;
}
BackendAuthenticator.readNotificationsAsync().whenComplete((notifications, exception) -> {
requestInFlight.set(false);
if (exception != null) {
System.err.println("[SPMEGA] Failed to poll webhook notifications: " + exception.getMessage());
return;
}
MinecraftClient client = MinecraftClient.getInstance();
if (client == null || notifications.isEmpty()) {
return;
}
client.execute(() -> notifications.forEach(notification ->
UiNotifications.instance().showQueued(Text.literal(format(notification)))));
});
}
}
@@ -6,11 +6,12 @@ import git.yawaflua.tech.spmega.client.telemetry.InstrumentedHttpClient;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
public final class SPWorldsApiClient { public final class SPWorldsApiClient {
private final InstrumentedHttpClient httpClient; private final InstrumentedHttpClient httpClient;
@@ -48,88 +49,88 @@ public final class SPWorldsApiClient {
return json.get(key).getAsString(); return json.get(key).getAsString();
} }
public CardInfo getCardInfo(CardAuth auth) throws IOException, InterruptedException { public CompletableFuture<CardInfo> getCardInfoAsync(CardAuth auth) {
HttpRequest request = requestBuilder("/api/public/card", auth).GET().build(); HttpRequest request = requestBuilder("/api/public/card", auth).GET().build();
String body = send(request); return sendAsync(request).thenApply(body -> {
try { try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject(); JsonObject json = JsonParser.parseString(body).getAsJsonObject();
if (json.has("statusCode")) { if (json.has("statusCode")) {
switch (json.get("statusCode").getAsInt()) { switch (json.get("statusCode").getAsInt()) {
case 403: case 403:
throw new IOException("Апи вернула ошибку: " + json.get("message").getAsString()); throw new IOException("Апи вернула ошибку: " + json.get("message").getAsString());
default: default:
System.out.println("Unhandled status code in card info response: " + json.get("statusCode").getAsInt()); System.out.println("Unhandled status code in card info response: " + json.get("statusCode").getAsInt());
break; break;
}
} }
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
String webhook = json.has("webhook") && !json.get("webhook").isJsonNull()
? json.get("webhook").getAsString()
: "";
return new CardInfo(balance, webhook);
} catch (Exception exception) {
System.out.println("Failed to parse card info response: " + exception.getMessage());
System.out.println(body);
throw new CompletionException(new IOException("Failed to parse card info response", exception));
} }
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L; });
String webhook = json.has("webhook") && !json.get("webhook").isJsonNull()
? json.get("webhook").getAsString()
: "";
return new CardInfo(balance, webhook);
} catch (Exception e) {
System.out.println("Failed to parse card info response: " + e.getMessage());
System.out.println(body);
throw new IOException("Failed to parse card info response", e);
}
} }
public List<PlayerCard> getPlayerCards(String username, CardAuth auth) throws IOException, InterruptedException { public CompletableFuture<List<PlayerCard>> getPlayerCardsAsync(String username, CardAuth auth) {
HttpRequest request = requestBuilder("/api/public/accounts/" + username + "/cards", auth).GET().build(); HttpRequest request = requestBuilder("/api/public/accounts/" + username + "/cards", auth).GET().build();
String body = send(request); return sendAsync(request).thenApply(body -> {
try { try {
JsonArray json = JsonParser.parseString(body).getAsJsonArray(); JsonArray json = JsonParser.parseString(body).getAsJsonArray();
List<PlayerCard> cards = new ArrayList<>();
List<PlayerCard> cards = new ArrayList<>(); for (JsonElement element : json) {
for (JsonElement element : json) {
JsonObject card = element.getAsJsonObject();
String name = card.has("name") ? card.get("name").getAsString() : "";
String number = card.has("number") ? card.get("number").getAsString() : "";
cards.add(new PlayerCard(name, number));
}
return cards;
} catch (Exception e) {
System.out.println("Failed to parse player cards response: " + e.getMessage());
System.out.println(body);
throw new IOException("Failed to parse player cards response", e);
}
}
public AccountMe getAccountMe(CardAuth auth) throws IOException, InterruptedException {
HttpRequest request = requestBuilder("/api/public/accounts/me", auth).GET().build();
String body = send(request);
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
String id = json.has("id") ? json.get("id").getAsString() : "";
String username = json.has("username") ? json.get("username").getAsString() : "";
String minecraftUuid = json.has("minecraftUUID") ? json.get("minecraftUUID").getAsString() : "";
List<AccountCard> cards = new ArrayList<>();
if (json.has("cards") && json.get("cards").isJsonArray()) {
for (JsonElement element : json.getAsJsonArray("cards")) {
JsonObject card = element.getAsJsonObject(); JsonObject card = element.getAsJsonObject();
cards.add(new AccountCard( String name = card.has("name") ? card.get("name").getAsString() : "";
getString(card, "id"), String number = card.has("number") ? card.get("number").getAsString() : "";
getString(card, "name"), cards.add(new PlayerCard(name, number));
getString(card, "number"),
card.has("color") && !card.get("color").isJsonNull() ? card.get("color").getAsInt() : 0
));
} }
return cards;
} catch (Exception exception) {
System.out.println("Failed to parse player cards response: " + exception.getMessage());
System.out.println(body);
throw new CompletionException(new IOException("Failed to parse player cards response", exception));
} }
});
return new AccountMe(id, username, minecraftUuid, cards);
} catch (Exception e) {
System.out.println("Failed to parse account info response: " + e.getMessage());
System.out.println(body);
throw new IOException("Failed to parse account info response", e);
}
} }
public TransactionResult createTransaction(CardAuth auth, String receiver, long amount, String comment) public CompletableFuture<AccountMe> getAccountMeAsync(CardAuth auth) {
throws IOException, InterruptedException { HttpRequest request = requestBuilder("/api/public/accounts/me", auth).GET().build();
return sendAsync(request).thenApply(body -> {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
String id = json.has("id") ? json.get("id").getAsString() : "";
String username = json.has("username") ? json.get("username").getAsString() : "";
String minecraftUuid = json.has("minecraftUUID") ? json.get("minecraftUUID").getAsString() : "";
List<AccountCard> cards = new ArrayList<>();
if (json.has("cards") && json.get("cards").isJsonArray()) {
for (JsonElement element : json.getAsJsonArray("cards")) {
JsonObject card = element.getAsJsonObject();
cards.add(new AccountCard(
getString(card, "id"),
getString(card, "name"),
getString(card, "number"),
card.has("color") && !card.get("color").isJsonNull() ? card.get("color").getAsInt() : 0
));
}
}
return new AccountMe(id, username, minecraftUuid, cards);
} catch (Exception exception) {
System.out.println("Failed to parse account info response: " + exception.getMessage());
System.out.println(body);
throw new CompletionException(new IOException("Failed to parse account info response", exception));
}
});
}
public CompletableFuture<TransactionResult> createTransactionAsync(
CardAuth auth, String receiver, long amount, String comment) {
JsonObject payload = new JsonObject(); JsonObject payload = new JsonObject();
payload.addProperty("receiver", receiver); payload.addProperty("receiver", receiver);
payload.addProperty("amount", amount); payload.addProperty("amount", amount);
@@ -139,18 +140,17 @@ public final class SPWorldsApiClient {
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload))) .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload)))
.build(); .build();
String body = send(request); return sendAsync(request).thenApply(body -> {
try { try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject(); JsonObject json = JsonParser.parseString(body).getAsJsonObject();
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L; return new TransactionResult(balance);
return new TransactionResult(balance); } catch (Exception exception) {
} catch (Exception exception) { System.out.println("Failed to parse transaction response: " + exception.getMessage());
System.out.println("Failed to parse transaction response: " + exception.getMessage()); System.out.println(body);
System.out.println(body); throw new CompletionException(new IOException("Failed to parse transaction response", exception));
}
throw new IOException("Failed to parse transaction response", exception); });
}
} }
private HttpRequest.Builder requestBuilder(String path, CardAuth auth) { private HttpRequest.Builder requestBuilder(String path, CardAuth auth) {
@@ -160,22 +160,25 @@ public final class SPWorldsApiClient {
.header("Accept", "application/json"); .header("Accept", "application/json");
} }
private String send(HttpRequest request) throws IOException, InterruptedException { private CompletableFuture<String> sendAsync(HttpRequest request) {
HttpResponse<String> response = httpClient.send(request); return httpClient.sendAsync(request).thenApply(response -> {
int statusCode = response.statusCode(); int statusCode = response.statusCode();
if (statusCode < 200 || statusCode >= 300) { if (statusCode < 200 || statusCode >= 300) {
System.out.println(response.body()); System.out.println(response.body());
JsonElement parsed = JsonParser.parseString(response.body()); JsonElement parsed = JsonParser.parseString(response.body());
JsonObject object = parsed.isJsonArray() ? parsed.getAsJsonArray().get(0).getAsJsonObject() : parsed.getAsJsonObject(); JsonObject object = parsed.isJsonArray()
var message = ""; ? parsed.getAsJsonArray().get(0).getAsJsonObject()
if (object.has("error") && !object.get("error").isJsonNull()) { : parsed.getAsJsonObject();
message = "Ошибка в запросе: " + object.get("error").getAsString(); String message = "";
} else if (object.has("message") && !object.get("message").isJsonNull()) { if (object.has("error") && !object.get("error").isJsonNull()) {
message = object.get("message").getAsString(); message = "Ошибка в запросе: " + object.get("error").getAsString();
} else if (object.has("message") && !object.get("message").isJsonNull()) {
message = object.get("message").getAsString();
}
throw new CompletionException(new IOException(statusCode + ": " + message));
} }
throw new IOException(statusCode + ": " + message); return response.body();
} });
return response.body();
} }
public record CardAuth(String cardId, String cardToken) { public record CardAuth(String cardId, String cardToken) {
@@ -9,18 +9,27 @@ import git.yawaflua.tech.spmega.client.telemetry.TelemetryEvent;
import git.yawaflua.tech.spmega.client.ui.QRcodeAcceptScreen; import git.yawaflua.tech.spmega.client.ui.QRcodeAcceptScreen;
import git.yawaflua.tech.spmega.client.ui.UiNotifications; import git.yawaflua.tech.spmega.client.ui.UiNotifications;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.MinecraftClient;
import net.minecraft.client.texture.NativeImage;
import net.minecraft.client.util.ScreenshotRecorder; import net.minecraft.client.util.ScreenshotRecorder;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import java.awt.image.BufferedImage;
import java.util.Collections; import java.util.Collections;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class QRCodeScanner { public final class QRCodeScanner {
private static final ExecutorService DECODER = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "SPMega-QR-Decoder");
thread.setDaemon(true);
return thread;
});
public static void ScanQrCode(MinecraftClient client) { private QRCodeScanner() {
}
public static void scanQrCode(MinecraftClient client) {
if (client == null || client.player == null || client.getFramebuffer() == null) { if (client == null || client.player == null || client.getFramebuffer() == null) {
return; return;
} }
@@ -46,24 +55,24 @@ public class QRCodeScanner {
return; return;
} }
String result = decodeQRCode(nativeImageToBufferedImage(nativeImage)); int width = nativeImage.getWidth();
long scanNs = System.nanoTime(); int height = nativeImage.getHeight();
boolean didLag = (scanNs - startNs) > 50_000_000L; int[] pixels = nativeImage.copyPixelsArgb();
client.execute(() -> { CompletableFuture.supplyAsync(() -> decodeQrCode(width, height, pixels), DECODER)
if (client.player == null) { .whenComplete((result, throwable) -> {
return; client.execute(() -> showResult(client, notifications, result, throwable));
} recordQrEvent(
if (result == null) { throwable == null && result != null,
notifications.show(Text.translatable("message.spmega.qr.not_found")); System.nanoTime() - startNs > 50_000_000L,
return; result,
} throwable == null ? null : throwable.getClass().getSimpleName() + ": " + throwable.getMessage(),
startNs
Text clickableLink = Text.literal(result) );
.styled(style -> style.withInsertion(result)); });
client.player.sendMessage(Text.translatable("message.spmega.qr.found_link", clickableLink), false); } catch (Exception exception) {
client.setScreen(new QRcodeAcceptScreen(result, client.currentScreen)); client.execute(() -> notifications.show(Text.translatable("message.spmega.qr.error")));
}); recordQrEvent(false, false, null,
recordQrEvent(result != null, didLag, result, null, startNs); exception.getClass().getSimpleName() + ": " + exception.getMessage(), startNs);
} finally { } finally {
if (nativeImage != null) { if (nativeImage != null) {
nativeImage.close(); nativeImage.close();
@@ -76,6 +85,29 @@ public class QRCodeScanner {
} }
} }
private static void showResult(
MinecraftClient client,
UiNotifications notifications,
String result,
Throwable throwable
) {
if (client.player == null) {
return;
}
if (throwable != null) {
notifications.show(Text.translatable("message.spmega.qr.error"));
return;
}
if (result == null) {
notifications.show(Text.translatable("message.spmega.qr.not_found"));
return;
}
Text clickableLink = Text.literal(result).styled(style -> style.withInsertion(result));
client.player.sendMessage(Text.translatable("message.spmega.qr.found_link", clickableLink), false);
client.setScreen(new QRcodeAcceptScreen(result, client.currentScreen));
}
private static void recordQrEvent(boolean success, boolean didLag, String decodedUrl, String error, long startNs) { private static void recordQrEvent(boolean success, boolean didLag, String decodedUrl, String error, long startNs) {
com.google.gson.JsonObject payload = new com.google.gson.JsonObject(); com.google.gson.JsonObject payload = new com.google.gson.JsonObject();
payload.addProperty("success", success); payload.addProperty("success", success);
@@ -90,31 +122,14 @@ public class QRCodeScanner {
TelemetryCollector.instance().record(TelemetryEvent.now("qr_scan", payload)); TelemetryCollector.instance().record(TelemetryEvent.now("qr_scan", payload));
} }
private static BufferedImage nativeImageToBufferedImage(NativeImage screenshot) { private static String decodeQrCode(int width, int height, int[] pixels) {
BufferedImage bufferedImage = new BufferedImage(
screenshot.getWidth(),
screenshot.getHeight(),
BufferedImage.TYPE_INT_RGB
);
for (int y = 0; y < screenshot.getHeight(); y++) {
for (int x = 0; x < screenshot.getWidth(); x++) {
int color = screenshot.getColorArgb(x, y);
bufferedImage.setRGB(x, y, color);
}
}
return bufferedImage;
}
private static String decodeQRCode(BufferedImage image) {
Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class); Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class);
hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE); hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE);
hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE)); hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE));
hints.put(DecodeHintType.CHARACTER_SET, "UTF-8"); hints.put(DecodeHintType.CHARACTER_SET, "UTF-8");
hints.put(DecodeHintType.ALSO_INVERTED, Boolean.TRUE); hints.put(DecodeHintType.ALSO_INVERTED, Boolean.TRUE);
LuminanceSource source = new RGBLuminanceSource(image.getWidth(), image.getHeight(), image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth())); LuminanceSource source = new RGBLuminanceSource(width, height, pixels);
String result = tryDecodeWithStrategies(source, hints); String result = tryDecodeWithStrategies(source, hints);
@@ -2,7 +2,6 @@ package git.yawaflua.tech.spmega.client.telemetry;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
@@ -36,38 +35,8 @@ public record InstrumentedHttpClient(HttpClient delegate) {
TelemetryCollector.instance().record(TelemetryEvent.now("http_request", payload)); TelemetryCollector.instance().record(TelemetryEvent.now("http_request", payload));
} }
public HttpResponse<String> send(HttpRequest request) throws IOException, InterruptedException {
URI uri = request.uri();
String target = TelemetryUriSanitizer.classifyTarget(uri);
String sanitizedPath = TelemetryUriSanitizer.sanitize(uri);
String method = request.method();
int fpsBefore = PerformanceSampler.instance().currentFps();
long startNs = System.nanoTime();
try {
HttpResponse<String> response = delegate.send(request, HttpResponse.BodyHandlers.ofString());
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
int fpsAfter = PerformanceSampler.instance().currentFps();
recordEvent(target, method, sanitizedPath, response.statusCode(), durationMs,
true, null, fpsBefore, fpsAfter);
return response;
} catch (IOException | InterruptedException e) {
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
int fpsAfter = PerformanceSampler.instance().currentFps();
recordEvent(target, method, sanitizedPath, -1, durationMs,
false, e.getClass().getSimpleName(), fpsBefore, fpsAfter);
throw e;
}
}
public CompletableFuture<HttpResponse<String>> sendAsync(HttpRequest request) { public CompletableFuture<HttpResponse<String>> sendAsync(HttpRequest request) {
URI uri = request.uri(); URI uri = request.uri();
String target = TelemetryUriSanitizer.classifyTarget(uri);
String sanitizedPath = TelemetryUriSanitizer.sanitize(uri);
String method = request.method(); String method = request.method();
int fpsBefore = PerformanceSampler.instance().currentFps(); int fpsBefore = PerformanceSampler.instance().currentFps();
@@ -79,31 +48,12 @@ public record InstrumentedHttpClient(HttpClient delegate) {
int fpsAfter = PerformanceSampler.instance().currentFps(); int fpsAfter = PerformanceSampler.instance().currentFps();
if (throwable != null) { if (throwable != null) {
recordEvent(target, method, sanitizedPath, -1, durationMs, recordHttpEvent(uri, method, -1, durationMs,
false, throwable.getClass().getSimpleName(), fpsBefore, fpsAfter); false, throwable.getClass().getSimpleName(), fpsBefore, fpsAfter);
} else { } else {
recordEvent(target, method, sanitizedPath, response.statusCode(), durationMs, recordHttpEvent(uri, method, response.statusCode(), durationMs,
true, null, fpsBefore, fpsAfter); true, null, fpsBefore, fpsAfter);
} }
}); });
} }
private void recordEvent(String target, String method, String path, int statusCode,
long durationMs, boolean success, String errorType,
int fpsBefore, int fpsAfter) {
JsonObject payload = new JsonObject();
payload.addProperty("target", target);
payload.addProperty("method", method);
payload.addProperty("path", path);
payload.addProperty("statusCode", statusCode);
payload.addProperty("durationMs", durationMs);
payload.addProperty("success", success);
if (errorType != null) {
payload.addProperty("errorType", errorType);
}
payload.addProperty("fpsBefore", fpsBefore);
payload.addProperty("fpsAfter", fpsAfter);
TelemetryCollector.instance().record(TelemetryEvent.now("http_request", payload));
}
} }
@@ -8,12 +8,10 @@ import java.net.InetAddress;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.FileStore; import java.nio.file.FileStore;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
public final class SystemInfoCollector { public final class SystemInfoCollector {
@@ -38,23 +36,21 @@ public final class SystemInfoCollector {
} }
private void fetchIpAsync() { private void fetchIpAsync() {
CompletableFuture.runAsync(() -> { HttpClient.newBuilder()
try { .connectTimeout(Duration.ofSeconds(5))
HttpClient client = HttpClient.newBuilder() .build()
.connectTimeout(Duration.ofSeconds(5)) .sendAsync(HttpRequest.newBuilder()
.build(); .uri(URI.create("https://api.ipify.org?format=text"))
HttpRequest req = HttpRequest.newBuilder() .timeout(Duration.ofSeconds(5))
.uri(URI.create("https://api.ipify.org?format=text")) .GET()
.timeout(Duration.ofSeconds(5)) .build(),
.GET() java.net.http.HttpResponse.BodyHandlers.ofString())
.build(); .thenAccept(response -> {
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 200) {
if (resp.statusCode() == 200) { cachedIp.set(response.body().trim());
cachedIp.set(resp.body().trim()); }
} })
} catch (Exception ignored) { .exceptionally(ignored -> null);
}
});
} }
private void collectGpuOnRenderThread() { private void collectGpuOnRenderThread() {
@@ -3,6 +3,7 @@ package git.yawaflua.tech.spmega.client.ui;
import git.yawaflua.tech.spmega.client.ui.service.BankUiService; import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
import git.yawaflua.tech.spmega.client.ui.service.CardViewModel; import git.yawaflua.tech.spmega.client.ui.service.CardViewModel;
import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.DrawContext;
import net.minecraft.client.gui.screen.ConfirmScreen;
import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.gui.screen.Screen;
import net.minecraft.client.gui.widget.ButtonWidget; import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.text.Text; import net.minecraft.text.Text;
@@ -16,6 +17,7 @@ public class CardScreen extends Screen {
private final UiNotifications notifications = UiNotifications.instance(); private final UiNotifications notifications = UiNotifications.instance();
private final List<ButtonWidget> cardButtons = new ArrayList<>(); private final List<ButtonWidget> cardButtons = new ArrayList<>();
private ButtonWidget historyButton; private ButtonWidget historyButton;
private ButtonWidget webhookButton;
public CardScreen(Screen parent) { public CardScreen(Screen parent) {
super(Text.literal("Управление картами")); super(Text.literal("Управление картами"));
@@ -93,8 +95,30 @@ public class CardScreen extends Screen {
this.client.setScreen(new TransactionHistoryScreen(this, selected.id(), selected.title())); this.client.setScreen(new TransactionHistoryScreen(this, selected.id(), selected.title()));
}).dimensions(rightX, startY + 72, columnWidth, 20).build()); }).dimensions(rightX, startY + 72, columnWidth, 20).build());
webhookButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("Включить вебхуки"), button -> {
if (this.client == null || bankUiService.getSelectedCard() == null) {
notifications.show(Text.literal("Сначала выбери или добавь карту"));
return;
}
this.client.setScreen(new ConfirmScreen(confirmed -> {
if (this.client == null) {
return;
}
this.client.setScreen(this);
if (!confirmed) {
return;
}
bankUiService.registerSelectedWebhookAsync().thenAccept(message -> this.client.execute(() -> {
notifications.showMessage(message);
this.clearAndInit();
}));
}, Text.literal("Включить обработку вебхуков?"),
Text.literal("Если другой вебхук уже подключен к карте — он может затереться."),
Text.literal("Включить"), Text.literal("Отмена")));
}).dimensions(rightX, startY + 96, columnWidth, 20).build());
this.addDrawableChild(ButtonWidget.builder(Text.literal("Назад"), button -> this.close()) this.addDrawableChild(ButtonWidget.builder(Text.literal("Назад"), button -> this.close())
.dimensions(rightX, startY + 120, columnWidth, 20) .dimensions(rightX, startY + 144, columnWidth, 20)
.build()); .build());
if (cards.isEmpty()) { if (cards.isEmpty()) {
@@ -147,5 +171,11 @@ public class CardScreen extends Screen {
if (historyButton != null) { if (historyButton != null) {
historyButton.active = !cards.isEmpty(); historyButton.active = !cards.isEmpty();
} }
if (webhookButton != null) {
CardViewModel selected = bankUiService.getSelectedCard();
boolean enabled = selected != null && selected.webhookEnabled();
webhookButton.active = selected != null && !enabled;
webhookButton.setMessage(Text.literal(enabled ? "Вебхуки включены" : "Включить вебхуки"));
}
} }
} }
@@ -20,9 +20,8 @@ import java.util.concurrent.TimeUnit;
public final class GpsHudRenderer { public final class GpsHudRenderer {
private static final GpsHudRenderer INSTANCE = new GpsHudRenderer(); private static final GpsHudRenderer INSTANCE = new GpsHudRenderer();
private final Object citiesLock = new Object();
private boolean enabled = true; private boolean enabled = true;
private List<City> cities = new ArrayList<>(); private volatile List<City> cities = List.of();
private GpsHudRenderer() { private GpsHudRenderer() {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
@@ -50,33 +49,35 @@ public final class GpsHudRenderer {
} }
private void fetchCities() { private void fetchCities() {
try { HttpRequest request = HttpRequest.newBuilder()
InstrumentedHttpClient client = new InstrumentedHttpClient(); .uri(URI.create("https://map.sp-mini.ru/api/map/territories"))
HttpRequest request = HttpRequest.newBuilder() .GET()
.uri(URI.create("https://map.sp-mini.ru/api/map/territories")) .build();
.GET()
.build(); new InstrumentedHttpClient().sendAsync(request)
var response = client.send(request); .thenAccept(response -> {
if (response.statusCode() == 200) { if (response.statusCode() != 200) {
TerritoryWrapper[] parsed = new Gson().fromJson(response.body(), TerritoryWrapper[].class); return;
if (parsed != null) { }
List<City> list = new ArrayList<>(); TerritoryWrapper[] parsed = new Gson().fromJson(response.body(), TerritoryWrapper[].class);
for (TerritoryWrapper w : parsed) { if (parsed == null) {
if (w != null && w.territory != null && w.territory.nether_portal != null && w.territory.nether_portal.length >= 2) { return;
City c = new City(); }
c.name = w.territory.name; List<City> updatedCities = new ArrayList<>();
c.netherX = w.territory.nether_portal[0]; for (TerritoryWrapper wrapper : parsed) {
c.netherZ = w.territory.nether_portal[1]; if (wrapper != null && wrapper.territory != null
list.add(c); && wrapper.territory.nether_portal != null
&& wrapper.territory.nether_portal.length >= 2) {
City city = new City();
city.name = wrapper.territory.name;
city.netherX = wrapper.territory.nether_portal[0];
city.netherZ = wrapper.territory.nether_portal[1];
updatedCities.add(city);
} }
} }
synchronized (citiesLock) { cities = List.copyOf(updatedCities);
cities = list; })
} .exceptionally(ignored -> null);
}
}
} catch (Exception ignored) {
}
} }
public void render(DrawContext context, TextRenderer textRenderer, int width, int height) { public void render(DrawContext context, TextRenderer textRenderer, int width, int height) {
@@ -102,13 +103,13 @@ public final class GpsHudRenderer {
String offsetText = "§7Смещение: §f" + (playerLane.offset == 0 ? "0" : (playerLane.offset > 0 ? "+" + playerLane.offset : playerLane.offset)); String offsetText = "§7Смещение: §f" + (playerLane.offset == 0 ? "0" : (playerLane.offset > 0 ? "+" + playerLane.offset : playerLane.offset));
City nearestCity = null; City nearestCity = null;
double minDistanceSq = Double.MAX_VALUE; double minDistanceSquared = Double.MAX_VALUE;
for (City city : cities) { for (City city : cities) {
double dx = playerX - (city.netherX / 8); double dx = playerX - (city.netherX / 8);
double dz = playerZ - (city.netherZ / 8); double dz = playerZ - (city.netherZ / 8);
double distSq = Math.sqrt(dx * dx + dz * dz); double distanceSquared = dx * dx + dz * dz;
if (distSq < minDistanceSq) { if (distanceSquared < minDistanceSquared) {
minDistanceSq = distSq; minDistanceSquared = distanceSquared;
nearestCity = city; nearestCity = city;
} }
} }
@@ -40,7 +40,7 @@ public class MainBankScreen extends Screen {
this.addDrawableChild(ButtonWidget.builder(Text.translatable("button.spmega.scan_qr"), button -> { this.addDrawableChild(ButtonWidget.builder(Text.translatable("button.spmega.scan_qr"), button -> {
this.client.setScreen(null); this.client.setScreen(null);
QRCodeScanner.ScanQrCode(this.client); QRCodeScanner.scanQrCode(this.client);
}).dimensions(centerX - 60, y + 28, 120, 20).build()); }).dimensions(centerX - 60, y + 28, 120, 20).build());
} }
@@ -107,50 +107,47 @@ public class TransactionHistoryScreen extends Screen {
UiNotifications.instance().show(Text.literal("Загрузка...")); UiNotifications.instance().show(Text.literal("Загрузка..."));
CompletableFuture.runAsync(() -> { int page = currentPage;
try { System.out.println("[SPMEGA] Transaction history loading started for card: " + cardId + ", page: " + page);
System.out.println("[SPMEGA] Transaction history loading started for card: " + cardId + ", page: " + currentPage); git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
List<LocalTransaction> list = null; CompletableFuture<List<LocalTransaction>> remote = config != null && config.allowBackend()
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig(); ? BackendAuthenticator.fetchTransactionsFromBackendAsync(cardId, page)
if (config != null && config.allowBackend()) { .whenComplete((list, exception) -> {
try { if (exception == null) {
list = BackendAuthenticator.fetchTransactionsFromBackend(cardId, currentPage); System.out.println("[SPMEGA] Fetched " + list.size() + " transactions from backend.");
System.out.println("[SPMEGA] Fetched " + (list != null ? list.size() : "null") + " transactions from backend."); } else {
} catch (Exception e) { System.err.println("[SPMEGA] Failed to fetch transactions from server, falling back to DB: "
System.err.println("[SPMEGA] Failed to fetch transactions from server, falling back to DB: " + e.getMessage()); + exception.getMessage());
e.printStackTrace(); }
})
.exceptionally(exception -> null)
: CompletableFuture.completedFuture(null);
remote.thenApply(list -> {
if (list != null) {
return list;
}
List<LocalTransaction> local = BankUiService.instance().getDatabase().loadTransferHistory(cardId);
System.out.println("[SPMEGA] Loaded " + local.size() + " transactions from database.");
return local;
})
.whenComplete((list, exception) -> {
MinecraftClient minecraftClient = MinecraftClient.getInstance();
if (minecraftClient == null) {
System.out.println("[SPMEGA] MinecraftClient.getInstance() is null in loadTransactions callback!");
return;
} }
}
if (list == null) {
list = BankUiService.instance().getDatabase().loadTransferHistory(cardId);
System.out.println("[SPMEGA] Loaded " + (list != null ? list.size() : "null") + " transactions from database.");
}
List<LocalTransaction> finalList = list != null ? list : new ArrayList<>();
MinecraftClient minecraftClient = MinecraftClient.getInstance();
if (minecraftClient != null) {
minecraftClient.execute(() -> { minecraftClient.execute(() -> {
System.out.println("[SPMEGA] Setting transactions list on main thread. Count: " + finalList.size()); if (exception != null) {
this.transactions.addAll(finalList); errorMessage = exception.getMessage();
this.scrollOffset = 0; } else {
this.loading = false; System.out.println("[SPMEGA] Setting transactions list on main thread. Count: " + list.size());
transactions.addAll(list);
scrollOffset = 0;
}
loading = false;
}); });
} else { });
System.out.println("[SPMEGA] MinecraftClient.getInstance() is null in loadTransactions callback!");
}
} catch (Exception e) {
System.err.println("[SPMEGA] Outer exception in loadTransactions: " + e.getMessage());
e.printStackTrace();
MinecraftClient minecraftClient = MinecraftClient.getInstance();
if (minecraftClient != null) {
minecraftClient.execute(() -> {
this.errorMessage = e.getMessage();
this.loading = false;
});
}
}
});
} }
@Override @Override
@@ -5,17 +5,22 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import com.google.gson.Strictness; import com.google.gson.Strictness;
import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonReader;
import git.yawaflua.tech.spmega.GpsHudPosition;
import git.yawaflua.tech.spmega.SPMega;
import net.minecraft.client.font.TextRenderer; import net.minecraft.client.font.TextRenderer;
import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.DrawContext;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import java.awt.*; import java.awt.*;
import java.util.ArrayDeque;
import java.util.Queue;
public final class UiNotifications { public final class UiNotifications {
private static final int DEFAULT_DURATION_TICKS = 70; private static final int DEFAULT_DURATION_TICKS = 70;
private static final UiNotifications INSTANCE = new UiNotifications(); private static final UiNotifications INSTANCE = new UiNotifications();
private Text currentText = Text.empty(); private Text currentText = Text.empty();
private final Queue<Text> queuedTexts = new ArrayDeque<>();
private int remainingTicks; private int remainingTicks;
private UiNotifications() { private UiNotifications() {
@@ -62,6 +67,17 @@ public final class UiNotifications {
show(Text.literal(extractMessage(message))); show(Text.literal(extractMessage(message)));
} }
public synchronized void showQueued(Text text) {
if (text == null || text.getString().isBlank()) {
return;
}
if (!isVisible()) {
show(text);
} else {
queuedTexts.add(text);
}
}
public synchronized void render(DrawContext context, TextRenderer textRenderer, int width, int height) { public synchronized void render(DrawContext context, TextRenderer textRenderer, int width, int height) {
if (!isVisible()) { if (!isVisible()) {
return; return;
@@ -72,8 +88,18 @@ public final class UiNotifications {
int textWidth = textRenderer.getWidth(message); int textWidth = textRenderer.getWidth(message);
int boxWidth = textWidth + padding * 2; int boxWidth = textWidth + padding * 2;
int boxHeight = textRenderer.fontHeight + padding * 2; int boxHeight = textRenderer.fontHeight + padding * 2;
int x = width - boxWidth - 10; GpsHudPosition position = SPMega.getConfig() == null
int y = height - boxHeight - 10; ? GpsHudPosition.BOTTOM_RIGHT
: SPMega.getConfig().notificationPosition();
int x = switch (position) {
case TOP_LEFT, BOTTOM_LEFT -> 10;
case TOP_CENTER, BOTTOM_CENTER -> (width - boxWidth) / 2;
case TOP_RIGHT, BOTTOM_RIGHT -> width - boxWidth - 10;
};
int y = switch (position) {
case TOP_LEFT, TOP_CENTER, TOP_RIGHT -> 10;
case BOTTOM_LEFT, BOTTOM_CENTER, BOTTOM_RIGHT -> height - boxHeight - 10;
};
context.fill(x, y, x + boxWidth, y + boxHeight, Color.DARK_GRAY.getRGB()); context.fill(x, y, x + boxWidth, y + boxHeight, Color.DARK_GRAY.getRGB());
context.drawTextWithShadow(textRenderer, currentText, x + padding, y + padding, Color.WHITE.getRGB()); context.drawTextWithShadow(textRenderer, currentText, x + padding, y + padding, Color.WHITE.getRGB());
@@ -85,8 +111,8 @@ public final class UiNotifications {
} }
remainingTicks--; remainingTicks--;
if (remainingTicks <= 0) { if (remainingTicks <= 0) {
currentText = Text.empty(); currentText = queuedTexts.isEmpty() ? Text.empty() : queuedTexts.remove();
remainingTicks = 0; remainingTicks = currentText.getString().isBlank() ? 0 : DEFAULT_DURATION_TICKS;
} }
} }
@@ -18,43 +18,48 @@ import java.net.http.HttpResponse;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
public class BackendAuthenticator { public final class BackendAuthenticator {
public static boolean authenticate(MinecraftClient client) { private BackendAuthenticator() {
}
public static CompletableFuture<Boolean> authenticateAsync(MinecraftClient client) {
Session session = client.getSession(); Session session = client.getSession();
if (session == null || session.getUuidOrNull() == null) { if (session == null || session.getUuidOrNull() == null) {
System.err.println("Cannot authenticate: Client has no valid session."); System.err.println("Cannot authenticate: Client has no valid session.");
return false; return CompletableFuture.completedFuture(false);
} }
MinecraftSessionService sessionService = client.getApiServices().sessionService(); MinecraftSessionService sessionService = client.getApiServices().sessionService();
return sendStartSessionRequestToBackendAsync(session.getUsername(), session.getUuidOrNull())
try { .thenCompose(serverId -> CompletableFuture.runAsync(() -> {
var serverId = sendStartSessionRequestToBackend(session.getUsername(), session.getUuidOrNull()); System.out.println("[SPMEGA] Trying to auth in mojang with serverId: " + serverId);
System.out.println("[SPMEGA] Trying to auth in mojang with serverId: " + serverId); try {
sessionService.joinServer( sessionService.joinServer(session.getUuidOrNull(), session.getAccessToken(), serverId);
session.getUuidOrNull(), } catch (AuthenticationException exception) {
session.getAccessToken(), throw new CompletionException(exception);
serverId }
); })
System.out.println("[SPMEGA] Sending session submitter to backend"); .thenCompose(ignored -> {
System.out.println("[SPMEGA] Sending session submitter to backend");
return sendAuthRequestToBackend(session.getUuidOrNull(), serverId); return sendAuthRequestToBackendAsync(session.getUuidOrNull(), serverId);
}))
} catch (AuthenticationException e) { .exceptionally(throwable -> {
System.err.println("I cant auth by Mojang: " + e.getMessage()); Throwable cause = throwable.getCause() == null ? throwable : throwable.getCause();
System.err.println("Please check your credentials and try again."); if (cause instanceof AuthenticationException) {
return false; System.err.println("I cant auth by Mojang: " + cause.getMessage());
System.err.println("Please check your credentials and try again.");
} catch (Exception e) { } else {
System.err.println("Failed to authenticate with backend: " + e.getMessage()); System.err.println("Failed to authenticate with backend: " + cause.getMessage());
return false; }
return false;
} });
} }
private static String sendStartSessionRequestToBackend(String username, UUID uuid) throws IOException, InterruptedException { private static CompletableFuture<String> sendStartSessionRequestToBackendAsync(String username, UUID uuid) {
ModConfig config = SPMega.getConfig(); ModConfig config = SPMega.getConfig();
String apiDomain = (config != null) ? config.apiDomain() : ModConfig.DEFAULT_API_DOMAIN; String apiDomain = (config != null) ? config.apiDomain() : ModConfig.DEFAULT_API_DOMAIN;
if (apiDomain == null || apiDomain.isBlank()) { if (apiDomain == null || apiDomain.isBlank()) {
@@ -78,21 +83,22 @@ public class BackendAuthenticator {
.method("POST", HttpRequest.BodyPublishers.ofString(requestBody)) .method("POST", HttpRequest.BodyPublishers.ofString(requestBody))
.build(); .build();
HttpResponse<String> response = httpClient.send(request); return httpClient.sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on start: " + response.body()); System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on start: " + response.body());
throw new IOException("Server returned status code " + response.statusCode() + " on start: " + response.body()); throw new CompletionException(new IOException(
} "Server returned status code " + response.statusCode() + " on start: " + response.body()));
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (!json.has("sessionId")) { if (!json.has("sessionId")) {
System.err.println("[SPMEGA] Invalid response from start endpoint: " + response.body()); System.err.println("[SPMEGA] Invalid response from start endpoint: " + response.body());
throw new IOException("Invalid response from start endpoint: " + response.body()); throw new CompletionException(new IOException("Invalid response from start endpoint: " + response.body()));
} }
return json.get("sessionId").getAsString(); return json.get("sessionId").getAsString();
});
} }
private static boolean sendAuthRequestToBackend(UUID uuid, String serverId) throws IOException, InterruptedException { private static CompletableFuture<Boolean> sendAuthRequestToBackendAsync(UUID uuid, String serverId) {
ModConfig config = SPMega.getConfig(); ModConfig config = SPMega.getConfig();
String apiDomain = (config != null) ? config.apiDomain() : ModConfig.DEFAULT_API_DOMAIN; String apiDomain = (config != null) ? config.apiDomain() : ModConfig.DEFAULT_API_DOMAIN;
if (apiDomain == null || apiDomain.isBlank()) { if (apiDomain == null || apiDomain.isBlank()) {
@@ -116,37 +122,30 @@ public class BackendAuthenticator {
.POST(HttpRequest.BodyPublishers.ofString(requestBody)) .POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build(); .build();
HttpResponse<String> response = httpClient.send(request); return httpClient.sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on validate: " + response.body()); System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on validate: " + response.body());
throw new IOException("Server returned status code " + response.statusCode() + " on validate: " + response.body()); throw new CompletionException(new IOException(
} "Server returned status code " + response.statusCode() + " on validate: " + response.body()));
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (!json.has("token")) {
System.err.println("[SPMEGA] Invalid response from validate endpoint: " + response.body());
throw new CompletionException(new IOException("Invalid response from validate endpoint: " + response.body()));
}
if (config == null) {
System.err.println("[SPMEGA] Config is null, cannot save token.");
throw new CompletionException(new IOException("Config is null, cannot save token."));
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); String token = json.get("token").getAsString();
if (!json.has("token")) { SPMega.setConfig(new ModConfig(
System.err.println("[SPMEGA] Invalid response from validate endpoint: " + response.body()); config.apiDomain(), token, config.allowBackend(), config.signQuickPayEnabled(),
throw new IOException("Invalid response from validate endpoint: " + response.body()); config.gpsEnabled(), config.gpsPosition(), config.notificationPosition(), config.telemetryEnabled(),
} config.telemetryIntervalSeconds(), config.telemetryCollectSystemInfo()));
if (config == null) { System.out.println("[SPMEGA] Backend auth successful, saved token.");
System.err.println("[SPMEGA] Config is null, cannot save token."); return true;
throw new IOException("Config is null, cannot save token."); });
}
String token = json.get("token").getAsString();
ModConfig updated = new ModConfig(
config.apiDomain(),
token,
config.allowBackend(),
config.signQuickPayEnabled(),
config.gpsEnabled(),
config.gpsPosition(),
config.telemetryEnabled(),
config.telemetryIntervalSeconds(),
config.telemetryCollectSystemInfo()
);
SPMega.setConfig(updated);
System.out.println("[SPMEGA] Backend auth successful, saved token.");
return true;
} }
public static void sendCardToBackend(String cardId, String cardToken) { public static void sendCardToBackend(String cardId, String cardToken) {
@@ -193,10 +192,10 @@ public class BackendAuthenticator {
}); });
} }
public static List<CardCredentials> fetchCardsFromBackend() throws IOException, InterruptedException { public static CompletableFuture<List<CardCredentials>> fetchCardsFromBackendAsync() {
ModConfig config = SPMega.getConfig(); ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) { if (config == null || !config.allowBackend()) {
return List.of(); return CompletableFuture.completedFuture(List.of());
} }
String apiDomain = config.apiDomain(); String apiDomain = config.apiDomain();
@@ -216,28 +215,29 @@ public class BackendAuthenticator {
.GET() .GET()
.build(); .build();
HttpResponse<String> response = httpClient.send(request); return httpClient.sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
throw new IOException("Server returned status code " + response.statusCode() + " on fetch cards."); throw new CompletionException(new IOException(
} "Server returned status code " + response.statusCode() + " on fetch cards."));
com.google.gson.JsonArray cardsArray = JsonParser.parseString(response.body()).getAsJsonArray();
List<CardCredentials> cards = new ArrayList<>();
for (com.google.gson.JsonElement el : cardsArray) {
JsonObject cardJson = el.getAsJsonObject();
String cardId = cardJson.has("cardId") && !cardJson.get("cardId").isJsonNull()
? cardJson.get("cardId").getAsString()
: (cardJson.has("id") && !cardJson.get("id").isJsonNull() ? cardJson.get("id").getAsString() : "");
String cardToken = cardJson.has("cardToken") && !cardJson.get("cardToken").isJsonNull()
? cardJson.get("cardToken").getAsString()
: (cardJson.has("token") && !cardJson.get("token").isJsonNull() ? cardJson.get("token").getAsString() : "");
if (!cardId.isEmpty() && !cardToken.isEmpty()) {
cards.add(new CardCredentials(cardId, cardToken));
} }
} com.google.gson.JsonArray cardsArray = JsonParser.parseString(response.body()).getAsJsonArray();
return cards; List<CardCredentials> cards = new ArrayList<>();
for (com.google.gson.JsonElement element : cardsArray) {
JsonObject cardJson = element.getAsJsonObject();
String cardId = cardJson.has("cardId") && !cardJson.get("cardId").isJsonNull()
? cardJson.get("cardId").getAsString()
: (cardJson.has("id") && !cardJson.get("id").isJsonNull() ? cardJson.get("id").getAsString() : "");
String cardToken = cardJson.has("cardToken") && !cardJson.get("cardToken").isJsonNull()
? cardJson.get("cardToken").getAsString()
: (cardJson.has("token") && !cardJson.get("token").isJsonNull() ? cardJson.get("token").getAsString() : "");
boolean webhookEnabled = cardJson.has("webhookConnected")
&& cardJson.get("webhookConnected").getAsBoolean();
if (!cardId.isEmpty() && !cardToken.isEmpty()) {
cards.add(new CardCredentials(cardId, cardToken, webhookEnabled));
}
}
return cards;
});
} }
public static void deleteCardOnBackend(String cardId) { public static void deleteCardOnBackend(String cardId) {
@@ -278,10 +278,87 @@ public class BackendAuthenticator {
}); });
} }
public static boolean createTransactionOnBackend(String senderCardId, String receiver, long amount, String comment) throws IOException, InterruptedException { public static CompletableFuture<Void> registerWebhookForCardAsync(String cardId) {
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) {
return CompletableFuture.failedFuture(new IOException("Backend is disabled"));
}
HttpRequest request = HttpRequest.newBuilder(URI.create(backendUrl(config, "/api/v1/webhook/" + cardId)))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.PUT(HttpRequest.BodyPublishers.noBody())
.build();
return new InstrumentedHttpClient().sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200 && response.statusCode() != 204) {
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + ": " + response.body()));
}
return null;
});
}
public static CompletableFuture<List<PaymentNotification>> readNotificationsAsync() {
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()
|| config.apiToken() == null || config.apiToken().isBlank()
|| ModConfig.DEFAULT_API_TOKEN.equals(config.apiToken())) {
return CompletableFuture.completedFuture(List.of());
}
HttpRequest request = HttpRequest.newBuilder(URI.create(backendUrl(config, "/api/v1/webhook/read")))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.GET()
.build();
return new InstrumentedHttpClient().sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200) {
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + " on notification read."));
}
JsonElement parsed = JsonParser.parseString(response.body());
com.google.gson.JsonArray array = parsed.isJsonArray()
? parsed.getAsJsonArray()
: parsed.getAsJsonObject().getAsJsonArray("$values");
if (array == null) {
return List.of();
}
List<PaymentNotification> notifications = new ArrayList<>();
for (JsonElement element : array) {
JsonObject json = element.getAsJsonObject();
notifications.add(new PaymentNotification(
stringValue(json, "id"),
stringValue(json, "senderName"),
stringValue(json, "senderNumber"),
stringValue(json, "comment"),
json.has("amount") ? json.get("amount").getAsInt() : 0,
stringValue(json, "type")
));
}
return notifications;
});
}
private static String backendUrl(ModConfig config, String path) {
String domain = config.apiDomain();
if (domain == null || domain.isBlank()) {
domain = ModConfig.DEFAULT_API_DOMAIN;
}
return (domain.endsWith("/") ? domain.substring(0, domain.length() - 1) : domain) + path;
}
private static String stringValue(JsonObject json, String name) {
return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsString() : "";
}
public static CompletableFuture<Boolean> createTransactionOnBackendAsync(
String senderCardId, String receiver, long amount, String comment) {
ModConfig config = SPMega.getConfig(); ModConfig config = SPMega.getConfig();
if (config == null) { if (config == null) {
throw new IOException("ModConfig is null"); return CompletableFuture.failedFuture(new IOException("ModConfig is null"));
} }
String apiDomain = config.apiDomain(); String apiDomain = config.apiDomain();
@@ -311,19 +388,22 @@ public class BackendAuthenticator {
.PUT(HttpRequest.BodyPublishers.ofString(requestBody)) .PUT(HttpRequest.BodyPublishers.ofString(requestBody))
.build(); .build();
HttpResponse<String> response = httpClient.send(request); return httpClient.sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200 && response.statusCode() != 201 && response.statusCode() != 204) { if (response.statusCode() != 200 && response.statusCode() != 201 && response.statusCode() != 204) {
throw new IOException("Server returned status code " + response.statusCode() + ": " + response.body()); throw new CompletionException(new IOException(
} "Server returned status code " + response.statusCode() + ": " + response.body()));
return true; }
return true;
});
} }
public static List<BankDatabase.LocalTransaction> fetchTransactionsFromBackend(String cardId, int page) throws IOException, InterruptedException { public static CompletableFuture<List<BankDatabase.LocalTransaction>> fetchTransactionsFromBackendAsync(
String cardId, int page) {
System.out.println("[SPMEGA] fetchTransactionsFromBackend called for cardId: " + cardId + ", page: " + page); System.out.println("[SPMEGA] fetchTransactionsFromBackend called for cardId: " + cardId + ", page: " + page);
ModConfig config = SPMega.getConfig(); ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) { if (config == null || !config.allowBackend()) {
System.out.println("[SPMEGA] fetchTransactionsFromBackend aborted: config is null or backend not allowed."); System.out.println("[SPMEGA] fetchTransactionsFromBackend aborted: config is null or backend not allowed.");
return List.of(); return CompletableFuture.completedFuture(List.of());
} }
String apiDomain = config.apiDomain(); String apiDomain = config.apiDomain();
@@ -344,11 +424,17 @@ public class BackendAuthenticator {
.GET() .GET()
.build(); .build();
HttpResponse<String> response = httpClient.send(request); return httpClient.sendAsync(request)
.thenApply(response -> parseTransactionsResponse(response, cardId));
}
private static List<BankDatabase.LocalTransaction> parseTransactionsResponse(
HttpResponse<String> response, String cardId) {
System.out.println("[SPMEGA] Response status code: " + response.statusCode()); System.out.println("[SPMEGA] Response status code: " + response.statusCode());
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Failed response body: " + response.body()); System.err.println("[SPMEGA] Failed response body: " + response.body());
throw new IOException("Server returned status code " + response.statusCode() + " on fetch transactions."); throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + " on fetch transactions."));
} }
String body = response.body(); String body = response.body();
@@ -426,4 +512,8 @@ public class BackendAuthenticator {
System.out.println("[SPMEGA] Returning " + list.size() + " filtered transactions for card " + cardId); System.out.println("[SPMEGA] Returning " + list.size() + " filtered transactions for card " + cardId);
return list; return list;
} }
public record PaymentNotification(
String id, String senderName, String senderNumber, String comment, int amount, String type) {
}
} }
@@ -23,9 +23,23 @@ public final class BankDatabase {
card_number TEXT, card_number TEXT,
balance INTEGER NOT NULL DEFAULT 0, balance INTEGER NOT NULL DEFAULT 0,
owner_uuid TEXT, owner_uuid TEXT,
webhook_enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP updated_at TEXT DEFAULT CURRENT_TIMESTAMP
) )
"""); """);
boolean hasWebhookColumn = false;
try (ResultSet columns = statement.executeQuery("PRAGMA table_info(cards)")) {
while (columns.next()) {
if ("webhook_enabled".equals(columns.getString("name"))) {
hasWebhookColumn = true;
break;
}
}
}
if (!hasWebhookColumn) {
statement.executeUpdate(
"ALTER TABLE cards ADD COLUMN webhook_enabled INTEGER NOT NULL DEFAULT 0");
}
statement.executeUpdate(""" statement.executeUpdate("""
CREATE TABLE IF NOT EXISTS transfer_history ( CREATE TABLE IF NOT EXISTS transfer_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -100,8 +114,31 @@ public final class BankDatabase {
} }
} }
public synchronized void setWebhookEnabled(String cardId, boolean enabled) {
String sql = "UPDATE cards SET webhook_enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE card_id = ?";
try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setBoolean(1, enabled);
statement.setString(2, cardId);
statement.executeUpdate();
} catch (SQLException exception) {
throw new RuntimeException("Failed to update webhook state", exception);
}
}
public synchronized boolean hasWebhookEnabledCards() {
try (Connection connection = open();
PreparedStatement statement = connection.prepareStatement(
"SELECT 1 FROM cards WHERE webhook_enabled = 1 LIMIT 1");
ResultSet result = statement.executeQuery()) {
return result.next();
} catch (SQLException exception) {
throw new RuntimeException("Failed to read webhook state", exception);
}
}
public synchronized List<StoredCard> loadCards() { public synchronized List<StoredCard> loadCards() {
String sql = "SELECT card_id, card_token, card_name, card_number, balance, owner_uuid FROM cards ORDER BY updated_at DESC"; String sql = "SELECT card_id, card_token, card_name, card_number, balance, owner_uuid, webhook_enabled "
+ "FROM cards ORDER BY updated_at DESC";
List<StoredCard> result = new ArrayList<>(); List<StoredCard> result = new ArrayList<>();
try (Connection connection = open(); try (Connection connection = open();
PreparedStatement statement = connection.prepareStatement(sql); PreparedStatement statement = connection.prepareStatement(sql);
@@ -113,7 +150,8 @@ public final class BankDatabase {
rs.getString("card_name"), rs.getString("card_name"),
rs.getString("card_number"), rs.getString("card_number"),
rs.getLong("balance"), rs.getLong("balance"),
rs.getString("owner_uuid") rs.getString("owner_uuid"),
rs.getBoolean("webhook_enabled")
)); ));
} }
return result; return result;
@@ -107,47 +107,37 @@ public final class BankUiService {
return lastMessage; return lastMessage;
} }
private static String rootMessage(Throwable throwable) {
Throwable cause = throwable;
while (cause.getCause() != null) {
cause = cause.getCause();
}
return cause.getMessage();
}
public CompletableFuture<Void> syncCardsWithBackendAsync(String playerUuid) { public CompletableFuture<Void> syncCardsWithBackendAsync(String playerUuid) {
return CompletableFuture.runAsync(() -> { return BackendAuthenticator.fetchCardsFromBackendAsync()
try { .thenCompose(cards -> syncMissingCardsAsync(cards, playerUuid))
List<CardCredentials> backendCards = BackendAuthenticator.fetchCardsFromBackend(); .thenAccept(changed -> {
boolean changed = false; if (changed) {
for (CardCredentials creds : backendCards) { reloadCardsFromDb();
if (database.getCredentials(creds.cardId()) == null) {
database.upsertCardCredentials(creds.cardId(), creds.cardToken());
refreshCardSync(creds.cardId(), creds.cardToken(), playerUuid, false, false);
changed = true;
} }
} })
if (changed) { .exceptionally(exception -> {
reloadCardsFromDb(); System.err.println("[SPMEGA] Failed to sync cards with backend: " + exception.getMessage());
} return null;
} catch (Exception e) { });
System.err.println("[SPMEGA] Failed to sync cards with backend: " + e.getMessage());
}
});
} }
public CompletableFuture<Void> refreshOnServerJoinAsync(String playerUuid) { public CompletableFuture<Void> refreshOnServerJoinAsync(String playerUuid) {
return CompletableFuture.runAsync(() -> { return BackendAuthenticator.fetchCardsFromBackendAsync()
try { .exceptionally(exception -> {
List<CardCredentials> backendCards = BackendAuthenticator.fetchCardsFromBackend(); System.err.println("[SPMEGA] Failed to sync cards with backend: " + exception.getMessage());
for (CardCredentials creds : backendCards) { return List.of();
if (database.getCredentials(creds.cardId()) == null) { })
database.upsertCardCredentials(creds.cardId(), creds.cardToken()); .thenCompose(cards -> syncMissingCardsAsync(cards, playerUuid))
refreshCardSync(creds.cardId(), creds.cardToken(), playerUuid, false, false); .thenCompose(ignored -> refreshStoredCardsAsync(playerUuid))
} .thenRun(this::reloadCardsFromDb);
}
} catch (Exception e) {
System.err.println("[SPMEGA] Failed to sync cards with backend: " + e.getMessage());
}
List<StoredCard> storedCards = database.loadCards();
for (StoredCard card : storedCards) {
refreshCardSync(card.cardId(), card.cardToken(), playerUuid, false, false);
}
reloadCardsFromDb();
});
} }
public CompletableFuture<List<String>> loadRecipientCardsAsync(String username) { public CompletableFuture<List<String>> loadRecipientCardsAsync(String username) {
@@ -156,9 +146,8 @@ public final class BankUiService {
return CompletableFuture.completedFuture(List.of()); return CompletableFuture.completedFuture(List.of());
} }
return CompletableFuture.supplyAsync(() -> { return apiClient.getPlayerCardsAsync(username, toApiAuth(credentials))
try { .thenApply(apiCards -> {
List<SPWorldsApiClient.PlayerCard> apiCards = apiClient.getPlayerCards(username, toApiAuth(credentials));
List<String> numbers = new ArrayList<>(); List<String> numbers = new ArrayList<>();
for (SPWorldsApiClient.PlayerCard apiCard : apiCards) { for (SPWorldsApiClient.PlayerCard apiCard : apiCards) {
if (apiCard.number() != null && !apiCard.number().isBlank()) { if (apiCard.number() != null && !apiCard.number().isBlank()) {
@@ -167,40 +156,11 @@ public final class BankUiService {
} }
lastMessage = ""; lastMessage = "";
return numbers; return numbers;
} catch (Exception exception) { })
.exceptionally(exception -> {
lastMessage = "Не удалось получить карты игрока: " + exception.getMessage(); lastMessage = "Не удалось получить карты игрока: " + exception.getMessage();
return List.of(); return List.of();
} });
});
}
public CompletableFuture<String> addCardAsync(String cardIdRaw, String cardTokenRaw, String playerUuid) {
String cardId = cardIdRaw == null ? "" : cardIdRaw.trim();
String cardToken = cardTokenRaw == null ? "" : cardTokenRaw.trim();
if (cardId.isEmpty() || cardToken.isEmpty()) {
return CompletableFuture.completedFuture("Укажи cardId и cardToken");
}
try {
UUID.fromString(cardId);
} catch (IllegalArgumentException exception) {
return CompletableFuture.completedFuture("cardId должен быть UUID");
}
return CompletableFuture.supplyAsync(() -> {
database.upsertCardCredentials(cardId, cardToken);
boolean refreshed = refreshCardSync(cardId, cardToken, playerUuid, true, true);
if (!refreshed) {
database.deleteCard(cardId);
}
reloadCardsFromDb();
if (refreshed && lastMessage.isBlank()) {
return "Карта добавлена";
}
return lastMessage;
});
} }
public CompletableFuture<String> removeSelectedCardAsync() { public CompletableFuture<String> removeSelectedCardAsync() {
@@ -227,101 +187,48 @@ public final class BankUiService {
}); });
} }
public CompletableFuture<String> addCardAsync(String cardIdRaw, String cardTokenRaw, String playerUuid) {
String cardId = cardIdRaw == null ? "" : cardIdRaw.trim();
String cardToken = cardTokenRaw == null ? "" : cardTokenRaw.trim();
if (cardId.isEmpty() || cardToken.isEmpty()) {
return CompletableFuture.completedFuture("Укажи cardId и cardToken");
}
try {
UUID.fromString(cardId);
} catch (IllegalArgumentException exception) {
return CompletableFuture.completedFuture("cardId должен быть UUID");
}
return CompletableFuture.runAsync(() -> database.upsertCardCredentials(cardId, cardToken))
.thenCompose(ignored -> refreshCardAsync(cardId, cardToken, playerUuid, true, true))
.thenApply(refreshed -> {
if (!refreshed) {
database.deleteCard(cardId);
}
reloadCardsFromDb();
return refreshed && lastMessage.isBlank() ? "Карта добавлена" : lastMessage;
});
}
public CompletableFuture<String> refreshSelectedCardAsync(String playerUuid) { public CompletableFuture<String> refreshSelectedCardAsync(String playerUuid) {
CardViewModel selected = getSelectedCard(); CardViewModel selected = getSelectedCard();
if (selected == null) { if (selected == null) {
return CompletableFuture.completedFuture("Нет карты для обновления"); return CompletableFuture.completedFuture("Нет карты для обновления");
} }
return CompletableFuture.supplyAsync(() -> { return CompletableFuture.supplyAsync(() -> database.getCredentials(selected.id()))
CardCredentials credentials = database.getCredentials(selected.id()); .thenCompose(credentials -> {
if (credentials == null) { if (credentials == null) {
return "Не найдены креды карты"; return CompletableFuture.completedFuture("Не найдены креды карты");
} }
return refreshCardAsync(credentials.cardId(), credentials.cardToken(), playerUuid, false, false)
refreshCardSync(credentials.cardId(), credentials.cardToken(), playerUuid, false, false); .thenApply(ignored -> {
reloadCardsFromDb(); reloadCardsFromDb();
return lastMessage.isBlank() ? "Карта обновлена" : lastMessage; return lastMessage.isBlank() ? "Карта обновлена" : lastMessage;
}); });
} });
public CompletableFuture<Boolean> submitPaymentAsync(PaymentDraft draft) {
return CompletableFuture.supplyAsync(() -> {
CardCredentials credentials = database.getCredentials(draft.senderCardId());
if (credentials == null) {
lastMessage = "Не найдены креды карты отправителя";
recordPaymentEvent(draft, false, "local", "no_credentials", 0, null);
return false;
}
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
boolean useBackend = config != null && config.allowBackend();
String mode = useBackend ? "backend" : "spworlds-direct";
String destination = useBackend ? "backend" : "local-db";
String senderLast4 = extractLastDigits(credentials.cardId());
long startNs = System.nanoTime();
try {
long newBalance = 0;
if (useBackend) {
BackendAuthenticator.createTransactionOnBackend(
draft.senderCardId(),
draft.recipient(),
draft.amount(),
draft.comment()
);
try {
refreshCardSync(credentials.cardId(), credentials.cardToken(), "", false, false);
StoredCard updatedCard = database.loadCards().stream()
.filter(c -> c.cardId().equals(credentials.cardId()))
.findFirst()
.orElse(null);
if (updatedCard != null) {
newBalance = updatedCard.balance();
}
} catch (Exception e) {
System.err.println("[SPMEGA] Failed to refresh card balance after transaction: " + e.getMessage());
}
} else {
SPWorldsApiClient.TransactionResult result = apiClient.createTransaction(
toApiAuth(credentials),
draft.recipient(),
draft.amount(),
draft.comment()
);
newBalance = result.balance();
database.updateCardBalance(credentials.cardId(), newBalance);
}
database.insertTransferHistory(
credentials.cardId(),
draft.recipient(),
draft.amount(),
draft.comment(),
newBalance,
"SUCCESS"
);
reloadCardsFromDb();
lastMessage = "Перевод выполнен";
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
recordPaymentEvent(draft, true, mode, destination, durationMs, senderLast4);
return true;
} catch (Exception exception) {
database.insertTransferHistory(
credentials.cardId(),
draft.recipient(),
draft.amount(),
draft.comment(),
null,
"FAILED: " + trimMessage(exception.getMessage())
);
lastMessage = "Ошибка перевода: " + exception.getMessage();
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
recordPaymentEvent(draft, false, mode, destination, durationMs, senderLast4, exception.getMessage());
return false;
}
});
} }
private void recordPaymentEvent(PaymentDraft draft, boolean success, String mode, String destination, long durationMs, String senderLast4) { private void recordPaymentEvent(PaymentDraft draft, boolean success, String mode, String destination, long durationMs, String senderLast4) {
@@ -343,17 +250,73 @@ public final class BankUiService {
TelemetryCollector.instance().record(TelemetryEvent.now("payment", payload)); TelemetryCollector.instance().record(TelemetryEvent.now("payment", payload));
} }
private boolean refreshCardSync( public CompletableFuture<Boolean> submitPaymentAsync(PaymentDraft draft) {
return CompletableFuture.supplyAsync(() -> database.getCredentials(draft.senderCardId()))
.thenCompose(credentials -> {
if (credentials == null) {
lastMessage = "Не найдены креды карты отправителя";
recordPaymentEvent(draft, false, "local", "no_credentials", 0, null);
return CompletableFuture.completedFuture(false);
}
ModConfig config = SPMega.getConfig();
boolean useBackend = config != null && config.allowBackend();
String mode = useBackend ? "backend" : "spworlds-direct";
String destination = useBackend ? "backend" : "local-db";
String senderLast4 = extractLastDigits(credentials.cardId());
long startNs = System.nanoTime();
CompletableFuture<Long> transaction = useBackend
? BackendAuthenticator.createTransactionOnBackendAsync(
draft.senderCardId(), draft.recipient(), draft.amount(), draft.comment())
.thenCompose(ignored -> refreshCardAsync(
credentials.cardId(), credentials.cardToken(), "", false, false))
.thenApply(ignored -> database.loadCards().stream()
.filter(card -> card.cardId().equals(credentials.cardId()))
.findFirst()
.map(StoredCard::balance)
.orElse(0L))
: apiClient.createTransactionAsync(
toApiAuth(credentials), draft.recipient(), draft.amount(), draft.comment())
.thenApply(result -> {
database.updateCardBalance(credentials.cardId(), result.balance());
return result.balance();
});
return transaction
.thenApply(newBalance -> {
database.insertTransferHistory(
credentials.cardId(), draft.recipient(), draft.amount(), draft.comment(),
newBalance, "SUCCESS");
reloadCardsFromDb();
lastMessage = "Перевод выполнен";
recordPaymentEvent(draft, true, mode, destination,
(System.nanoTime() - startNs) / 1_000_000L, senderLast4);
return true;
})
.exceptionally(exception -> {
String error = rootMessage(exception);
database.insertTransferHistory(
credentials.cardId(), draft.recipient(), draft.amount(), draft.comment(),
null, "FAILED: " + trimMessage(error));
lastMessage = "Ошибка перевода: " + error;
recordPaymentEvent(draft, false, mode, destination,
(System.nanoTime() - startNs) / 1_000_000L, senderLast4, error);
return false;
});
});
}
private CompletableFuture<Boolean> refreshCardAsync(
String cardId, String cardId,
String cardToken, String cardToken,
String playerUuid, String playerUuid,
boolean reportOwnerWarning, boolean reportOwnerWarning,
boolean requireCardInAccount boolean requireCardInAccount
) { ) {
try { SPWorldsApiClient.CardAuth auth = new SPWorldsApiClient.CardAuth(cardId, cardToken);
SPWorldsApiClient.CardAuth auth = new SPWorldsApiClient.CardAuth(cardId, cardToken); return apiClient.getAccountMeAsync(auth)
SPWorldsApiClient.AccountMe me = apiClient.getAccountMe(auth); .thenCompose(me -> apiClient.getCardInfoAsync(auth).thenApply(cardInfo -> {
SPWorldsApiClient.CardInfo cardInfo = apiClient.getCardInfo(auth);
SPWorldsApiClient.AccountCard currentCard = me.cards().stream() SPWorldsApiClient.AccountCard currentCard = me.cards().stream()
.filter(card -> Objects.equals(card.id(), cardId)) .filter(card -> Objects.equals(card.id(), cardId))
@@ -378,17 +341,67 @@ public final class BankUiService {
return true; return true;
} }
} }
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig(); ModConfig config = SPMega.getConfig();
if (config != null && config.allowBackend()) { if (config != null && config.allowBackend()) {
BackendAuthenticator.sendCardToBackend(cardId, cardToken); BackendAuthenticator.sendCardToBackend(cardId, cardToken);
} }
lastMessage = ""; lastMessage = "";
return true; return true;
} catch (Exception exception) { }))
lastMessage = "Не удалось обновить карту: " + exception.getMessage(); .exceptionally(exception -> {
return false; lastMessage = "Не удалось обновить карту: " + rootMessage(exception);
return false;
});
}
public CompletableFuture<String> registerSelectedWebhookAsync() {
CardViewModel selected = getSelectedCard();
if (selected == null) {
return CompletableFuture.completedFuture("Сначала выбери или добавь карту");
} }
if (selected.webhookEnabled()) {
return CompletableFuture.completedFuture("Вебхук для этой карты уже включён");
}
return BackendAuthenticator.registerWebhookForCardAsync(selected.id())
.thenApply(ignored -> {
database.setWebhookEnabled(selected.id(), true);
reloadCardsFromDb();
return "Обработка вебхуков включена";
})
.exceptionally(exception -> "Не удалось включить вебхук: " + rootMessage(exception));
}
public boolean hasWebhookEnabledCards() {
return database.hasWebhookEnabledCards();
}
private CompletableFuture<Boolean> syncMissingCardsAsync(List<CardCredentials> backendCards, String playerUuid) {
CompletableFuture<Boolean> result = CompletableFuture.completedFuture(false);
for (CardCredentials credentials : backendCards) {
result = result.thenCompose(changed -> {
if (database.getCredentials(credentials.cardId()) != null) {
database.setWebhookEnabled(credentials.cardId(), credentials.webhookEnabled());
return CompletableFuture.completedFuture(changed);
}
database.upsertCardCredentials(credentials.cardId(), credentials.cardToken());
database.setWebhookEnabled(credentials.cardId(), credentials.webhookEnabled());
return refreshCardAsync(
credentials.cardId(), credentials.cardToken(), playerUuid, false, false)
.thenApply(ignored -> true);
});
}
return result;
}
private CompletableFuture<Void> refreshStoredCardsAsync(String playerUuid) {
CompletableFuture<Void> result = CompletableFuture.completedFuture(null);
for (StoredCard card : database.loadCards()) {
result = result.thenCompose(ignored -> refreshCardAsync(
card.cardId(), card.cardToken(), playerUuid, false, false).thenApply(refreshed -> null));
}
return result;
} }
private void reloadCardsFromDb() { private void reloadCardsFromDb() {
@@ -401,7 +414,8 @@ public final class BankUiService {
: stored.cardNumber(); : stored.cardNumber();
String cardName = stored.cardName() == null || stored.cardName().isBlank() ? "Карта" : stored.cardName(); String cardName = stored.cardName() == null || stored.cardName().isBlank() ? "Карта" : stored.cardName();
String title = cardNumber + ": " + cardName; String title = cardNumber + ": " + cardName;
cards.add(new CardViewModel(stored.cardId(), title, stored.balance())); cards.add(new CardViewModel(
stored.cardId(), title, stored.balance(), stored.webhookEnabled()));
} }
if (cards.isEmpty()) { if (cards.isEmpty()) {
@@ -1,5 +1,8 @@
package git.yawaflua.tech.spmega.client.ui.service; package git.yawaflua.tech.spmega.client.ui.service;
public record CardCredentials(String cardId, String cardToken) { public record CardCredentials(String cardId, String cardToken, boolean webhookEnabled) {
public CardCredentials(String cardId, String cardToken) {
this(cardId, cardToken, false);
}
} }
@@ -1,5 +1,5 @@
package git.yawaflua.tech.spmega.client.ui.service; package git.yawaflua.tech.spmega.client.ui.service;
public record CardViewModel(String id, String title, long balance) { public record CardViewModel(String id, String title, long balance, boolean webhookEnabled) {
} }
@@ -1,6 +1,6 @@
package git.yawaflua.tech.spmega.client.ui.service; package git.yawaflua.tech.spmega.client.ui.service;
public record StoredCard(String cardId, String cardToken, String cardName, String cardNumber, long balance, public record StoredCard(String cardId, String cardToken, String cardName, String cardNumber, long balance,
String ownerUuid) { String ownerUuid, boolean webhookEnabled) {
} }
@@ -29,6 +29,13 @@
"option.spmega.gps_position.bottom_right": "Bottom-Right", "option.spmega.gps_position.bottom_right": "Bottom-Right",
"option.spmega.gps_position.bottom_center": "Bottom-center", "option.spmega.gps_position.bottom_center": "Bottom-center",
"option.spmega.gps_position.top_center": "Top-center", "option.spmega.gps_position.top_center": "Top-center",
"option.spmega.notification_position": "Notification position",
"option.spmega.notification_position.top_left": "Top-left",
"option.spmega.notification_position.top_right": "Top-right",
"option.spmega.notification_position.bottom_left": "Bottom-left",
"option.spmega.notification_position.bottom_right": "Bottom-right",
"option.spmega.notification_position.bottom_center": "Bottom-center",
"option.spmega.notification_position.top_center": "Top-center",
"category.spmega.telemetry": "Telemetry", "category.spmega.telemetry": "Telemetry",
"option.spmega.telemetry_enabled": "Collect system information", "option.spmega.telemetry_enabled": "Collect system information",
"tooltip.spmega.telemetry_enabled": "Disabling hides hardware data (CPU, GPU, RAM, IP). Performance and request stats keep sending.", "tooltip.spmega.telemetry_enabled": "Disabling hides hardware data (CPU, GPU, RAM, IP). Performance and request stats keep sending.",
@@ -29,6 +29,13 @@
"option.spmega.gps_position.bottom_center": "Снизу по-центру", "option.spmega.gps_position.bottom_center": "Снизу по-центру",
"option.spmega.gps_position.top_center": "Сверху по-центру", "option.spmega.gps_position.top_center": "Сверху по-центру",
"option.spmega.gps_position.bottom_right": "Снизу справа", "option.spmega.gps_position.bottom_right": "Снизу справа",
"option.spmega.notification_position": "Положение уведомлений",
"option.spmega.notification_position.top_left": "Слева вверху",
"option.spmega.notification_position.top_right": "Справа вверху",
"option.spmega.notification_position.bottom_left": "Слева внизу",
"option.spmega.notification_position.bottom_center": "Снизу по центру",
"option.spmega.notification_position.top_center": "Сверху по центру",
"option.spmega.notification_position.bottom_right": "Снизу справа",
"category.spmega.telemetry": "Телеметрия", "category.spmega.telemetry": "Телеметрия",
"option.spmega.telemetry_enabled": "Сбор системной информации", "option.spmega.telemetry_enabled": "Сбор системной информации",
"tooltip.spmega.telemetry_enabled": "Отключение скрывает данные компьютера (CPU, GPU, RAM, IP). Статистика производительности и запросов продолжает отправляться.", "tooltip.spmega.telemetry_enabled": "Отключение скрывает данные компьютера (CPU, GPU, RAM, IP). Статистика производительности и запросов продолжает отправляться.",
@@ -37,4 +44,3 @@
"option.spmega.telemetry_system_info": "Отправлять информацию о системе", "option.spmega.telemetry_system_info": "Отправлять информацию о системе",
"tooltip.spmega.telemetry_system_info": "CPU, GPU, RAM, диск, IP, имя устройства, версия Java" "tooltip.spmega.telemetry_system_info": "CPU, GPU, RAM, диск, IP, имя устройства, версия Java"
} }
@@ -43,7 +43,7 @@ public final class ConfigManager {
} }
boolean allowAccess = readBoolean(properties, "allow.backend", defaults.allowBackend()); boolean allowAccess = readBoolean(properties, "allow.backend", defaults.allowBackend());
String rawAllowAccess = properties.getProperty("sign.quickPay.enabled"); String rawAllowAccess = properties.getProperty("allow.backend");
if (rawAllowAccess == null || !Boolean.toString(allowAccess).equalsIgnoreCase(rawAllowAccess.trim())) { if (rawAllowAccess == null || !Boolean.toString(allowAccess).equalsIgnoreCase(rawAllowAccess.trim())) {
shouldSave = true; shouldSave = true;
} }
@@ -66,6 +66,14 @@ public final class ConfigManager {
shouldSave = true; shouldSave = true;
} }
GpsHudPosition notificationPosition = readEnum(
properties, "notifications.position", GpsHudPosition.class, defaults.notificationPosition());
String rawNotificationPosition = properties.getProperty("notifications.position");
if (rawNotificationPosition == null
|| !notificationPosition.name().equalsIgnoreCase(rawNotificationPosition.trim())) {
shouldSave = true;
}
boolean telemetryEnabled = readBoolean(properties, "telemetry.enabled", defaults.telemetryEnabled()); boolean telemetryEnabled = readBoolean(properties, "telemetry.enabled", defaults.telemetryEnabled());
String rawTelemetry = properties.getProperty("telemetry.enabled"); String rawTelemetry = properties.getProperty("telemetry.enabled");
if (rawTelemetry == null || !Boolean.toString(telemetryEnabled).equalsIgnoreCase(rawTelemetry.trim())) { if (rawTelemetry == null || !Boolean.toString(telemetryEnabled).equalsIgnoreCase(rawTelemetry.trim())) {
@@ -85,6 +93,7 @@ public final class ConfigManager {
} }
ModConfig config = new ModConfig(apiDomain, apiToken, allowAccess, signQuickPayEnabled, gpsEnabled, gpsPosition, ModConfig config = new ModConfig(apiDomain, apiToken, allowAccess, signQuickPayEnabled, gpsEnabled, gpsPosition,
notificationPosition,
telemetryEnabled, telemetryIntervalSeconds, telemetryCollectSystemInfo); telemetryEnabled, telemetryIntervalSeconds, telemetryCollectSystemInfo);
@@ -145,9 +154,11 @@ public final class ConfigManager {
Properties properties = new Properties(); Properties properties = new Properties();
properties.setProperty("api.domain", config.apiDomain()); properties.setProperty("api.domain", config.apiDomain());
properties.setProperty("api.token", config.apiToken()); properties.setProperty("api.token", config.apiToken());
properties.setProperty("allow.backend", Boolean.toString(config.allowBackend()));
properties.setProperty("sign.quickPay.enabled", Boolean.toString(config.signQuickPayEnabled())); properties.setProperty("sign.quickPay.enabled", Boolean.toString(config.signQuickPayEnabled()));
properties.setProperty("gps.enabled", Boolean.toString(config.gpsEnabled())); properties.setProperty("gps.enabled", Boolean.toString(config.gpsEnabled()));
properties.setProperty("gps.position", config.gpsPosition().name()); properties.setProperty("gps.position", config.gpsPosition().name());
properties.setProperty("notifications.position", config.notificationPosition().name());
properties.setProperty("telemetry.enabled", Boolean.toString(config.telemetryEnabled())); properties.setProperty("telemetry.enabled", Boolean.toString(config.telemetryEnabled()));
properties.setProperty("telemetry.intervalSeconds", Integer.toString(config.telemetryIntervalSeconds())); properties.setProperty("telemetry.intervalSeconds", Integer.toString(config.telemetryIntervalSeconds()));
properties.setProperty("telemetry.collectSystemInfo", Boolean.toString(config.telemetryCollectSystemInfo())); properties.setProperty("telemetry.collectSystemInfo", Boolean.toString(config.telemetryCollectSystemInfo()));
@@ -2,6 +2,7 @@ package git.yawaflua.tech.spmega;
public record ModConfig(String apiDomain, String apiToken, boolean allowBackend, boolean signQuickPayEnabled, public record ModConfig(String apiDomain, String apiToken, boolean allowBackend, boolean signQuickPayEnabled,
boolean gpsEnabled, GpsHudPosition gpsPosition, boolean gpsEnabled, GpsHudPosition gpsPosition,
GpsHudPosition notificationPosition,
boolean telemetryEnabled, int telemetryIntervalSeconds, boolean telemetryCollectSystemInfo) { boolean telemetryEnabled, int telemetryIntervalSeconds, boolean telemetryCollectSystemInfo) {
public static final String DEFAULT_API_DOMAIN = "https://spmega.yawaflua.tech"; public static final String DEFAULT_API_DOMAIN = "https://spmega.yawaflua.tech";
public static final boolean ALLOW_BACKEND = true; public static final boolean ALLOW_BACKEND = true;
@@ -9,6 +10,7 @@ public record ModConfig(String apiDomain, String apiToken, boolean allowBackend,
public static final boolean DEFAULT_SIGN_QUICK_PAY_ENABLED = true; public static final boolean DEFAULT_SIGN_QUICK_PAY_ENABLED = true;
public static final boolean DEFAULT_GPS_ENABLED = true; public static final boolean DEFAULT_GPS_ENABLED = true;
public static final GpsHudPosition DEFAULT_GPS_POSITION = GpsHudPosition.TOP_CENTER; public static final GpsHudPosition DEFAULT_GPS_POSITION = GpsHudPosition.TOP_CENTER;
public static final GpsHudPosition DEFAULT_NOTIFICATION_POSITION = GpsHudPosition.BOTTOM_RIGHT;
public static final boolean DEFAULT_TELEMETRY_ENABLED = true; public static final boolean DEFAULT_TELEMETRY_ENABLED = true;
public static final int DEFAULT_TELEMETRY_INTERVAL_SECONDS = 60; public static final int DEFAULT_TELEMETRY_INTERVAL_SECONDS = 60;
public static final boolean DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO = true; public static final boolean DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO = true;
@@ -21,6 +23,7 @@ public record ModConfig(String apiDomain, String apiToken, boolean allowBackend,
DEFAULT_SIGN_QUICK_PAY_ENABLED, DEFAULT_SIGN_QUICK_PAY_ENABLED,
DEFAULT_GPS_ENABLED, DEFAULT_GPS_ENABLED,
DEFAULT_GPS_POSITION, DEFAULT_GPS_POSITION,
DEFAULT_NOTIFICATION_POSITION,
DEFAULT_TELEMETRY_ENABLED, DEFAULT_TELEMETRY_ENABLED,
DEFAULT_TELEMETRY_INTERVAL_SECONDS, DEFAULT_TELEMETRY_INTERVAL_SECONDS,
DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO