diff --git a/Orangemart.cs b/Orangemart.cs index b0d5f1d..c958c5f 100644 --- a/Orangemart.cs +++ b/Orangemart.cs @@ -1,1365 +1,1401 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Newtonsoft.Json; -using Oxide.Core; -using Oxide.Core.Libraries.Covalence; -using Oxide.Core.Libraries; - -namespace Oxide.Plugins -{ - [Info("Orangemart", "saulteafarmer", "0.3.0")] - [Description("Allows players to buy and sell in-game units and VIP status using Bitcoin Lightning Network payments via LNbits")] - public class Orangemart : CovalencePlugin - { - // Configuration sections and keys - private static class ConfigSections - { - public const string Commands = "Commands"; - public const string CurrencySettings = "CurrencySettings"; - public const string Discord = "Discord"; - public const string InvoiceSettings = "InvoiceSettings"; - public const string VIPSettings = "VIPSettings"; - } - - private static class ConfigKeys - { - // Commands - public const string BuyCurrencyCommandName = "BuyCurrencyCommandName"; - public const string SendCurrencyCommandName = "SendCurrencyCommandName"; - public const string BuyVipCommandName = "BuyVipCommandName"; - - // CurrencySettings - public const string CurrencyItemID = "CurrencyItemID"; - public const string CurrencyName = "CurrencyName"; - public const string CurrencySkinID = "CurrencySkinID"; - public const string PricePerCurrencyUnit = "PricePerCurrencyUnit"; - public const string SatsPerCurrencyUnit = "SatsPerCurrencyUnit"; - - // Discord - public const string DiscordChannelName = "DiscordChannelName"; - public const string DiscordWebhookUrl = "DiscordWebhookUrl"; - - // InvoiceSettings - public const string BlacklistedDomains = "BlacklistedDomains"; - public const string WhitelistedDomains = "WhitelistedDomains"; - public const string CheckIntervalSeconds = "CheckIntervalSeconds"; - public const string InvoiceTimeoutSeconds = "InvoiceTimeoutSeconds"; - public const string LNbitsApiKey = "LNbitsApiKey"; - public const string LNbitsBaseUrl = "LNbitsBaseUrl"; - public const string MaxRetries = "MaxRetries"; - - // VIPSettings - public const string VipPermissionGroup = "VipPermissionGroup"; - public const string VipPrice = "VipPrice"; - } - - // Configuration variables - private int currencyItemID; - private string buyCurrencyCommandName; - private string sendCurrencyCommandName; - private string buyVipCommandName; - private int vipPrice; - private string vipPermissionGroup; - private string currencyName; - private int satsPerCurrencyUnit; - private int pricePerCurrencyUnit; - private string discordChannelName; - private ulong currencySkinID; - private int checkIntervalSeconds; - private int invoiceTimeoutSeconds; - private int maxRetries; - private List blacklistedDomains = new List(); - private List whitelistedDomains = new List(); - private const string SellLogFile = "Orangemart/send_bitcoin.json"; - private const string BuyInvoiceLogFile = "Orangemart/buy_invoices.json"; - private LNbitsConfig config; - private List pendingInvoices = new List(); - private Dictionary retryCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); - - // LNbits Configuration - private class LNbitsConfig - { - public string BaseUrl { get; set; } - public string ApiKey { get; set; } - public string DiscordWebhookUrl { get; set; } - - public static LNbitsConfig ParseLNbitsConnection(string baseUrl, string apiKey, string discordWebhookUrl) - { - var trimmedBaseUrl = baseUrl.TrimEnd('/'); - if (!Uri.IsWellFormedUriString(trimmedBaseUrl, UriKind.Absolute)) - throw new Exception("Invalid base URL in connection string."); - - return new LNbitsConfig - { - BaseUrl = trimmedBaseUrl, - ApiKey = apiKey, - DiscordWebhookUrl = discordWebhookUrl - }; - } - } - - // Invoice and Payment Classes - private class InvoiceResponse - { - [JsonProperty("payment_request")] - public string PaymentRequest { get; set; } - - [JsonProperty("payment_hash")] - public string PaymentHash { get; set; } - } - - private class SellInvoiceLogEntry - { - public string SteamID { get; set; } - public string LightningAddress { get; set; } - public bool Success { get; set; } - public int SatsAmount { get; set; } - public string PaymentHash { get; set; } - public bool CurrencyReturned { get; set; } - public DateTime Timestamp { get; set; } - public int RetryCount { get; set; } - } - - private class BuyInvoiceLogEntry - { - public string SteamID { get; set; } - public string InvoiceID { get; set; } - public bool IsPaid { get; set; } - public DateTime Timestamp { get; set; } - public int Amount { get; set; } - public bool CurrencyGiven { get; set; } - public bool VipGranted { get; set; } - public int RetryCount { get; set; } - } - - private class PendingInvoice - { - public string RHash { get; set; } - public IPlayer Player { get; set; } - public int Amount { get; set; } - public string Memo { get; set; } - public DateTime CreatedAt { get; set; } - public PurchaseType Type { get; set; } - } - - private enum PurchaseType - { - Currency, - Vip, - SendBitcoin - } - - private class PaymentStatusResponse - { - [JsonProperty("paid")] - public bool Paid { get; set; } - - [JsonProperty("preimage")] - public string Preimage { get; set; } - } - - protected override void LoadConfig() - { - base.LoadConfig(); - try - { - bool configChanged = false; - - // Parse LNbits connection settings - config = LNbitsConfig.ParseLNbitsConnection( - GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.LNbitsBaseUrl, "https://your-lnbits-instance.com", ref configChanged), - GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.LNbitsApiKey, "your-lnbits-admin-api-key", ref configChanged), - GetConfigValue(ConfigSections.Discord, ConfigKeys.DiscordWebhookUrl, "https://discord.com/api/webhooks/your_webhook_url", ref configChanged) - ); - - // Parse Currency Settings - currencyItemID = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.CurrencyItemID, 1776460938, ref configChanged); - currencyName = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.CurrencyName, "blood", ref configChanged); - satsPerCurrencyUnit = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.SatsPerCurrencyUnit, 1, ref configChanged); - pricePerCurrencyUnit = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.PricePerCurrencyUnit, 1, ref configChanged); - currencySkinID = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.CurrencySkinID, 0UL, ref configChanged); - - // Parse Command Names - buyCurrencyCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.BuyCurrencyCommandName, "buyblood", ref configChanged); - sendCurrencyCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.SendCurrencyCommandName, "sendblood", ref configChanged); - buyVipCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.BuyVipCommandName, "buyvip", ref configChanged); - - // Parse VIP Settings - vipPrice = GetConfigValue(ConfigSections.VIPSettings, ConfigKeys.VipPrice, 1000, ref configChanged); - vipPermissionGroup = GetConfigValue(ConfigSections.VIPSettings, ConfigKeys.VipPermissionGroup, "vip", ref configChanged); - - // Parse Discord Settings - discordChannelName = GetConfigValue(ConfigSections.Discord, ConfigKeys.DiscordChannelName, "mart", ref configChanged); - - // Parse Invoice Settings - checkIntervalSeconds = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.CheckIntervalSeconds, 10, ref configChanged); - invoiceTimeoutSeconds = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.InvoiceTimeoutSeconds, 300, ref configChanged); - maxRetries = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.MaxRetries, 25, ref configChanged); - - blacklistedDomains = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.BlacklistedDomains, new List { "example.com", "blacklisted.net" }, ref configChanged) - .Select(d => d.ToLower()).ToList(); - - whitelistedDomains = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.WhitelistedDomains, new List(), ref configChanged) - .Select(d => d.ToLower()).ToList(); - - MigrateConfig(); - - if (configChanged) - { - SaveConfig(); - } - } - catch (Exception ex) - { - PrintError($"Failed to load configuration: {ex.Message}"); - } - } - - private void MigrateConfig() - { - bool configChanged = false; - - if (!(Config[ConfigSections.InvoiceSettings] is Dictionary invoiceSettings)) - { - invoiceSettings = new Dictionary(); - Config[ConfigSections.InvoiceSettings] = invoiceSettings; - configChanged = true; - } - - if (!invoiceSettings.ContainsKey(ConfigKeys.WhitelistedDomains)) - { - invoiceSettings[ConfigKeys.WhitelistedDomains] = new List(); - configChanged = true; - } - - if (configChanged) - { - SaveConfig(); - } - } - - private T GetConfigValue(string section, string key, T defaultValue, ref bool configChanged) - { - if (!(Config[section] is Dictionary data)) - { - data = new Dictionary(); - Config[section] = data; - configChanged = true; - } - - if (!data.TryGetValue(key, out var value)) - { - value = defaultValue; - data[key] = value; - configChanged = true; - } - - try - { - if (value is T tValue) - { - return tValue; - } - else if (typeof(T) == typeof(List)) - { - if (value is IEnumerable enumerable) - { - return (T)(object)enumerable.Select(item => item.ToString()).ToList(); - } - else if (value is string singleString) - { - return (T)(object)new List { singleString }; - } - else - { - PrintError($"Unexpected type for [{section}][{key}]. Using default value."); - data[key] = defaultValue; - configChanged = true; - return defaultValue; - } - } - else if (typeof(T) == typeof(ulong)) - { - if (value is long longVal) - { - return (T)(object)(ulong)longVal; - } - else if (value is ulong ulongVal) - { - return (T)(object)ulongVal; - } - else - { - return (T)Convert.ChangeType(value, typeof(T)); - } - } - else - { - return (T)Convert.ChangeType(value, typeof(T)); - } - } - catch (Exception ex) - { - PrintError($"Error converting config value for [{section}][{key}]: {ex.Message}. Using default value."); - data[key] = defaultValue; - configChanged = true; - return defaultValue; - } - } - - protected override void LoadDefaultConfig() - { - Config[ConfigSections.Commands] = new Dictionary - { - [ConfigKeys.BuyCurrencyCommandName] = "buyblood", - [ConfigKeys.BuyVipCommandName] = "buyvip", - [ConfigKeys.SendCurrencyCommandName] = "sendblood" - }; - - Config[ConfigSections.CurrencySettings] = new Dictionary - { - [ConfigKeys.CurrencyItemID] = 1776460938, - [ConfigKeys.CurrencyName] = "blood", - [ConfigKeys.CurrencySkinID] = 0UL, - [ConfigKeys.PricePerCurrencyUnit] = 1, - [ConfigKeys.SatsPerCurrencyUnit] = 1 - }; - - Config[ConfigSections.Discord] = new Dictionary - { - [ConfigKeys.DiscordChannelName] = "mart", - [ConfigKeys.DiscordWebhookUrl] = "https://discord.com/api/webhooks/your_webhook_url" - }; - - Config[ConfigSections.InvoiceSettings] = new Dictionary - { - [ConfigKeys.BlacklistedDomains] = new List { "example.com", "blacklisted.net" }, - [ConfigKeys.WhitelistedDomains] = new List(), - [ConfigKeys.CheckIntervalSeconds] = 10, - [ConfigKeys.InvoiceTimeoutSeconds] = 300, - [ConfigKeys.LNbitsApiKey] = "your-lnbits-admin-api-key", - [ConfigKeys.LNbitsBaseUrl] = "https://your-lnbits-instance.com", - [ConfigKeys.MaxRetries] = 25 - }; - - Config[ConfigSections.VIPSettings] = new Dictionary - { - [ConfigKeys.VipPermissionGroup] = "vip", - [ConfigKeys.VipPrice] = 1000 - }; - } - - private void Init() - { - // Register permissions - permission.RegisterPermission("orangemart.buycurrency", this); - permission.RegisterPermission("orangemart.sendcurrency", this); - permission.RegisterPermission("orangemart.buyvip", this); - } - - private void OnServerInitialized() - { - if (config == null) - { - PrintError("Plugin configuration is not properly set up. Please check your configuration file."); - return; - } - - // Register commands - AddCovalenceCommand(buyCurrencyCommandName, nameof(CmdBuyCurrency), "orangemart.buycurrency"); - AddCovalenceCommand(sendCurrencyCommandName, nameof(CmdSendCurrency), "orangemart.sendcurrency"); - AddCovalenceCommand(buyVipCommandName, nameof(CmdBuyVip), "orangemart.buyvip"); - - // Start a timer to check pending invoices periodically - timer.Every(checkIntervalSeconds, CheckPendingInvoices); - } - - private void Unload() - { - pendingInvoices.Clear(); - retryCounts.Clear(); - } - - protected override void LoadDefaultMessages() - { - lang.RegisterMessages(new Dictionary - { - ["UsageSendCurrency"] = "Usage: /{0} ", - ["NeedMoreCurrency"] = "You need more {0}. You currently have {1}.", - ["FailedToReserveCurrency"] = "Failed to reserve currency. Please try again.", - ["FailedToQueryLightningAddress"] = "Failed to query Lightning address for an invoice.", - ["FailedToAuthenticate"] = "Failed to authenticate with LNbits.", - ["InvoiceCreatedCheckDiscord"] = "Invoice created! Please check the #{0} channel on Discord to complete your payment.", - ["FailedToCreateInvoice"] = "Failed to create an invoice. Please try again later.", - ["FailedToProcessPayment"] = "Failed to process payment. Please try again later.", - ["CurrencySentSuccess"] = "You have successfully sent {0} {1}!", - ["PurchaseSuccess"] = "You have successfully purchased {0} {1}!", - ["PurchaseVipSuccess"] = "You have successfully purchased VIP status!", - ["InvalidCommandUsage"] = "Usage: /{0} ", - ["NoPermission"] = "You do not have permission to use this command.", - ["FailedToFindBasePlayer"] = "Failed to find base player object for player {0}.", - ["FailedToCreateCurrencyItem"] = "Failed to create {0} item for player {1}.", - ["AddedToVipGroup"] = "Player {0} added to VIP group '{1}'.", - ["InvoiceExpired"] = "Your invoice for {0} sats has expired. Please try again.", - ["BlacklistedDomain"] = "The domain '{0}' is currently blacklisted. Please use a different Lightning address.", - ["NotWhitelistedDomain"] = "The domain '{0}' is not whitelisted. Please use a Lightning address from the following domains: {1}.", - ["InvalidLightningAddress"] = "The Lightning Address provided is invalid or cannot be resolved.", - ["PaymentProcessing"] = "Your payment is being processed. You will receive a confirmation once it's complete." - }, this); - } - - private string Lang(string key, string userId = null, params object[] args) - { - return string.Format(lang.GetMessage(key, this, userId), args); - } - - private List GetAllInventoryItems(BasePlayer player) - { - List allItems = new List(); - - // Main Inventory - if (player.inventory.containerMain != null) - allItems.AddRange(player.inventory.containerMain.itemList); - - // Belt (Hotbar) - if (player.inventory.containerBelt != null) - allItems.AddRange(player.inventory.containerBelt.itemList); - - // Wear (Clothing) - if (player.inventory.containerWear != null) - allItems.AddRange(player.inventory.containerWear.itemList); - - return allItems; - } - - private void CheckPendingInvoices() - { - foreach (var invoice in pendingInvoices.ToList()) - { - string localPaymentHash = invoice.RHash.ToLower(); - CheckInvoicePaid(localPaymentHash, isPaid => - { - if (isPaid) - { - pendingInvoices.Remove(invoice); - - switch (invoice.Type) - { - case PurchaseType.Currency: - RewardPlayer(invoice.Player, invoice.Amount); - break; - case PurchaseType.Vip: - GrantVip(invoice.Player); - break; - case PurchaseType.SendBitcoin: - invoice.Player.Reply(Lang("CurrencySentSuccess", invoice.Player.Id, invoice.Amount, currencyName)); - break; - } - - if (invoice.Type == PurchaseType.SendBitcoin) - { - var logEntry = new SellInvoiceLogEntry - { - SteamID = GetPlayerId(invoice.Player), - LightningAddress = ExtractLightningAddress(invoice.Memo), - Success = true, - SatsAmount = invoice.Amount, - PaymentHash = invoice.RHash, - CurrencyReturned = false, - Timestamp = DateTime.UtcNow, - RetryCount = retryCounts.ContainsKey(invoice.RHash) ? retryCounts[invoice.RHash] : 0 - }; - LogSellTransaction(logEntry); - - Puts($"Invoice {invoice.RHash} marked as paid. RetryCount: {logEntry.RetryCount}"); - } - else - { - var logEntry = CreateBuyInvoiceLogEntry( - player: invoice.Player, - invoiceID: invoice.RHash, - isPaid: true, - amount: invoice.Type == PurchaseType.SendBitcoin ? invoice.Amount : invoice.Amount * pricePerCurrencyUnit, - type: invoice.Type, - retryCount: retryCounts.ContainsKey(invoice.RHash) ? retryCounts[invoice.RHash] : 0 - ); - LogBuyInvoice(logEntry); - Puts($"Invoice {invoice.RHash} marked as paid. RetryCount: {logEntry.RetryCount}"); - } - - retryCounts.Remove(invoice.RHash); - } - else - { - if (!retryCounts.ContainsKey(localPaymentHash)) - { - retryCounts[localPaymentHash] = 0; - Puts($"Initialized retry count for paymentHash: {localPaymentHash}"); - } - - retryCounts[localPaymentHash]++; - Puts($"Retry count for paymentHash {localPaymentHash}: {retryCounts[localPaymentHash]} of {maxRetries}"); - - if (retryCounts[localPaymentHash] >= maxRetries) - { - pendingInvoices.Remove(invoice); - int finalRetryCount = retryCounts[localPaymentHash]; - retryCounts.Remove(localPaymentHash); - PrintWarning($"Invoice for player {GetPlayerId(invoice.Player)} expired (amount: {invoice.Amount} sats)."); - - invoice.Player.Reply(Lang("InvoiceExpired", invoice.Player.Id, invoice.Amount)); - - if (invoice.Type == PurchaseType.SendBitcoin) - { - var basePlayer = invoice.Player.Object as BasePlayer; - if (basePlayer != null) - { - ReturnCurrency(basePlayer, invoice.Amount / satsPerCurrencyUnit); - Puts($"Refunded {invoice.Amount / satsPerCurrencyUnit} {currencyName} to player {basePlayer.UserIDString} due to failed payment."); - } - else - { - PrintError($"Failed to find base player object for player {invoice.Player.Id} to refund currency."); - } - - var failedLogEntry = new SellInvoiceLogEntry - { - SteamID = GetPlayerId(invoice.Player), - LightningAddress = ExtractLightningAddress(invoice.Memo), - Success = false, - SatsAmount = invoice.Amount, - PaymentHash = invoice.RHash, - CurrencyReturned = true, - Timestamp = DateTime.UtcNow, - RetryCount = finalRetryCount - }; - LogSellTransaction(failedLogEntry); - Puts($"Invoice {localPaymentHash} expired after {finalRetryCount} retries."); - } - else - { - var failedLogEntry = CreateBuyInvoiceLogEntry( - player: invoice.Player, - invoiceID: localPaymentHash, - isPaid: false, - amount: invoice.Type == PurchaseType.SendBitcoin ? invoice.Amount : invoice.Amount * pricePerCurrencyUnit, - type: invoice.Type, - retryCount: finalRetryCount - ); - LogBuyInvoice(failedLogEntry); - Puts($"Invoice {localPaymentHash} expired after {finalRetryCount} retries."); - } - } - else - { - PrintWarning($"Retrying invoice {localPaymentHash}. Attempt {retryCounts[localPaymentHash]} of {maxRetries}."); - } - } - }); - } - } - - private void CheckInvoicePaid(string paymentHash, Action callback) - { - string normalizedPaymentHash = paymentHash.ToLower(); - string url = $"{config.BaseUrl}/api/v1/payments/{normalizedPaymentHash}"; - - var headers = new Dictionary - { - { "Content-Type", "application/json" }, - { "X-Api-Key", config.ApiKey } - }; - - MakeWebRequest(url, null, (code, response) => - { - if (code != 200 || string.IsNullOrEmpty(response)) - { - PrintError($"Error checking invoice status: HTTP {code}"); - callback(false); - return; - } - - try - { - var paymentStatus = JsonConvert.DeserializeObject(response); - callback(paymentStatus != null && paymentStatus.Paid); - } - catch (Exception ex) - { - PrintError($"Failed to parse invoice status response: {ex.Message}"); - callback(false); - } - }, RequestMethod.GET, headers); - } - - private void CmdSendCurrency(IPlayer player, string command, string[] args) - { - if (!player.HasPermission("orangemart.sendcurrency")) - { - player.Reply(Lang("NoPermission", player.Id)); - return; - } - - if (args.Length != 2 || !int.TryParse(args[0], out int amount) || amount <= 0) - { - player.Reply(Lang("UsageSendCurrency", player.Id, sendCurrencyCommandName)); - return; - } - - string lightningAddress = args[1]; - - if (!IsLightningAddressAllowed(lightningAddress)) - { - string domain = GetDomainFromLightningAddress(lightningAddress); - if (whitelistedDomains.Any()) - { - string whitelist = string.Join(", ", whitelistedDomains); - player.Reply(Lang("NotWhitelistedDomain", player.Id, domain, whitelist)); - } - else - { - player.Reply(Lang("BlacklistedDomain", player.Id, domain)); - } - return; - } - - var basePlayer = player.Object as BasePlayer; - if (basePlayer == null) - { - player.Reply(Lang("FailedToFindBasePlayer", player.Id)); - return; - } - - int currencyAmount = GetAllInventoryItems(basePlayer).Where(IsCurrencyItem).Sum(item => item.amount); - - if (currencyAmount < amount) - { - player.Reply(Lang("NeedMoreCurrency", player.Id, currencyName, currencyAmount)); - return; - } - - if (!TryReserveCurrency(basePlayer, amount)) - { - player.Reply(Lang("FailedToReserveCurrency", player.Id)); - return; - } - - player.Reply(Lang("PaymentProcessing", player.Id)); - - SendBitcoin(lightningAddress, amount * satsPerCurrencyUnit, (success, paymentHash) => - { - if (success && !string.IsNullOrEmpty(paymentHash)) - { - LogSellTransaction( - new SellInvoiceLogEntry - { - SteamID = GetPlayerId(player), - LightningAddress = lightningAddress, - Success = true, - SatsAmount = amount * satsPerCurrencyUnit, - PaymentHash = paymentHash, - CurrencyReturned = false, - Timestamp = DateTime.UtcNow, - RetryCount = 0 - } - ); - - var pendingInvoice = new PendingInvoice - { - RHash = paymentHash.ToLower(), - Player = player, - Amount = amount * satsPerCurrencyUnit, - Memo = $"Sending {amount} {currencyName} to {lightningAddress}", - CreatedAt = DateTime.UtcNow, - Type = PurchaseType.SendBitcoin - }; - pendingInvoices.Add(pendingInvoice); - - Puts($"Outbound payment to {lightningAddress} initiated. PaymentHash: {paymentHash}"); - } - else - { - player.Reply(Lang("FailedToProcessPayment", player.Id)); - - LogSellTransaction( - new SellInvoiceLogEntry - { - SteamID = GetPlayerId(player), - LightningAddress = lightningAddress, - Success = false, - SatsAmount = amount * satsPerCurrencyUnit, - PaymentHash = null, - CurrencyReturned = true, - Timestamp = DateTime.UtcNow, - RetryCount = 0 - } - ); - - Puts($"Outbound payment to {lightningAddress} failed to initiate."); - - ReturnCurrency(basePlayer, amount); - Puts($"Returned {amount} {currencyName} to player {basePlayer.UserIDString} due to failed payment."); - } - }); - } - - private bool IsLightningAddressAllowed(string lightningAddress) - { - string domain = GetDomainFromLightningAddress(lightningAddress); - if (string.IsNullOrEmpty(domain)) - return false; - - if (whitelistedDomains.Any()) - { - return whitelistedDomains.Contains(domain.ToLower()); - } - else - { - return !blacklistedDomains.Contains(domain.ToLower()); - } - } - - private string GetDomainFromLightningAddress(string lightningAddress) - { - if (string.IsNullOrEmpty(lightningAddress)) - return null; - - var parts = lightningAddress.Split('@'); - return parts.Length == 2 ? parts[1].ToLower() : null; - } - - private void SendBitcoin(string lightningAddress, int satsAmount, Action callback) - { - ResolveLightningAddress(lightningAddress, satsAmount, bolt11 => - { - if (string.IsNullOrEmpty(bolt11)) - { - PrintError($"Failed to resolve Lightning Address: {lightningAddress}"); - callback(false, null); - return; - } - - SendPayment(bolt11, satsAmount, (success, paymentHash) => - { - if (success && !string.IsNullOrEmpty(paymentHash)) - { - StartPaymentStatusCheck(paymentHash, isPaid => - { - callback(isPaid, isPaid ? paymentHash : null); - }); - } - else - { - callback(false, null); - } - }); - }); - } - - private void StartPaymentStatusCheck(string paymentHash, Action callback) - { - if (!retryCounts.ContainsKey(paymentHash)) - { - retryCounts[paymentHash] = 0; - } - - Timer timerInstance = null; - timerInstance = timer.Repeat(checkIntervalSeconds, maxRetries, () => - { - CheckInvoicePaid(paymentHash, isPaid => - { - if (isPaid) - { - callback(true); - timerInstance.Destroy(); - } - else - { - retryCounts[paymentHash]++; - Puts($"PaymentHash {paymentHash} not yet paid. Retry {retryCounts[paymentHash]} of {maxRetries}."); - - if (retryCounts[paymentHash] >= maxRetries) - { - callback(false); - timerInstance.Destroy(); - } - } - }); - }); - } - - private void CmdBuyCurrency(IPlayer player, string command, string[] args) - { - if (!player.HasPermission("orangemart.buycurrency")) - { - player.Reply(Lang("NoPermission", player.Id)); - return; - } - - if (args.Length != 1 || !int.TryParse(args[0], out int amount) || amount <= 0) - { - player.Reply(Lang("InvalidCommandUsage", player.Id, buyCurrencyCommandName)); - return; - } - - int amountSats = amount * pricePerCurrencyUnit; - - CreateInvoice(amountSats, $"Buying {amount} {currencyName}", invoiceResponse => - { - if (invoiceResponse != null) - { - SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, $"Buying {amount} {currencyName}"); - - player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, discordChannelName)); - - var pendingInvoice = new PendingInvoice - { - RHash = invoiceResponse.PaymentHash.ToLower(), - Player = player, - Amount = amount, - Memo = $"Buying {amount} {currencyName}", - CreatedAt = DateTime.UtcNow, - Type = PurchaseType.Currency - }; - pendingInvoices.Add(pendingInvoice); - - ScheduleInvoiceExpiry(pendingInvoice); - } - else - { - player.Reply(Lang("FailedToCreateInvoice", player.Id)); - } - }); - } - - private void CmdBuyVip(IPlayer player, string command, string[] args) - { - if (!player.HasPermission("orangemart.buyvip")) - { - player.Reply(Lang("NoPermission", player.Id)); - return; - } - - int amountSats = vipPrice; - - CreateInvoice(amountSats, "Buying VIP Status", invoiceResponse => - { - if (invoiceResponse != null) - { - SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, "Buying VIP Status"); - - player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, discordChannelName)); - - var pendingInvoice = new PendingInvoice - { - RHash = invoiceResponse.PaymentHash.ToLower(), - Player = player, - Amount = amountSats, - Memo = "Buying VIP Status", - CreatedAt = DateTime.UtcNow, - Type = PurchaseType.Vip - }; - pendingInvoices.Add(pendingInvoice); - - ScheduleInvoiceExpiry(pendingInvoice); - } - else - { - player.Reply(Lang("FailedToCreateInvoice", player.Id)); - } - }); - } - - private void ScheduleInvoiceExpiry(PendingInvoice pendingInvoice) - { - timer.Once(invoiceTimeoutSeconds, () => - { - if (pendingInvoices.Contains(pendingInvoice)) - { - pendingInvoices.Remove(pendingInvoice); - PrintWarning($"Invoice for player {GetPlayerId(pendingInvoice.Player)} expired (amount: {pendingInvoice.Amount} sats)."); - - int finalRetryCount = retryCounts.ContainsKey(pendingInvoice.RHash) ? retryCounts[pendingInvoice.RHash] : 0; - - if (pendingInvoice.Type == PurchaseType.SendBitcoin) - { - var basePlayer = pendingInvoice.Player.Object as BasePlayer; - if (basePlayer != null) - { - ReturnCurrency(basePlayer, pendingInvoice.Amount / satsPerCurrencyUnit); - Puts($"Refunded {pendingInvoice.Amount / satsPerCurrencyUnit} {currencyName} to player {basePlayer.UserIDString} due to failed payment."); - } - else - { - PrintError($"Failed to find base player object for player {pendingInvoice.Player.Id} to refund currency."); - } - - var logEntry = new SellInvoiceLogEntry - { - SteamID = GetPlayerId(pendingInvoice.Player), - LightningAddress = ExtractLightningAddress(pendingInvoice.Memo), - Success = false, - SatsAmount = pendingInvoice.Amount, - PaymentHash = pendingInvoice.RHash, - CurrencyReturned = true, - Timestamp = DateTime.UtcNow, - RetryCount = finalRetryCount - }; - LogSellTransaction(logEntry); - Puts($"Invoice {pendingInvoice.RHash} for player {GetPlayerId(pendingInvoice.Player)} expired and logged."); - } - else - { - var logEntry = CreateBuyInvoiceLogEntry( - player: pendingInvoice.Player, - invoiceID: pendingInvoice.RHash, - isPaid: false, - amount: pendingInvoice.Type == PurchaseType.SendBitcoin ? pendingInvoice.Amount : pendingInvoice.Amount * pricePerCurrencyUnit, - type: pendingInvoice.Type, - retryCount: finalRetryCount - ); - LogBuyInvoice(logEntry); - Puts($"Invoice {pendingInvoice.RHash} for player {GetPlayerId(pendingInvoice.Player)} expired and logged."); - } - } - }); - } - - private void SendPayment(string bolt11, int satsAmount, Action callback) - { - string url = $"{config.BaseUrl}/api/v1/payments"; - var requestBody = new - { - @out = true, - bolt11 = bolt11, - amount = satsAmount - }; - string jsonBody = JsonConvert.SerializeObject(requestBody); - - var headers = new Dictionary - { - { "X-Api-Key", config.ApiKey }, - { "Content-Type", "application/json" } - }; - - MakeWebRequest(url, jsonBody, (code, response) => - { - if (code != 200 && code != 201) - { - PrintError($"Error processing payment: HTTP {code}"); - callback(false, null); - return; - } - - try - { - var paymentResponse = JsonConvert.DeserializeObject>(response); - string paymentHash = paymentResponse.ContainsKey("payment_hash") ? paymentResponse["payment_hash"].ToString() : null; - - if (!string.IsNullOrEmpty(paymentHash)) - { - callback(true, paymentHash); - } - else - { - PrintError("Payment hash (rhash) is missing or invalid in the response."); - callback(false, null); - } - } - catch (Exception ex) - { - PrintError($"Exception occurred while parsing payment response: {ex.Message}"); - callback(false, null); - } - }, RequestMethod.POST, headers); - } - - private void SendInvoiceToDiscord(IPlayer player, string invoice, int amountSats, string memo) - { - if (string.IsNullOrEmpty(config.DiscordWebhookUrl)) - { - PrintError("Discord webhook URL is not configured."); - return; - } - - string qrCodeUrl = $"https://api.qrserver.com/v1/create-qr-code/?data={Uri.EscapeDataString(invoice)}&size=200x200"; - - var webhookPayload = new - { - content = $"**{player.Name}**, please pay **{amountSats} sats** using the Lightning Network.", - embeds = new[] - { - new - { - title = "Payment Invoice", - description = $"{memo}\n\nPlease pay the following Lightning invoice to complete your purchase:\n\n```\n{invoice}\n```", - image = new - { - url = qrCodeUrl - }, - fields = new[] - { - new { name = "Amount", value = $"{amountSats} sats", inline = true }, - new { name = "Steam ID", value = GetPlayerId(player), inline = true } - } - } - } - }; - - string jsonPayload = JsonConvert.SerializeObject(webhookPayload); - - MakeWebRequest(config.DiscordWebhookUrl, jsonPayload, (code, response) => - { - if (code != 204) - { - PrintError($"Failed to send invoice to Discord webhook: HTTP {code}"); - } - else - { - Puts($"Invoice sent to Discord for player {GetPlayerId(player)}."); - } - }, RequestMethod.POST, new Dictionary { { "Content-Type", "application/json" } }); - } - - private void RewardPlayer(IPlayer player, int amount) - { - player.Reply($"You have successfully purchased {amount} {currencyName}!"); - - var basePlayer = player.Object as BasePlayer; - - if (basePlayer != null) - { - var currencyItem = ItemManager.CreateByItemID(currencyItemID, amount); - if (currencyItem != null) - { - if (currencySkinID > 0) - { - currencyItem.skin = currencySkinID; - } - - basePlayer.GiveItem(currencyItem); - Puts($"Gave {amount} {currencyName} (skinID: {currencySkinID}) to player {basePlayer.UserIDString}."); - } - else - { - PrintError($"Failed to create {currencyName} item for player {basePlayer.UserIDString}."); - } - } - else - { - PrintError($"Failed to find base player object for player {player.Id}."); - } - } - - private void GrantVip(IPlayer player) - { - player.Reply("You have successfully purchased VIP status!"); - - permission.AddUserGroup(player.Id, vipPermissionGroup); - - Puts($"Player {GetPlayerId(player)} added to VIP group '{vipPermissionGroup}'."); - } - - private bool TryReserveCurrency(BasePlayer player, int amount) - { - var items = GetAllInventoryItems(player).Where(IsCurrencyItem).ToList(); - int totalCurrency = items.Sum(item => item.amount); - - if (totalCurrency < amount) - { - return false; - } - - int remaining = amount; - - foreach (var item in items) - { - if (item.amount > remaining) - { - item.UseItem(remaining); - break; - } - else - { - remaining -= item.amount; - item.Remove(); - } - - if (remaining <= 0) - { - break; - } - } - - return true; - } - - private void ReturnCurrency(BasePlayer player, int amount) - { - var returnedCurrency = ItemManager.CreateByItemID(currencyItemID, amount); - if (returnedCurrency != null) - { - if (currencySkinID > 0) - { - returnedCurrency.skin = currencySkinID; - } - returnedCurrency.MoveToContainer(player.inventory.containerMain); - } - else - { - PrintError($"Failed to create {currencyName} item to return to player {player.UserIDString}."); - } - } - - private bool IsCurrencyItem(Item item) - { - return item.info.itemid == currencyItemID && (currencySkinID == 0 || item.skin == currencySkinID); - } - - private void LogSellTransaction(SellInvoiceLogEntry logEntry) - { - var logs = LoadSellLogData(); - logs.Add(logEntry); - SaveSellLogData(logs); - Puts($"[Orangemart] Logged sell transaction: {JsonConvert.SerializeObject(logEntry)}"); - } - - private List LoadSellLogData() - { - var path = Path.Combine(Interface.Oxide.DataDirectory, SellLogFile); - return File.Exists(path) - ? JsonConvert.DeserializeObject>(File.ReadAllText(path)) - : new List(); - } - - private void SaveSellLogData(List data) - { - var path = Path.Combine(Interface.Oxide.DataDirectory, SellLogFile); - var directory = Path.GetDirectoryName(path); - if (!Directory.Exists(directory)) - Directory.CreateDirectory(directory); - - File.WriteAllText(path, JsonConvert.SerializeObject(data, Formatting.Indented)); - } - - private void LogBuyInvoice(BuyInvoiceLogEntry logEntry) - { - var logPath = Path.Combine(Interface.Oxide.DataDirectory, BuyInvoiceLogFile); - var directory = Path.GetDirectoryName(logPath); - if (!Directory.Exists(directory)) - Directory.CreateDirectory(directory); - - List invoiceLogs = File.Exists(logPath) - ? JsonConvert.DeserializeObject>(File.ReadAllText(logPath)) ?? new List() - : new List(); - - invoiceLogs.Add(logEntry); - File.WriteAllText(logPath, JsonConvert.SerializeObject(invoiceLogs, Formatting.Indented)); - Puts($"[Orangemart] Logged buy invoice: {JsonConvert.SerializeObject(logEntry)}"); - } - - private BuyInvoiceLogEntry CreateBuyInvoiceLogEntry(IPlayer player, string invoiceID, bool isPaid, int amount, PurchaseType type, int retryCount) - { - return new BuyInvoiceLogEntry - { - SteamID = GetPlayerId(player), - InvoiceID = invoiceID, - IsPaid = isPaid, - Timestamp = DateTime.UtcNow, - Amount = type == PurchaseType.SendBitcoin ? amount : amount * pricePerCurrencyUnit, - CurrencyGiven = isPaid && type == PurchaseType.Currency, - VipGranted = isPaid && type == PurchaseType.Vip, - RetryCount = retryCount - }; - } - - private void CreateInvoice(int amountSats, string memo, Action callback) - { - string url = $"{config.BaseUrl}/api/v1/payments"; - - var requestBody = new - { - @out = false, - amount = amountSats, - memo = memo - }; - string jsonBody = JsonConvert.SerializeObject(requestBody); - - var headers = new Dictionary - { - { "X-Api-Key", config.ApiKey }, - { "Content-Type", "application/json" } - }; - - MakeWebRequest(url, jsonBody, (code, response) => - { - if (code != 200 && code != 201) - { - PrintError($"Error creating invoice: HTTP {code}"); - callback(null); - return; - } - - try - { - var invoiceResponse = JsonConvert.DeserializeObject(response); - callback(invoiceResponse != null && !string.IsNullOrEmpty(invoiceResponse.PaymentHash) ? invoiceResponse : null); - } - catch (Exception ex) - { - PrintError($"Failed to deserialize invoice response: {ex.Message}"); - callback(null); - } - }, RequestMethod.POST, headers); - } - - private string GetPlayerId(IPlayer player) - { - var basePlayer = player.Object as BasePlayer; - return basePlayer != null ? basePlayer.UserIDString : player.Id; - } - - private void MakeWebRequest(string url, string jsonData, Action callback, RequestMethod method = RequestMethod.GET, Dictionary headers = null) - { - webrequest.Enqueue(url, jsonData, (code, response) => - { - if (string.IsNullOrEmpty(response) && (code < 200 || code >= 300)) - { - PrintError($"Web request to {url} returned empty response or HTTP {code}"); - callback(code, null); - } - else - { - callback(code, response); - } - }, this, method, headers ?? new Dictionary { { "Content-Type", "application/json" } }); - } - - private void ResolveLightningAddress(string lightningAddress, int amountSats, Action callback) - { - var parts = lightningAddress.Split('@'); - if (parts.Length != 2) - { - PrintError($"Invalid Lightning Address format: {lightningAddress}"); - callback(null); - return; - } - - string user = parts[0]; - string domain = parts[1]; - - string lnurlEndpoint = $"https://{domain}/.well-known/lnurlp/{user}"; - - var headers = new Dictionary - { - { "Content-Type", "application/json" } - }; - - MakeWebRequest(lnurlEndpoint, null, (code, response) => - { - if (code != 200 || string.IsNullOrEmpty(response)) - { - PrintError($"Failed to fetch LNURL for {lightningAddress}: HTTP {code}"); - callback(null); - return; - } - - try - { - var lnurlResponse = JsonConvert.DeserializeObject(response); - if (lnurlResponse == null || string.IsNullOrEmpty(lnurlResponse.Callback)) - { - PrintError($"Invalid LNURL response for {lightningAddress}"); - callback(null); - return; - } - - long amountMsat = (long)amountSats * 1000; - - string callbackUrl = lnurlResponse.Callback; - - string callbackUrlWithAmount = $"{callbackUrl}?amount={amountMsat}"; - - MakeWebRequest(callbackUrlWithAmount, null, (payCode, payResponse) => - { - if (payCode != 200 || string.IsNullOrEmpty(payResponse)) - { - PrintError($"Failed to perform LNURL Pay for {lightningAddress}: HTTP {payCode}"); - callback(null); - return; - } - - try - { - var payAction = JsonConvert.DeserializeObject(payResponse); - if (payAction == null || string.IsNullOrEmpty(payAction.Pr)) - { - PrintError($"Invalid LNURL Pay response for {lightningAddress}"); - callback(null); - return; - } - - callback(payAction.Pr); - } - catch (Exception ex) - { - PrintError($"Error parsing LNURL Pay response: {ex.Message}"); - callback(null); - } - }, RequestMethod.GET, headers); - } - catch (Exception ex) - { - PrintError($"Error parsing LNURL response: {ex.Message}"); - callback(null); - } - }, RequestMethod.GET, headers); - } - - private class LNURLResponse - { - [JsonProperty("tag")] - public string Tag { get; set; } - - [JsonProperty("callback")] - public string Callback { get; set; } - - [JsonProperty("minSendable")] - public long MinSendable { get; set; } - - [JsonProperty("maxSendable")] - public long MaxSendable { get; set; } - - [JsonProperty("metadata")] - public string Metadata { get; set; } - - [JsonProperty("commentAllowed")] - public int CommentAllowed { get; set; } - - [JsonProperty("allowsNostr")] - public bool AllowsNostr { get; set; } - - [JsonProperty("nostrPubkey")] - public string NostrPubkey { get; set; } - } - - private class LNURLPayResponse - { - [JsonProperty("pr")] - public string Pr { get; set; } - - [JsonProperty("routes")] - public List Routes { get; set; } - } - - private string ExtractLightningAddress(string memo) - { - // Extract the Lightning Address from the memo - // Expected format: "Sending {amount} {currency} to {lightning_address}" - var parts = memo.Split(" to "); - return parts.Length == 2 ? parts[1] : "unknown@unknown.com"; - } - } -} +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Oxide.Core; +using Oxide.Core.Libraries.Covalence; +using Oxide.Core.Libraries; + +namespace Oxide.Plugins +{ + [Info("Orangemart", "RustySats", "0.3.0")] + [Description("Allows players to buy and sell in-game units and VIP status using Bitcoin Lightning Network payments via LNbits")] + public class Orangemart : CovalencePlugin + { + // Configuration sections and keys + private static class ConfigSections + { + public const string Commands = "Commands"; + public const string CurrencySettings = "CurrencySettings"; + public const string Discord = "Discord"; + public const string InvoiceSettings = "InvoiceSettings"; + public const string VIPSettings = "VIPSettings"; + } + + private static class ConfigKeys + { + // Commands + public const string BuyCurrencyCommandName = "BuyCurrencyCommandName"; + public const string SendCurrencyCommandName = "SendCurrencyCommandName"; + public const string BuyVipCommandName = "BuyVipCommandName"; + + // CurrencySettings + public const string CurrencyItemID = "CurrencyItemID"; + public const string CurrencyName = "CurrencyName"; + public const string CurrencySkinID = "CurrencySkinID"; + public const string PricePerCurrencyUnit = "PricePerCurrencyUnit"; + public const string SatsPerCurrencyUnit = "SatsPerCurrencyUnit"; + + // Discord + public const string DiscordChannelName = "DiscordChannelName"; + public const string DiscordWebhookUrl = "DiscordWebhookUrl"; + + // InvoiceSettings + public const string BlacklistedDomains = "BlacklistedDomains"; + public const string WhitelistedDomains = "WhitelistedDomains"; + public const string CheckIntervalSeconds = "CheckIntervalSeconds"; + public const string InvoiceTimeoutSeconds = "InvoiceTimeoutSeconds"; + public const string LNbitsApiKey = "LNbitsApiKey"; + public const string LNbitsBaseUrl = "LNbitsBaseUrl"; + public const string MaxRetries = "MaxRetries"; + + // VIPSettings + public const string VipPermissionGroup = "VipPermissionGroup"; + public const string VipPrice = "VipPrice"; + } + + // Configuration variables + private int currencyItemID; + private string buyCurrencyCommandName; + private string sendCurrencyCommandName; + private string buyVipCommandName; + private int vipPrice; + private string vipPermissionGroup; + private string currencyName; + private int satsPerCurrencyUnit; + private int pricePerCurrencyUnit; + private string discordChannelName; + private ulong currencySkinID; + private int checkIntervalSeconds; + private int invoiceTimeoutSeconds; + private int maxRetries; + private List blacklistedDomains = new List(); + private List whitelistedDomains = new List(); + private const string SellLogFile = "Orangemart/send_bitcoin.json"; + private const string BuyInvoiceLogFile = "Orangemart/buy_invoices.json"; + private LNbitsConfig config; + private List pendingInvoices = new List(); + private Dictionary retryCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // LNbits Configuration + private class LNbitsConfig + { + public string BaseUrl { get; set; } + public string ApiKey { get; set; } + public string DiscordWebhookUrl { get; set; } + + public static LNbitsConfig ParseLNbitsConnection(string baseUrl, string apiKey, string discordWebhookUrl) + { + var trimmedBaseUrl = baseUrl.TrimEnd('/'); + if (!Uri.IsWellFormedUriString(trimmedBaseUrl, UriKind.Absolute)) + throw new Exception("Invalid base URL in connection string."); + + return new LNbitsConfig + { + BaseUrl = trimmedBaseUrl, + ApiKey = apiKey, + DiscordWebhookUrl = discordWebhookUrl + }; + } + } + + // Invoice and Payment Classes +private class InvoiceResponse +{ + [JsonProperty("bolt11")] + public string PaymentRequest { get; set; } + + [JsonProperty("payment_hash")] + public string PaymentHash { get; set; } +} + + + // NEW: Wrapper class for LNbits v1 responses + private class InvoiceResponseWrapper + { + [JsonProperty("data")] + public InvoiceResponse Data { get; set; } + } + + private class SellInvoiceLogEntry + { + public string SteamID { get; set; } + public string LightningAddress { get; set; } + public bool Success { get; set; } + public int SatsAmount { get; set; } + public string PaymentHash { get; set; } + public bool CurrencyReturned { get; set; } + public DateTime Timestamp { get; set; } + public int RetryCount { get; set; } + } + + private class BuyInvoiceLogEntry + { + public string SteamID { get; set; } + public string InvoiceID { get; set; } + public bool IsPaid { get; set; } + public DateTime Timestamp { get; set; } + public int Amount { get; set; } + public bool CurrencyGiven { get; set; } + public bool VipGranted { get; set; } + public int RetryCount { get; set; } + } + + private class PendingInvoice + { + public string RHash { get; set; } + public IPlayer Player { get; set; } + public int Amount { get; set; } + public string Memo { get; set; } + public DateTime CreatedAt { get; set; } + public PurchaseType Type { get; set; } + } + + private enum PurchaseType + { + Currency, + Vip, + SendBitcoin + } + + private class PaymentStatusResponse + { + [JsonProperty("paid")] + public bool Paid { get; set; } + + [JsonProperty("preimage")] + public string Preimage { get; set; } + } + + protected override void LoadConfig() + { + base.LoadConfig(); + try + { + bool configChanged = false; + + // Parse LNbits connection settings + config = LNbitsConfig.ParseLNbitsConnection( + GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.LNbitsBaseUrl, "https://your-lnbits-instance.com", ref configChanged), + GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.LNbitsApiKey, "your-lnbits-admin-api-key", ref configChanged), + GetConfigValue(ConfigSections.Discord, ConfigKeys.DiscordWebhookUrl, "https://discord.com/api/webhooks/your_webhook_url", ref configChanged) + ); + + // Parse Currency Settings + currencyItemID = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.CurrencyItemID, 1776460938, ref configChanged); + currencyName = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.CurrencyName, "blood", ref configChanged); + satsPerCurrencyUnit = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.SatsPerCurrencyUnit, 1, ref configChanged); + pricePerCurrencyUnit = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.PricePerCurrencyUnit, 1, ref configChanged); + currencySkinID = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.CurrencySkinID, 0UL, ref configChanged); + + // Parse Command Names + buyCurrencyCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.BuyCurrencyCommandName, "buyblood", ref configChanged); + sendCurrencyCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.SendCurrencyCommandName, "sendblood", ref configChanged); + buyVipCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.BuyVipCommandName, "buyvip", ref configChanged); + + // Parse VIP Settings + vipPrice = GetConfigValue(ConfigSections.VIPSettings, ConfigKeys.VipPrice, 1000, ref configChanged); + vipPermissionGroup = GetConfigValue(ConfigSections.VIPSettings, ConfigKeys.VipPermissionGroup, "vip", ref configChanged); + + // Parse Discord Settings + discordChannelName = GetConfigValue(ConfigSections.Discord, ConfigKeys.DiscordChannelName, "mart", ref configChanged); + + // Parse Invoice Settings + checkIntervalSeconds = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.CheckIntervalSeconds, 10, ref configChanged); + invoiceTimeoutSeconds = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.InvoiceTimeoutSeconds, 300, ref configChanged); + maxRetries = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.MaxRetries, 25, ref configChanged); + + blacklistedDomains = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.BlacklistedDomains, new List { "example.com", "blacklisted.net" }, ref configChanged) + .Select(d => d.ToLower()).ToList(); + + whitelistedDomains = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.WhitelistedDomains, new List(), ref configChanged) + .Select(d => d.ToLower()).ToList(); + + MigrateConfig(); + + if (configChanged) + { + SaveConfig(); + } + } + catch (Exception ex) + { + PrintError($"Failed to load configuration: {ex.Message}"); + } + } + + private void MigrateConfig() + { + bool configChanged = false; + + if (!(Config[ConfigSections.InvoiceSettings] is Dictionary invoiceSettings)) + { + invoiceSettings = new Dictionary(); + Config[ConfigSections.InvoiceSettings] = invoiceSettings; + configChanged = true; + } + + if (!invoiceSettings.ContainsKey(ConfigKeys.WhitelistedDomains)) + { + invoiceSettings[ConfigKeys.WhitelistedDomains] = new List(); + configChanged = true; + } + + if (configChanged) + { + SaveConfig(); + } + } + + private T GetConfigValue(string section, string key, T defaultValue, ref bool configChanged) + { + if (!(Config[section] is Dictionary data)) + { + data = new Dictionary(); + Config[section] = data; + configChanged = true; + } + + if (!data.TryGetValue(key, out var value)) + { + value = defaultValue; + data[key] = value; + configChanged = true; + } + + try + { + if (value is T tValue) + { + return tValue; + } + else if (typeof(T) == typeof(List)) + { + if (value is IEnumerable enumerable) + { + return (T)(object)enumerable.Select(item => item.ToString()).ToList(); + } + else if (value is string singleString) + { + return (T)(object)new List { singleString }; + } + else + { + PrintError($"Unexpected type for [{section}][{key}]. Using default value."); + data[key] = defaultValue; + configChanged = true; + return defaultValue; + } + } + else if (typeof(T) == typeof(ulong)) + { + if (value is long longVal) + { + return (T)(object)(ulong)longVal; + } + else if (value is ulong ulongVal) + { + return (T)(object)ulongVal; + } + else + { + return (T)Convert.ChangeType(value, typeof(T)); + } + } + else + { + return (T)Convert.ChangeType(value, typeof(T)); + } + } + catch (Exception ex) + { + PrintError($"Error converting config value for [{section}][{key}]: {ex.Message}. Using default value."); + data[key] = defaultValue; + configChanged = true; + return defaultValue; + } + } + + protected override void LoadDefaultConfig() + { + Config[ConfigSections.Commands] = new Dictionary + { + [ConfigKeys.BuyCurrencyCommandName] = "buyblood", + [ConfigKeys.BuyVipCommandName] = "buyvip", + [ConfigKeys.SendCurrencyCommandName] = "sendblood" + }; + + Config[ConfigSections.CurrencySettings] = new Dictionary + { + [ConfigKeys.CurrencyItemID] = 1776460938, + [ConfigKeys.CurrencyName] = "blood", + [ConfigKeys.CurrencySkinID] = 0UL, + [ConfigKeys.PricePerCurrencyUnit] = 1, + [ConfigKeys.SatsPerCurrencyUnit] = 1 + }; + + Config[ConfigSections.Discord] = new Dictionary + { + [ConfigKeys.DiscordChannelName] = "mart", + [ConfigKeys.DiscordWebhookUrl] = "https://discord.com/api/webhooks/your_webhook_url" + }; + + Config[ConfigSections.InvoiceSettings] = new Dictionary + { + [ConfigKeys.BlacklistedDomains] = new List { "example.com", "blacklisted.net" }, + [ConfigKeys.WhitelistedDomains] = new List(), + [ConfigKeys.CheckIntervalSeconds] = 10, + [ConfigKeys.InvoiceTimeoutSeconds] = 300, + [ConfigKeys.LNbitsApiKey] = "your-lnbits-admin-api-key", + [ConfigKeys.LNbitsBaseUrl] = "https://your-lnbits-instance.com", + [ConfigKeys.MaxRetries] = 25 + }; + + Config[ConfigSections.VIPSettings] = new Dictionary + { + [ConfigKeys.VipPermissionGroup] = "vip", + [ConfigKeys.VipPrice] = 1000 + }; + } + + private void Init() + { + // Register permissions + permission.RegisterPermission("orangemart.buycurrency", this); + permission.RegisterPermission("orangemart.sendcurrency", this); + permission.RegisterPermission("orangemart.buyvip", this); + } + + private void OnServerInitialized() + { + if (config == null) + { + PrintError("Plugin configuration is not properly set up. Please check your configuration file."); + return; + } + + // Register commands + AddCovalenceCommand(buyCurrencyCommandName, nameof(CmdBuyCurrency), "orangemart.buycurrency"); + AddCovalenceCommand(sendCurrencyCommandName, nameof(CmdSendCurrency), "orangemart.sendcurrency"); + AddCovalenceCommand(buyVipCommandName, nameof(CmdBuyVip), "orangemart.buyvip"); + + // Start a timer to check pending invoices periodically + timer.Every(checkIntervalSeconds, CheckPendingInvoices); + } + + private void Unload() + { + pendingInvoices.Clear(); + retryCounts.Clear(); + } + + protected override void LoadDefaultMessages() + { + lang.RegisterMessages(new Dictionary + { + ["UsageSendCurrency"] = "Usage: /{0} ", + ["NeedMoreCurrency"] = "You need more {0}. You currently have {1}.", + ["FailedToReserveCurrency"] = "Failed to reserve currency. Please try again.", + ["FailedToQueryLightningAddress"] = "Failed to query Lightning address for an invoice.", + ["FailedToAuthenticate"] = "Failed to authenticate with LNbits.", + ["InvoiceCreatedCheckDiscord"] = "Invoice created! Please check the #{0} channel on Discord to complete your payment.", + ["FailedToCreateInvoice"] = "Failed to create an invoice. Please try again later.", + ["FailedToProcessPayment"] = "Failed to process payment. Please try again later.", + ["CurrencySentSuccess"] = "You have successfully sent {0} {1}!", + ["PurchaseSuccess"] = "You have successfully purchased {0} {1}!", + ["PurchaseVipSuccess"] = "You have successfully purchased VIP status!", + ["InvalidCommandUsage"] = "Usage: /{0} ", + ["NoPermission"] = "You do not have permission to use this command.", + ["FailedToFindBasePlayer"] = "Failed to find base player object for player {0}.", + ["FailedToCreateCurrencyItem"] = "Failed to create {0} item for player {1}.", + ["AddedToVipGroup"] = "Player {0} added to VIP group '{1}'.", + ["InvoiceExpired"] = "Your invoice for {0} sats has expired. Please try again.", + ["BlacklistedDomain"] = "The domain '{0}' is currently blacklisted. Please use a different Lightning address.", + ["NotWhitelistedDomain"] = "The domain '{0}' is not whitelisted. Please use a Lightning address from the following domains: {1}.", + ["InvalidLightningAddress"] = "The Lightning Address provided is invalid or cannot be resolved.", + ["PaymentProcessing"] = "Your payment is being processed. You will receive a confirmation once it's complete." + }, this); + } + + private string Lang(string key, string userId = null, params object[] args) + { + return string.Format(lang.GetMessage(key, this, userId), args); + } + + private List GetAllInventoryItems(BasePlayer player) + { + List allItems = new List(); + + // Main Inventory + if (player.inventory.containerMain != null) + allItems.AddRange(player.inventory.containerMain.itemList); + + // Belt (Hotbar) + if (player.inventory.containerBelt != null) + allItems.AddRange(player.inventory.containerBelt.itemList); + + // Wear (Clothing) + if (player.inventory.containerWear != null) + allItems.AddRange(player.inventory.containerWear.itemList); + + return allItems; + } + + private bool IsCurrencyItem(Item item) +{ + return item.info.itemid == currencyItemID && (currencySkinID == 0 || item.skin == currencySkinID); +} + +private bool TryReserveCurrency(BasePlayer player, int amount) +{ + var items = GetAllInventoryItems(player).Where(IsCurrencyItem).ToList(); + int totalCurrency = items.Sum(item => item.amount); + + if (totalCurrency < amount) + { + return false; + } + + int remaining = amount; + + foreach (var item in items) + { + if (item.amount > remaining) + { + item.UseItem(remaining); + break; + } + else + { + remaining -= item.amount; + item.Remove(); + } + + if (remaining <= 0) + { + break; + } + } + + return true; +} + + private void CheckPendingInvoices() + { + foreach (var invoice in pendingInvoices.ToList()) + { + string localPaymentHash = invoice.RHash.ToLower(); + CheckInvoicePaid(localPaymentHash, isPaid => + { + if (isPaid) + { + pendingInvoices.Remove(invoice); + + switch (invoice.Type) + { + case PurchaseType.Currency: + RewardPlayer(invoice.Player, invoice.Amount); + break; + case PurchaseType.Vip: + GrantVip(invoice.Player); + break; + case PurchaseType.SendBitcoin: + invoice.Player.Reply(Lang("CurrencySentSuccess", invoice.Player.Id, invoice.Amount, currencyName)); + break; + } + + if (invoice.Type == PurchaseType.SendBitcoin) + { + var logEntry = new SellInvoiceLogEntry + { + SteamID = GetPlayerId(invoice.Player), + LightningAddress = ExtractLightningAddress(invoice.Memo), + Success = true, + SatsAmount = invoice.Amount, + PaymentHash = invoice.RHash, + CurrencyReturned = false, + Timestamp = DateTime.UtcNow, + RetryCount = retryCounts.ContainsKey(invoice.RHash) ? retryCounts[invoice.RHash] : 0 + }; + LogSellTransaction(logEntry); + + Puts($"Invoice {invoice.RHash} marked as paid. RetryCount: {logEntry.RetryCount}"); + } + else + { + var logEntry = CreateBuyInvoiceLogEntry( + player: invoice.Player, + invoiceID: invoice.RHash, + isPaid: true, + amount: invoice.Type == PurchaseType.SendBitcoin ? invoice.Amount : invoice.Amount * pricePerCurrencyUnit, + type: invoice.Type, + retryCount: retryCounts.ContainsKey(invoice.RHash) ? retryCounts[invoice.RHash] : 0 + ); + LogBuyInvoice(logEntry); + Puts($"Invoice {invoice.RHash} marked as paid. RetryCount: {logEntry.RetryCount}"); + } + + retryCounts.Remove(invoice.RHash); + } + else + { + if (!retryCounts.ContainsKey(localPaymentHash)) + { + retryCounts[localPaymentHash] = 0; + Puts($"Initialized retry count for paymentHash: {localPaymentHash}"); + } + + retryCounts[localPaymentHash]++; + Puts($"Retry count for paymentHash {localPaymentHash}: {retryCounts[localPaymentHash]} of {maxRetries}"); + + if (retryCounts[localPaymentHash] >= maxRetries) + { + pendingInvoices.Remove(invoice); + int finalRetryCount = retryCounts[localPaymentHash]; + retryCounts.Remove(localPaymentHash); + PrintWarning($"Invoice for player {GetPlayerId(invoice.Player)} expired (amount: {invoice.Amount} sats)."); + + invoice.Player.Reply(Lang("InvoiceExpired", invoice.Player.Id, invoice.Amount)); + + if (invoice.Type == PurchaseType.SendBitcoin) + { + var basePlayer = invoice.Player.Object as BasePlayer; + if (basePlayer != null) + { + ReturnCurrency(basePlayer, invoice.Amount / satsPerCurrencyUnit); + Puts($"Refunded {invoice.Amount / satsPerCurrencyUnit} {currencyName} to player {basePlayer.UserIDString} due to failed payment."); + } + else + { + PrintError($"Failed to find base player object for player {invoice.Player.Id} to refund currency."); + } + + var failedLogEntry = new SellInvoiceLogEntry + { + SteamID = GetPlayerId(invoice.Player), + LightningAddress = ExtractLightningAddress(invoice.Memo), + Success = false, + SatsAmount = invoice.Amount, + PaymentHash = invoice.RHash, + CurrencyReturned = true, + Timestamp = DateTime.UtcNow, + RetryCount = finalRetryCount + }; + LogSellTransaction(failedLogEntry); + Puts($"Invoice {localPaymentHash} expired after {finalRetryCount} retries."); + } + else + { + var failedLogEntry = CreateBuyInvoiceLogEntry( + player: invoice.Player, + invoiceID: localPaymentHash, + isPaid: false, + amount: invoice.Type == PurchaseType.SendBitcoin ? invoice.Amount : invoice.Amount * pricePerCurrencyUnit, + type: invoice.Type, + retryCount: finalRetryCount + ); + LogBuyInvoice(failedLogEntry); + Puts($"Invoice {localPaymentHash} expired after {finalRetryCount} retries."); + } + } + else + { + PrintWarning($"Retrying invoice {localPaymentHash}. Attempt {retryCounts[localPaymentHash]} of {maxRetries}."); + } + } + }); + } + } + + private void CheckInvoicePaid(string paymentHash, Action callback) + { + string normalizedPaymentHash = paymentHash.ToLower(); + string url = $"{config.BaseUrl}/api/v1/payments/{normalizedPaymentHash}"; + + var headers = new Dictionary + { + { "Content-Type", "application/json" }, + { "X-Api-Key", config.ApiKey } + }; + + MakeWebRequest(url, null, (code, response) => + { + if (code != 200 || string.IsNullOrEmpty(response)) + { + PrintError($"Error checking invoice status: HTTP {code}"); + callback(false); + return; + } + + try + { + var paymentStatus = JsonConvert.DeserializeObject(response); + callback(paymentStatus != null && paymentStatus.Paid); + } + catch (Exception ex) + { + PrintError($"Failed to parse invoice status response: {ex.Message}"); + callback(false); + } + }, RequestMethod.GET, headers); + } + + private void CmdSendCurrency(IPlayer player, string command, string[] args) + { + if (!player.HasPermission("orangemart.sendcurrency")) + { + player.Reply(Lang("NoPermission", player.Id)); + return; + } + + if (args.Length != 2 || !int.TryParse(args[0], out int amount) || amount <= 0) + { + player.Reply(Lang("UsageSendCurrency", player.Id, sendCurrencyCommandName)); + return; + } + + string lightningAddress = args[1]; + + if (!IsLightningAddressAllowed(lightningAddress)) + { + string domain = GetDomainFromLightningAddress(lightningAddress); + if (whitelistedDomains.Any()) + { + string whitelist = string.Join(", ", whitelistedDomains); + player.Reply(Lang("NotWhitelistedDomain", player.Id, domain, whitelist)); + } + else + { + player.Reply(Lang("BlacklistedDomain", player.Id, domain)); + } + return; + } + + var basePlayer = player.Object as BasePlayer; + if (basePlayer == null) + { + player.Reply(Lang("FailedToFindBasePlayer", player.Id)); + return; + } + + int currencyAmount = GetAllInventoryItems(basePlayer).Where(IsCurrencyItem).Sum(item => item.amount); + + if (currencyAmount < amount) + { + player.Reply(Lang("NeedMoreCurrency", player.Id, currencyName, currencyAmount)); + return; + } + + if (!TryReserveCurrency(basePlayer, amount)) + { + player.Reply(Lang("FailedToReserveCurrency", player.Id)); + return; + } + + player.Reply(Lang("PaymentProcessing", player.Id)); + + SendBitcoin(lightningAddress, amount * satsPerCurrencyUnit, (success, paymentHash) => + { + if (success && !string.IsNullOrEmpty(paymentHash)) + { + LogSellTransaction( + new SellInvoiceLogEntry + { + SteamID = GetPlayerId(player), + LightningAddress = lightningAddress, + Success = true, + SatsAmount = amount * satsPerCurrencyUnit, + PaymentHash = paymentHash, + CurrencyReturned = false, + Timestamp = DateTime.UtcNow, + RetryCount = 0 + } + ); + + var pendingInvoice = new PendingInvoice + { + RHash = paymentHash.ToLower(), + Player = player, + Amount = amount * satsPerCurrencyUnit, + Memo = $"Sending {amount} {currencyName} to {lightningAddress}", + CreatedAt = DateTime.UtcNow, + Type = PurchaseType.SendBitcoin + }; + pendingInvoices.Add(pendingInvoice); + + Puts($"Outbound payment to {lightningAddress} initiated. PaymentHash: {paymentHash}"); + } + else + { + player.Reply(Lang("FailedToProcessPayment", player.Id)); + + LogSellTransaction( + new SellInvoiceLogEntry + { + SteamID = GetPlayerId(player), + LightningAddress = lightningAddress, + Success = false, + SatsAmount = amount * satsPerCurrencyUnit, + PaymentHash = null, + CurrencyReturned = true, + Timestamp = DateTime.UtcNow, + RetryCount = 0 + } + ); + + Puts($"Outbound payment to {lightningAddress} failed to initiate."); + + ReturnCurrency(basePlayer, amount); + Puts($"Returned {amount} {currencyName} to player {basePlayer.UserIDString} due to failed payment."); + } + }); + } + + private bool IsLightningAddressAllowed(string lightningAddress) + { + string domain = GetDomainFromLightningAddress(lightningAddress); + if (string.IsNullOrEmpty(domain)) + return false; + + if (whitelistedDomains.Any()) + { + return whitelistedDomains.Contains(domain.ToLower()); + } + else + { + return !blacklistedDomains.Contains(domain.ToLower()); + } + } + + private string GetDomainFromLightningAddress(string lightningAddress) + { + if (string.IsNullOrEmpty(lightningAddress)) + return null; + + var parts = lightningAddress.Split('@'); + return parts.Length == 2 ? parts[1].ToLower() : null; + } + + private void SendBitcoin(string lightningAddress, int satsAmount, Action callback) + { + ResolveLightningAddress(lightningAddress, satsAmount, bolt11 => + { + if (string.IsNullOrEmpty(bolt11)) + { + PrintError($"Failed to resolve Lightning Address: {lightningAddress}"); + callback(false, null); + return; + } + + SendPayment(bolt11, satsAmount, (success, paymentHash) => + { + if (success && !string.IsNullOrEmpty(paymentHash)) + { + StartPaymentStatusCheck(paymentHash, isPaid => + { + callback(isPaid, isPaid ? paymentHash : null); + }); + } + else + { + callback(false, null); + } + }); + }); + } + + private void StartPaymentStatusCheck(string paymentHash, Action callback) + { + if (!retryCounts.ContainsKey(paymentHash)) + { + retryCounts[paymentHash] = 0; + } + + Timer timerInstance = null; + timerInstance = timer.Repeat(checkIntervalSeconds, maxRetries, () => + { + CheckInvoicePaid(paymentHash, isPaid => + { + if (isPaid) + { + callback(true); + timerInstance.Destroy(); + } + else + { + retryCounts[paymentHash]++; + Puts($"PaymentHash {paymentHash} not yet paid. Retry {retryCounts[paymentHash]} of {maxRetries}."); + + if (retryCounts[paymentHash] >= maxRetries) + { + callback(false); + timerInstance.Destroy(); + } + } + }); + }); + } + + private void CmdBuyCurrency(IPlayer player, string command, string[] args) + { + if (!player.HasPermission("orangemart.buycurrency")) + { + player.Reply(Lang("NoPermission", player.Id)); + return; + } + + if (args.Length != 1 || !int.TryParse(args[0], out int amount) || amount <= 0) + { + player.Reply(Lang("InvalidCommandUsage", player.Id, buyCurrencyCommandName)); + return; + } + + int amountSats = amount * pricePerCurrencyUnit; + + CreateInvoice(amountSats, $"Buying {amount} {currencyName}", invoiceResponse => + { + if (invoiceResponse != null) + { + SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, $"Buying {amount} {currencyName}"); + + player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, discordChannelName)); + + var pendingInvoice = new PendingInvoice + { + RHash = invoiceResponse.PaymentHash.ToLower(), + Player = player, + Amount = amount, + Memo = $"Buying {amount} {currencyName}", + CreatedAt = DateTime.UtcNow, + Type = PurchaseType.Currency + }; + pendingInvoices.Add(pendingInvoice); + + ScheduleInvoiceExpiry(pendingInvoice); + } + else + { + player.Reply(Lang("FailedToCreateInvoice", player.Id)); + } + }); + } + + private void CmdBuyVip(IPlayer player, string command, string[] args) + { + if (!player.HasPermission("orangemart.buyvip")) + { + player.Reply(Lang("NoPermission", player.Id)); + return; + } + + int amountSats = vipPrice; + + CreateInvoice(amountSats, "Buying VIP Status", invoiceResponse => + { + if (invoiceResponse != null) + { + SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, "Buying VIP Status"); + + player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, discordChannelName)); + + var pendingInvoice = new PendingInvoice + { + RHash = invoiceResponse.PaymentHash.ToLower(), + Player = player, + Amount = amountSats, + Memo = "Buying VIP Status", + CreatedAt = DateTime.UtcNow, + Type = PurchaseType.Vip + }; + pendingInvoices.Add(pendingInvoice); + + ScheduleInvoiceExpiry(pendingInvoice); + } + else + { + player.Reply(Lang("FailedToCreateInvoice", player.Id)); + } + }); + } + + private void ScheduleInvoiceExpiry(PendingInvoice pendingInvoice) + { + timer.Once(invoiceTimeoutSeconds, () => + { + if (pendingInvoices.Contains(pendingInvoice)) + { + pendingInvoices.Remove(pendingInvoice); + PrintWarning($"Invoice for player {GetPlayerId(pendingInvoice.Player)} expired (amount: {pendingInvoice.Amount} sats)."); + + int finalRetryCount = retryCounts.ContainsKey(pendingInvoice.RHash) ? retryCounts[pendingInvoice.RHash] : 0; + + if (pendingInvoice.Type == PurchaseType.SendBitcoin) + { + var basePlayer = pendingInvoice.Player.Object as BasePlayer; + if (basePlayer != null) + { + ReturnCurrency(basePlayer, pendingInvoice.Amount / satsPerCurrencyUnit); + Puts($"Refunded {pendingInvoice.Amount / satsPerCurrencyUnit} {currencyName} to player {basePlayer.UserIDString} due to failed payment."); + } + else + { + PrintError($"Failed to find base player object for player {pendingInvoice.Player.Id} to refund currency."); + } + + var logEntry = new SellInvoiceLogEntry + { + SteamID = GetPlayerId(pendingInvoice.Player), + LightningAddress = ExtractLightningAddress(pendingInvoice.Memo), + Success = false, + SatsAmount = pendingInvoice.Amount, + PaymentHash = pendingInvoice.RHash, + CurrencyReturned = true, + Timestamp = DateTime.UtcNow, + RetryCount = finalRetryCount + }; + LogSellTransaction(logEntry); + Puts($"Invoice {pendingInvoice.RHash} for player {GetPlayerId(pendingInvoice.Player)} expired and logged."); + } + else + { + var logEntry = CreateBuyInvoiceLogEntry( + player: pendingInvoice.Player, + invoiceID: pendingInvoice.RHash, + isPaid: false, + amount: pendingInvoice.Type == PurchaseType.SendBitcoin ? pendingInvoice.Amount : pendingInvoice.Amount * pricePerCurrencyUnit, + type: pendingInvoice.Type, + retryCount: finalRetryCount + ); + LogBuyInvoice(logEntry); + Puts($"Invoice {pendingInvoice.RHash} for player {GetPlayerId(pendingInvoice.Player)} expired and logged."); + } + } + }); + } + + // UPDATED: SendPayment now deserializes using the wrapper class +private void SendPayment(string bolt11, int satsAmount, Action callback) +{ + // For outbound payments, LNbits expects only "out" and "bolt11" + string url = $"{config.BaseUrl}/api/v1/payments"; + var requestBody = new + { + @out = true, + bolt11 = bolt11 + }; + string jsonBody = JsonConvert.SerializeObject(requestBody); + + var headers = new Dictionary + { + { "X-Api-Key", config.ApiKey }, + { "Content-Type", "application/json" } + }; + + MakeWebRequest(url, jsonBody, (code, response) => + { + if (code != 200 && code != 201) + { + PrintError($"Error processing payment: HTTP {code}"); + callback(false, null); + return; + } + + try + { + InvoiceResponse invoiceResponse = null; + // First, attempt to deserialize using the wrapper (if present) + try + { + var wrapper = JsonConvert.DeserializeObject(response); + invoiceResponse = wrapper?.Data; + } + catch { } + + // Fallback: try direct deserialization + if (invoiceResponse == null) + { + invoiceResponse = JsonConvert.DeserializeObject(response); + } + + string paymentHash = invoiceResponse != null ? invoiceResponse.PaymentHash : null; + + if (!string.IsNullOrEmpty(paymentHash)) + { + callback(true, paymentHash); + } + else + { + PrintError("Payment hash (rhash) is missing or invalid in the response."); + PrintWarning($"[SendPayment] Raw response: {response}"); + callback(false, null); + } + } + catch (Exception ex) + { + PrintError($"Exception occurred while parsing payment response: {ex.Message}"); + callback(false, null); + } + }, RequestMethod.POST, headers); +} + + + // UPDATED: CreateInvoice now deserializes using the wrapper class +private void CreateInvoice(int amountSats, string memo, Action callback) +{ + string url = $"{config.BaseUrl}/api/v1/payments"; + + var requestBody = new + { + @out = false, + amount = amountSats, + memo = memo + }; + string jsonBody = JsonConvert.SerializeObject(requestBody); + + var headers = new Dictionary + { + { "X-Api-Key", config.ApiKey }, + { "Content-Type", "application/json" } + }; + + MakeWebRequest(url, jsonBody, (code, response) => + { + if (code != 200 && code != 201) + { + PrintError($"Error creating invoice: HTTP {code}"); + callback(null); + return; + } + + if (string.IsNullOrEmpty(response)) + { + PrintError("Empty response received when creating invoice."); + callback(null); + return; + } + + // Log the raw response for debugging purposes. + PrintWarning($"[CreateInvoice] Raw response: {response}"); + + try + { + var invoiceResponse = JsonConvert.DeserializeObject(response); + callback(invoiceResponse != null && !string.IsNullOrEmpty(invoiceResponse.PaymentHash) ? invoiceResponse : null); + } + catch (Exception ex) + { + PrintError($"Failed to deserialize invoice response: {ex.Message}"); + callback(null); + } + }, RequestMethod.POST, headers); +} + + private string GetPlayerId(IPlayer player) + { + var basePlayer = player.Object as BasePlayer; + return basePlayer != null ? basePlayer.UserIDString : player.Id; + } + + private void MakeWebRequest(string url, string jsonData, Action callback, RequestMethod method = RequestMethod.GET, Dictionary headers = null) + { + webrequest.Enqueue(url, jsonData, (code, response) => + { + if (string.IsNullOrEmpty(response) && (code < 200 || code >= 300)) + { + PrintError($"Web request to {url} returned empty response or HTTP {code}"); + callback(code, null); + } + else + { + callback(code, response); + } + }, this, method, headers ?? new Dictionary { { "Content-Type", "application/json" } }); + } + + private void ResolveLightningAddress(string lightningAddress, int amountSats, Action callback) + { + var parts = lightningAddress.Split('@'); + if (parts.Length != 2) + { + PrintError($"Invalid Lightning Address format: {lightningAddress}"); + callback(null); + return; + } + + string user = parts[0]; + string domain = parts[1]; + + string lnurlEndpoint = $"https://{domain}/.well-known/lnurlp/{user}"; + + var headers = new Dictionary + { + { "Content-Type", "application/json" } + }; + + MakeWebRequest(lnurlEndpoint, null, (code, response) => + { + if (code != 200 || string.IsNullOrEmpty(response)) + { + PrintError($"Failed to fetch LNURL for {lightningAddress}: HTTP {code}"); + callback(null); + return; + } + + try + { + var lnurlResponse = JsonConvert.DeserializeObject(response); + if (lnurlResponse == null || string.IsNullOrEmpty(lnurlResponse.Callback)) + { + PrintError($"Invalid LNURL response for {lightningAddress}"); + callback(null); + return; + } + + long amountMsat = (long)amountSats * 1000; + + string callbackUrl = lnurlResponse.Callback; + + string callbackUrlWithAmount = $"{callbackUrl}?amount={amountMsat}"; + + MakeWebRequest(callbackUrlWithAmount, null, (payCode, payResponse) => + { + if (payCode != 200 || string.IsNullOrEmpty(payResponse)) + { + PrintError($"Failed to perform LNURL Pay for {lightningAddress}: HTTP {payCode}"); + callback(null); + return; + } + + try + { + var payAction = JsonConvert.DeserializeObject(payResponse); + if (payAction == null || string.IsNullOrEmpty(payAction.Pr)) + { + PrintError($"Invalid LNURL Pay response for {lightningAddress}"); + callback(null); + return; + } + + callback(payAction.Pr); + } + catch (Exception ex) + { + PrintError($"Error parsing LNURL Pay response: {ex.Message}"); + callback(null); + } + }, RequestMethod.GET, headers); + } + catch (Exception ex) + { + PrintError($"Error parsing LNURL response: {ex.Message}"); + callback(null); + } + }, RequestMethod.GET, headers); + } + + private class LNURLResponse + { + [JsonProperty("tag")] + public string Tag { get; set; } + + [JsonProperty("callback")] + public string Callback { get; set; } + + [JsonProperty("minSendable")] + public long MinSendable { get; set; } + + [JsonProperty("maxSendable")] + public long MaxSendable { get; set; } + + [JsonProperty("metadata")] + public string Metadata { get; set; } + + [JsonProperty("commentAllowed")] + public int CommentAllowed { get; set; } + + [JsonProperty("allowsNostr")] + public bool AllowsNostr { get; set; } + + [JsonProperty("nostrPubkey")] + public string NostrPubkey { get; set; } + } + + private class LNURLPayResponse + { + [JsonProperty("pr")] + public string Pr { get; set; } + + [JsonProperty("routes")] + public List Routes { get; set; } + } + + private string ExtractLightningAddress(string memo) + { + // Expected format: "Sending {amount} {currency} to {lightning_address}" + var parts = memo.Split(" to "); + return parts.Length == 2 ? parts[1] : "unknown@unknown.com"; + } + + // NEW: RewardPlayer method to grant currency items to the player + private void RewardPlayer(IPlayer player, int amount) + { + player.Reply($"You have successfully purchased {amount} {currencyName}!"); + + var basePlayer = player.Object as BasePlayer; + if (basePlayer != null) + { + var currencyItem = ItemManager.CreateByItemID(currencyItemID, amount); + if (currencyItem != null) + { + if (currencySkinID > 0) + { + currencyItem.skin = currencySkinID; + } + basePlayer.GiveItem(currencyItem); + Puts($"Gave {amount} {currencyName} (skinID: {currencySkinID}) to player {basePlayer.UserIDString}."); + } + else + { + PrintError($"Failed to create {currencyName} item for player {basePlayer.UserIDString}."); + } + } + else + { + PrintError($"Failed to find base player object for player {player.Id}."); + } + } + + // NEW: GrantVip method to add the VIP permission group to the player + private void GrantVip(IPlayer player) + { + player.Reply("You have successfully purchased VIP status!"); + permission.AddUserGroup(player.Id, vipPermissionGroup); + Puts($"Player {GetPlayerId(player)} added to VIP group '{vipPermissionGroup}'."); + } + + // NEW: ReturnCurrency method to refund currency items to the player + private void ReturnCurrency(BasePlayer player, int amount) + { + var returnedCurrency = ItemManager.CreateByItemID(currencyItemID, amount); + if (returnedCurrency != null) + { + if (currencySkinID > 0) + { + returnedCurrency.skin = currencySkinID; + } + returnedCurrency.MoveToContainer(player.inventory.containerMain); + } + else + { + PrintError($"Failed to create {currencyName} item to return to player {player.UserIDString}."); + } + } + + // NEW: LogSellTransaction helper + private void LogSellTransaction(SellInvoiceLogEntry logEntry) + { + var logs = LoadSellLogData(); + logs.Add(logEntry); + SaveSellLogData(logs); + Puts($"[Orangemart] Logged sell transaction: {JsonConvert.SerializeObject(logEntry)}"); + } + + private List LoadSellLogData() + { + var path = Path.Combine(Interface.Oxide.DataDirectory, SellLogFile); + return File.Exists(path) + ? JsonConvert.DeserializeObject>(File.ReadAllText(path)) + : new List(); + } + + private void SaveSellLogData(List data) + { + var path = Path.Combine(Interface.Oxide.DataDirectory, SellLogFile); + var directory = Path.GetDirectoryName(path); + if (!Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllText(path, JsonConvert.SerializeObject(data, Formatting.Indented)); + } + + // NEW: LogBuyInvoice helper + private void LogBuyInvoice(BuyInvoiceLogEntry logEntry) + { + var logPath = Path.Combine(Interface.Oxide.DataDirectory, BuyInvoiceLogFile); + var directory = Path.GetDirectoryName(logPath); + if (!Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + List invoiceLogs = File.Exists(logPath) + ? JsonConvert.DeserializeObject>(File.ReadAllText(logPath)) ?? new List() + : new List(); + + invoiceLogs.Add(logEntry); + File.WriteAllText(logPath, JsonConvert.SerializeObject(invoiceLogs, Formatting.Indented)); + Puts($"[Orangemart] Logged buy invoice: {JsonConvert.SerializeObject(logEntry)}"); + } + + private BuyInvoiceLogEntry CreateBuyInvoiceLogEntry(IPlayer player, string invoiceID, bool isPaid, int amount, PurchaseType type, int retryCount) + { + return new BuyInvoiceLogEntry + { + SteamID = GetPlayerId(player), + InvoiceID = invoiceID, + IsPaid = isPaid, + Timestamp = DateTime.UtcNow, + Amount = type == PurchaseType.SendBitcoin ? amount : amount * pricePerCurrencyUnit, + CurrencyGiven = isPaid && type == PurchaseType.Currency, + VipGranted = isPaid && type == PurchaseType.Vip, + RetryCount = retryCount + }; + } + + private void SendInvoiceToDiscord(IPlayer player, string invoice, int amountSats, string memo) + { + if (string.IsNullOrEmpty(config.DiscordWebhookUrl)) + { + PrintError("Discord webhook URL is not configured."); + return; + } + + string qrCodeUrl = $"https://api.qrserver.com/v1/create-qr-code/?data={Uri.EscapeDataString(invoice)}&size=200x200"; + + var webhookPayload = new + { + content = $"**{player.Name}**, please pay **{amountSats} sats** using the Lightning Network.", + embeds = new[] + { + new + { + title = "Payment Invoice", + description = $"{memo}\n\nPlease pay the following Lightning invoice to complete your purchase:\n\n```\n{invoice}\n```", + image = new + { + url = qrCodeUrl + }, + fields = new[] + { + new { name = "Amount", value = $"{amountSats} sats", inline = true }, + new { name = "Steam ID", value = GetPlayerId(player), inline = true } + } + } + } + }; + + string jsonPayload = JsonConvert.SerializeObject(webhookPayload); + + MakeWebRequest(config.DiscordWebhookUrl, jsonPayload, (code, response) => + { + if (code != 204) + { + PrintError($"Failed to send invoice to Discord webhook: HTTP {code}"); + } + else + { + Puts($"Invoice sent to Discord for player {GetPlayerId(player)}."); + } + }, RequestMethod.POST, new Dictionary { { "Content-Type", "application/json" } }); + } + } +}