Kultur-Bug behoben + Threema entfernt (INotificationSink)

- TraderMonitorService las API-Preise kulturabhaengig: unter de-DE wurde aus
  "0.53" der Wert 53 (Faktor-100-Fehler im Einstandspreis). Nutzt jetzt den
  bereits vorhandenen invarianten Helper ParseDecimal.
- Gleiche Fehlerklasse in PolymarketClobClient (6x) und MasterTraderAnalyticsJob
  vorsorglich auf InvariantCulture gestellt.
- Neuer Regressionstest ApiNumberParsingTests (10 Faelle unter erzwungener de-DE-Kultur).
- Threema komplett entfernt (Entscheidung Richard): ThreemaService, vendorte
  Bibliothek libs/Threema-MsgApi-Net-Core, ServerSettings-Block, DI-Verdrahtung.
- Ersetzt durch neutrale INotificationSink (No-Throw-Vertrag) + LogNotificationSink
  als Uebergang; RocketChat/Telegram folgen spaeter.
- Entfernt nebenbei libsodium 1.0.16, die einzige Registry-Nutzung im Build,
  den HttpListener-Webhook und System.Web.HttpUtility (alles Linux-Hindernisse).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 12:01:13 +02:00
co-authored by Claude Opus 5
parent ab0a494f29
commit fd6d62618f
56 changed files with 794 additions and 3734 deletions
+1 -37
View File
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Xml.Serialization;
@@ -16,42 +16,6 @@ namespace PolyTraderSharp.Models
[XmlArrayItem("Module")]
public List<string> DisabledModules { get; set; } = new();
[Category("Threema Notifications")]
[DisplayName("Threema Enabled")]
[Description("Enable or disable Threema notifications.")]
public bool ThreemaEnabled { get; set; } = true;
[Category("Threema Notifications")]
[DisplayName("Gateway ID")]
[Description("The Threema Gateway ID (e.g. *3MAGW01).")]
public string ThreemaGatewayId { get; set; } = "*3MAGW01";
[Category("Threema Notifications")]
[DisplayName("Gateway Secret")]
[Description("The secret for the Threema Gateway integration.")]
public string ThreemaSecret { get; set; } = "";
[Category("Threema Notifications")]
[DisplayName("Private Key")]
[Description("The Private Key (hex) for End-to-End encryption.")]
public string ThreemaPrivateKey { get; set; } = "";
[Category("Threema Notifications")]
[DisplayName("Group ID")]
[Description("The Threema Group ID to send messages to.")]
public string ThreemaGroupId { get; set; } = "";
[Category("Threema Notifications")]
[DisplayName("Webhook Port")]
[Description("The local port to listen on for incoming Threema messages (e.g. 8080).")]
public int ThreemaWebhookPort { get; set; } = 8080;
[Category("Threema Notifications")]
[DisplayName("Report Interval (Hours)")]
[Description("Interval for the automatic summary report.")]
public int ThreemaReportIntervalHours { get; set; } = 6;
[Category("Mullvad VPN")]
[DisplayName("VPN Enabled")]
[Description("Enable or disable automatic VPN rotation.")]
@@ -0,0 +1,38 @@
using System.Threading;
using System.Threading.Tasks;
namespace PolyTrader.Core.Notifications
{
/// <summary>Einstufung einer Benachrichtigung steuert später die Kanalwahl und das Alarmverhalten.</summary>
public enum NotificationSeverity
{
/// <summary>Reine Information (z.B. Tagesbericht).</summary>
Info,
/// <summary>Auffälligkeit, die Beachtung verdient (z.B. Auto-Pause eines Master-Traders).</summary>
Warning,
/// <summary>Kritisch erfordert Eingreifen (z.B. Verlust-Eskalation der Sell-Ladder).</summary>
Critical
}
/// <summary>
/// Neutraler Ausgang für Benachrichtigungen an den Betreiber. Ersetzt die frühere direkte
/// Abhängigkeit der Dienste auf den (verworfenen) Threema-Versand: die Fachlogik kennt nur noch
/// diese Schnittstelle, der tatsächliche Kanal wird per DI gesetzt. Geplante Implementierungen
/// sind RocketChat und Telegram; bis dahin schreibt <see cref="LogNotificationSink"/> ins Terminal.
///
/// <para><b>Vertrag für JEDE Implementierung bindend:</b> <see cref="SendAsync"/> wirft NIEMALS.
/// Netzwerk- oder Kanalfehler werden intern abgefangen und protokolliert. Ein ausgefallener
/// Benachrichtigungskanal darf den Handelsablauf unter keinen Umständen unterbrechen die
/// Aufrufer verzichten deshalb bewusst auf eigene try/catch-Blöcke.</para>
/// </summary>
public interface INotificationSink
{
/// <summary>
/// Sendet eine Benachrichtigung. Wirft nie (siehe Vertrag in der Schnittstellen-Doku).
/// </summary>
Task SendAsync(string message, NotificationSeverity severity = NotificationSeverity.Info,
CancellationToken ct = default);
}
}
@@ -0,0 +1,49 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using PolyTraderSharp.Services;
namespace PolyTrader.Core.Notifications
{
/// <summary>
/// Übergangs-Implementierung von <see cref="INotificationSink"/>: schreibt Benachrichtigungen ins
/// Terminal-Log (und damit in die Tages-JSONL-Datei), bis die echten Kanäle (RocketChat, Telegram)
/// angebunden sind. Damit gehen Auto-Pause- und Eskalationsmeldungen nach dem Threema-Ausbau nicht
/// verloren sie sind im Terminal-Fenster und in den Logs auffindbar.
///
/// Erfüllt den No-Throw-Vertrag der Schnittstelle.
/// </summary>
public sealed class LogNotificationSink : INotificationSink
{
private readonly TerminalLogger _logger;
public LogNotificationSink(TerminalLogger logger) => _logger = logger;
public Task SendAsync(string message, NotificationSeverity severity = NotificationSeverity.Info,
CancellationToken ct = default)
{
try
{
string text = $"🔔 [Benachrichtigung] {message}";
switch (severity)
{
case NotificationSeverity.Critical:
case NotificationSeverity.Warning:
_logger.Warning(text);
break;
default:
_logger.Info(text);
break;
}
}
catch (Exception ex)
{
// No-Throw-Vertrag: ein defekter Benachrichtigungsweg darf den Handel nie unterbrechen.
try { Console.WriteLine($"[NotificationSink] Zustellung fehlgeschlagen (ignoriert): {ex.Message}"); }
catch { /* selbst Console kann in Sonderfällen fehlschlagen */ }
}
return Task.CompletedTask;
}
}
}
@@ -21,10 +21,6 @@
<PackageReference Include="Nethereum.Web3" Version="6.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\libs\Threema-MsgApi-Net-Core\IcgSoftware.Threema.CoreMsgApi\IcgSoftware.Threema.CoreMsgApi.csproj" />
</ItemGroup>
<!-- Erlaubt dem Testprojekt, interne Helfer (z.B. SecretProtection.Reset) zu testen. -->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
@@ -767,7 +767,10 @@ namespace PolyTraderSharp.Services
var decMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"maker amount supports a max accuracy of (\d+) decimals, taker amount a max of (\d+) decimals");
if (decMatch.Success && overrideMakerDecimals == null)
{
if (int.TryParse(decMatch.Groups[1].Value, out int newMaker) && int.TryParse(decMatch.Groups[2].Value, out int newTaker))
if (int.TryParse(decMatch.Groups[1].Value, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out int newMaker)
&& int.TryParse(decMatch.Groups[2].Value, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out int newTaker))
{
_logger.Info($"🔄 Automatische Anpassung an Dezimalregeln (Maker: {newMaker}, Taker: {newTaker}). Order wird neu berechnet...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, newMaker, newTaker);
@@ -778,7 +781,8 @@ namespace PolyTraderSharp.Services
var match = System.Text.RegularExpressions.Regex.Match(responseContent, @"invalid fee rate \(\d+\), current market's (?:taker|maker) fee: (\d+)");
if (match.Success && actualFeeBps == 0) // Only retry once
{
if (int.TryParse(match.Groups[1].Value, out int newFeeBps))
if (int.TryParse(match.Groups[1].Value, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out int newFeeBps))
{
_logger.Info($"🔄 Automatische Anpassung an Fee Rate ({newFeeBps} bps). Order wird erneut platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, newFeeBps, overrideTickSize);
@@ -789,7 +793,8 @@ namespace PolyTraderSharp.Services
var sizeMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"Size \([\d\.]+\) lower than the minimum: (\d+)");
if (sizeMatch.Success)
{
if (decimal.TryParse(sizeMatch.Groups[1].Value, out decimal minReq))
if (decimal.TryParse(sizeMatch.Groups[1].Value, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out decimal minReq))
{
if (orderType != "MARKET")
{
@@ -812,12 +817,15 @@ namespace PolyTraderSharp.Services
decimal totalBal = 0m, activeOrders = 0m;
if (balMatch1.Success)
{
_ = decimal.TryParse(balMatch1.Groups[1].Value, out totalBal);
_ = decimal.TryParse(balMatch1.Groups[2].Value, out activeOrders);
_ = decimal.TryParse(balMatch1.Groups[1].Value, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out totalBal);
_ = decimal.TryParse(balMatch1.Groups[2].Value, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out activeOrders);
}
else if (balMatch2.Success)
{
_ = decimal.TryParse(balMatch2.Groups[1].Value, out totalBal);
_ = decimal.TryParse(balMatch2.Groups[1].Value, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out totalBal);
activeOrders = 0m;
}
@@ -1,270 +0,0 @@
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
using IcgSoftware.Threema.CoreMsgApi;
using IcgSoftware.Threema.CoreMsgApi.Exceptions;
namespace PolyTraderSharp.Services
{
public class ThreemaService : BackgroundService
{
public event Action<string>? OnCommandReceived;
private readonly TerminalLogger _logger;
private ServerSettings _settings;
private readonly string _settingsPath = "server_settings.xml";
private readonly JobStatusRow _jobStatus;
private HttpListener? _httpListener;
private APIConnector? _apiConnector;
public ThreemaService(TerminalLogger logger, JobManager jobManager)
{
_logger = logger;
_settings = ServerSettings.Load(_settingsPath);
_jobStatus = new JobStatusRow
{
JobName = "Threema Webhook Listener",
Description = "Listens for incoming Threema Gateway Webhooks on the configured port.",
StatusText = "Pending Initial Delay..."
};
_jobStatus.ManualTriggerAction = async () =>
{
_jobStatus.StatusText = "Manual trigger not supported for Webhook";
await Task.Delay(2000);
};
jobManager.RegisterJob(_jobStatus);
InitConnector();
}
public void ReloadSettings()
{
_settings = ServerSettings.Load(_settingsPath);
InitConnector();
}
private void InitConnector()
{
if (_settings.ThreemaEnabled && !string.IsNullOrEmpty(_settings.ThreemaGatewayId) && !string.IsNullOrEmpty(_settings.ThreemaSecret))
{
// Initialize the pt-icg SDK APIConnector
_apiConnector = new APIConnector(_settings.ThreemaGatewayId, _settings.ThreemaSecret, new PublicKeyStoreNone());
}
}
public async Task<bool> SendMessageAsync(string text, string parseMode = "")
{
if (!_settings.ThreemaEnabled || _apiConnector == null)
{
return false;
}
// Using the GroupID field as the target (could be a Threema ID)
string targetId = _settings.ThreemaGroupId;
if (string.IsNullOrEmpty(targetId))
{
_logger.Error("Threema send failed: Target ID (Group ID) is not configured.");
return false;
}
try
{
return await Task.Run(() =>
{
// If no private key is configured, fallback to Basic mode (SendTextMessageSimple)
// Note: Basic mode does not support actual Group messaging, so targetId must be a personal Threema ID.
// If a private key IS configured, we would use E2E mode, but without the official 2.0 SDK's
// SendGroupTextMessage, we just send a direct E2E message.
if (string.IsNullOrEmpty(_settings.ThreemaPrivateKey))
{
string msgId = _apiConnector.SendTextMessageSimple(targetId, text);
if (!string.IsNullOrEmpty(msgId))
{
_logger.Info($"Threema basic message sent. ID: {msgId}");
return true;
}
}
else
{
// E2E Mode Direct Message
byte[] privateKey = DataUtils.HexStringToByteArray(_settings.ThreemaPrivateKey);
byte[] publicKey = _apiConnector.LookupKey(targetId);
if (publicKey == null)
{
_logger.Error($"Threema E2E failed: Could not lookup public key for {targetId}");
return false;
}
byte[] nonce = CryptTool.RandomNonce();
var encryptResult = CryptTool.EncryptTextMessage(text, privateKey, publicKey);
if (encryptResult != null && encryptResult.Result != null)
{
byte[] box = encryptResult.Result;
string msgId = _apiConnector.SendE2EMessage(targetId, encryptResult.Nonce, box);
if (!string.IsNullOrEmpty(msgId))
{
_logger.Info($"Threema E2E message sent. ID: {msgId}");
return true;
}
}
}
return false;
});
}
catch (Exception ex)
{
_logger.Error($"Threema send failed: {ex.Message}");
return false;
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_jobStatus.StatusText = "Idle";
while (!stoppingToken.IsCancellationRequested)
{
if (!_settings.ThreemaEnabled || !_jobStatus.IsEnabled)
{
_jobStatus.StatusText = "Paused / Disabled";
if (_httpListener != null && _httpListener.IsListening)
{
_httpListener.Stop();
}
await Task.Delay(5000, stoppingToken);
continue;
}
try
{
if (_httpListener == null || !_httpListener.IsListening)
{
_httpListener = new HttpListener();
_httpListener.Prefixes.Add($"http://*:{_settings.ThreemaWebhookPort}/");
_httpListener.Start();
_jobStatus.StatusText = $"Listening on port {_settings.ThreemaWebhookPort}...";
_logger.Info($"[Threema] Webhook listener started on port {_settings.ThreemaWebhookPort}");
}
_jobStatus.LastRun = DateTime.Now;
var getContextTask = _httpListener.GetContextAsync();
var delayTask = Task.Delay(5000, stoppingToken);
var completedTask = await Task.WhenAny(getContextTask, delayTask);
if (completedTask == getContextTask)
{
var context = await getContextTask;
_ = Task.Run(() => HandleIncomingWebhook(context), stoppingToken);
}
}
catch (TaskCanceledException) { }
catch (Exception ex)
{
if (ex is HttpListenerException hle && hle.ErrorCode == 5)
{
_logger.Error($"[Threema] Access Denied starting Webhook. Try running as Administrator or run: netsh http add urlacl url=http://*:{_settings.ThreemaWebhookPort}/ user=Everyone");
}
else
{
_logger.Error($"[Threema] Listener error: {ex.Message}");
}
_jobStatus.StatusText = "Error! Retrying in 5s...";
if (_httpListener != null)
{
try { _httpListener.Close(); } catch { }
_httpListener = null;
}
await Task.Delay(5000, stoppingToken);
}
_jobStatus.NextRun = DateTime.Now;
}
if (_httpListener != null)
{
try { _httpListener.Close(); } catch { }
}
}
private void HandleIncomingWebhook(HttpListenerContext context)
{
try
{
var request = context.Request;
var response = context.Response;
if (request.HttpMethod == "POST")
{
using (var reader = new StreamReader(request.InputStream, request.ContentEncoding))
{
string body = reader.ReadToEnd();
var parsedParams = System.Web.HttpUtility.ParseQueryString(body);
string? from = parsedParams["from"];
string? to = parsedParams["to"];
string? nonceStr = parsedParams["nonce"];
string? boxStr = parsedParams["box"];
string? macStr = parsedParams["mac"];
// Decrypt the message if E2E
if (!string.IsNullOrEmpty(from) && !string.IsNullOrEmpty(nonceStr) && !string.IsNullOrEmpty(boxStr) && !string.IsNullOrEmpty(_settings.ThreemaPrivateKey) && _apiConnector != null)
{
try
{
byte[] privateKey = DataUtils.HexStringToByteArray(_settings.ThreemaPrivateKey);
byte[] publicKey = _apiConnector.LookupKey(from);
byte[] nonce = DataUtils.HexStringToByteArray(nonceStr);
byte[] box = DataUtils.HexStringToByteArray(boxStr);
if (publicKey != null)
{
var msg = CryptTool.DecryptMessage(box, privateKey, publicKey, nonce);
if (msg is IcgSoftware.Threema.CoreMsgApi.Messages.TextMessage textMsg)
{
string text = textMsg.Text;
string logText = text.Length > 200 ? text.Substring(0, 200) + "..." : text;
_logger.Info($"[Threema] Empfangen von {from}: {logText}");
if (text.StartsWith("/"))
{
OnCommandReceived?.Invoke(text);
}
}
}
}
catch (Exception dex)
{
_logger.Warning($"[Threema] Failed to decrypt incoming message: {dex.Message}");
}
}
else
{
_logger.Info($"[Threema] Received webhook, but cannot process (Basic mode doesn't support incoming, or missing E2E keys).");
}
}
}
response.StatusCode = 200;
response.Close();
}
catch (Exception ex)
{
_logger.Error($"[Threema] Webhook handling error: {ex.Message}");
}
}
}
}