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:
@@ -5,12 +5,6 @@ VisualStudioVersion = 17.14.36915.13
|
|||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.App", "PolyTrader.App.csproj", "{8B91AC5C-8A7C-47BF-B651-6CF3B60FEC84}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.App", "PolyTrader.App.csproj", "{8B91AC5C-8A7C-47BF-B651-6CF3B60FEC84}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "libs", "libs", "{6C4D298B-81FB-2B6B-5AB6-46205EDDC72C}"
|
|
||||||
EndProject
|
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Threema-MsgApi-Net-Core", "Threema-MsgApi-Net-Core", "{35072DD7-6FE3-A86A-51D9-DD886E81202C}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IcgSoftware.Threema.CoreMsgApi", "libs\Threema-MsgApi-Net-Core\IcgSoftware.Threema.CoreMsgApi\IcgSoftware.Threema.CoreMsgApi.csproj", "{EFAE2853-E52F-4163-8266-3859C7748B85}"
|
|
||||||
EndProject
|
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.Core", "src\PolyTrader.Core\PolyTrader.Core.csproj", "{3502A702-FA60-456E-BF23-05EAD02465E6}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.Core", "src\PolyTrader.Core\PolyTrader.Core.csproj", "{3502A702-FA60-456E-BF23-05EAD02465E6}"
|
||||||
@@ -49,18 +43,6 @@ Global
|
|||||||
{8B91AC5C-8A7C-47BF-B651-6CF3B60FEC84}.Release|x64.Build.0 = Release|Any CPU
|
{8B91AC5C-8A7C-47BF-B651-6CF3B60FEC84}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{8B91AC5C-8A7C-47BF-B651-6CF3B60FEC84}.Release|x86.ActiveCfg = Release|Any CPU
|
{8B91AC5C-8A7C-47BF-B651-6CF3B60FEC84}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{8B91AC5C-8A7C-47BF-B651-6CF3B60FEC84}.Release|x86.Build.0 = Release|Any CPU
|
{8B91AC5C-8A7C-47BF-B651-6CF3B60FEC84}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{EFAE2853-E52F-4163-8266-3859C7748B85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{EFAE2853-E52F-4163-8266-3859C7748B85}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{EFAE2853-E52F-4163-8266-3859C7748B85}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{EFAE2853-E52F-4163-8266-3859C7748B85}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{EFAE2853-E52F-4163-8266-3859C7748B85}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{EFAE2853-E52F-4163-8266-3859C7748B85}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{EFAE2853-E52F-4163-8266-3859C7748B85}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{EFAE2853-E52F-4163-8266-3859C7748B85}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{EFAE2853-E52F-4163-8266-3859C7748B85}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{EFAE2853-E52F-4163-8266-3859C7748B85}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{EFAE2853-E52F-4163-8266-3859C7748B85}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{EFAE2853-E52F-4163-8266-3859C7748B85}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{3502A702-FA60-456E-BF23-05EAD02465E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{3502A702-FA60-456E-BF23-05EAD02465E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{3502A702-FA60-456E-BF23-05EAD02465E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{3502A702-FA60-456E-BF23-05EAD02465E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{3502A702-FA60-456E-BF23-05EAD02465E6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{3502A702-FA60-456E-BF23-05EAD02465E6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
@@ -138,8 +120,6 @@ Global
|
|||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{35072DD7-6FE3-A86A-51D9-DD886E81202C} = {6C4D298B-81FB-2B6B-5AB6-46205EDDC72C}
|
|
||||||
{EFAE2853-E52F-4163-8266-3859C7748B85} = {35072DD7-6FE3-A86A-51D9-DD886E81202C}
|
|
||||||
{3502A702-FA60-456E-BF23-05EAD02465E6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{3502A702-FA60-456E-BF23-05EAD02465E6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{FA3FC57B-EA9D-4703-B643-2874C8C0461E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{FA3FC57B-EA9D-4703-B643-2874C8C0461E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{E361C601-CC50-409F-8298-FD753DB4F6FF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{E361C601-CC50-409F-8298-FD753DB4F6FF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
|||||||
+6
-4
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
@@ -146,9 +146,13 @@ internal static class Program
|
|||||||
// Master-Key-/Lizenz-Meldungen von oben im Terminal-Fenster auftauchen.
|
// Master-Key-/Lizenz-Meldungen von oben im Terminal-Fenster auftauchen.
|
||||||
services.AddSingleton(bootLog);
|
services.AddSingleton(bootLog);
|
||||||
services.AddSingleton<MullvadVpnService>();
|
services.AddSingleton<MullvadVpnService>();
|
||||||
services.AddSingleton<ThreemaService>();
|
|
||||||
services.AddSingleton<JobManager>();
|
services.AddSingleton<JobManager>();
|
||||||
|
|
||||||
|
// Benachrichtigungen laufen über eine neutrale Senke. Threema ist entfallen; bis RocketChat
|
||||||
|
// und Telegram angebunden sind, landen Meldungen im Terminal-Log (LogNotificationSink).
|
||||||
|
services.AddSingleton<PolyTrader.Core.Notifications.INotificationSink>(
|
||||||
|
sp => new PolyTrader.Core.Notifications.LogNotificationSink(sp.GetRequiredService<TerminalLogger>()));
|
||||||
|
|
||||||
// Watchdog-Heartbeat (Dead-Man's-Switch). Als Singleton registriert, damit das
|
// Watchdog-Heartbeat (Dead-Man's-Switch). Als Singleton registriert, damit das
|
||||||
// Settings-Fenster denselben Dienst für ReloadSettings/Test-Heartbeat nutzt.
|
// Settings-Fenster denselben Dienst für ReloadSettings/Test-Heartbeat nutzt.
|
||||||
services.AddSingleton(sp => new WatchdogHeartbeatService(sp.GetRequiredService<TerminalLogger>()));
|
services.AddSingleton(sp => new WatchdogHeartbeatService(sp.GetRequiredService<TerminalLogger>()));
|
||||||
@@ -166,7 +170,6 @@ internal static class Program
|
|||||||
|
|
||||||
services.AddHostedService<MarketSyncService>();
|
services.AddHostedService<MarketSyncService>();
|
||||||
services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService<MullvadVpnService>());
|
services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService<MullvadVpnService>());
|
||||||
services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService<ThreemaService>());
|
|
||||||
services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService<WatchdogHeartbeatService>());
|
services.AddHostedService((IServiceProvider sp) => sp.GetRequiredService<WatchdogHeartbeatService>());
|
||||||
services.AddSingleton<PolyTraderSharp.Ui.ShellUiHost>();
|
services.AddSingleton<PolyTraderSharp.Ui.ShellUiHost>();
|
||||||
services.AddTransient<PolyTraderSharp.Ui.LauncherForm>();
|
services.AddTransient<PolyTraderSharp.Ui.LauncherForm>();
|
||||||
@@ -229,7 +232,6 @@ internal static class Program
|
|||||||
{
|
{
|
||||||
var view = new PolyTraderSharp.Ui.Views.SettingsView();
|
var view = new PolyTraderSharp.Ui.Views.SettingsView();
|
||||||
view.Initialize(
|
view.Initialize(
|
||||||
viewServices.GetRequiredService<ThreemaService>(),
|
|
||||||
viewServices.GetRequiredService<MullvadVpnService>(),
|
viewServices.GetRequiredService<MullvadVpnService>(),
|
||||||
viewServices.GetRequiredService<TerminalLogger>(),
|
viewServices.GetRequiredService<TerminalLogger>(),
|
||||||
viewServices.GetRequiredService<PolyTrader.Core.Persistence.IAccountRepository>(),
|
viewServices.GetRequiredService<PolyTrader.Core.Persistence.IAccountRepository>(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -23,7 +23,6 @@ namespace PolyTraderSharp.Ui.Views
|
|||||||
private const string SettingsPath = "server_settings.xml";
|
private const string SettingsPath = "server_settings.xml";
|
||||||
|
|
||||||
private ServerSettings _settings = new();
|
private ServerSettings _settings = new();
|
||||||
private ThreemaService? _threema;
|
|
||||||
private MullvadVpnService? _vpn;
|
private MullvadVpnService? _vpn;
|
||||||
private WatchdogHeartbeatService? _watchdog;
|
private WatchdogHeartbeatService? _watchdog;
|
||||||
private TerminalLogger? _logger;
|
private TerminalLogger? _logger;
|
||||||
@@ -57,10 +56,9 @@ namespace PolyTraderSharp.Ui.Views
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize(ThreemaService threema, MullvadVpnService vpn, TerminalLogger logger,
|
public void Initialize(MullvadVpnService vpn, TerminalLogger logger,
|
||||||
IAccountRepository accountRepo, TradingState state, WatchdogHeartbeatService? watchdog = null)
|
IAccountRepository accountRepo, TradingState state, WatchdogHeartbeatService? watchdog = null)
|
||||||
{
|
{
|
||||||
_threema = threema;
|
|
||||||
_vpn = vpn;
|
_vpn = vpn;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_accountRepo = accountRepo;
|
_accountRepo = accountRepo;
|
||||||
@@ -84,7 +82,6 @@ namespace PolyTraderSharp.Ui.Views
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_settings.Save(SettingsPath);
|
_settings.Save(SettingsPath);
|
||||||
_threema?.ReloadSettings();
|
|
||||||
_vpn?.ReloadSettings();
|
_vpn?.ReloadSettings();
|
||||||
_watchdog?.ReloadSettings();
|
_watchdog?.ReloadSettings();
|
||||||
_logger?.Info("Server-Einstellungen gespeichert und Services neu geladen.");
|
_logger?.Info("Server-Einstellungen gespeichert und Services neu geladen.");
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
# Analyse & Umsetzungsrahmen: Linux-Fähigkeit von PolyTrader
|
||||||
|
|
||||||
|
**Stand:** 06.08.2026 · **Revision 3** (Entscheidungen Richard eingearbeitet, P3b umgesetzt)
|
||||||
|
**Basis:** 7 Projekte, ~24.250 LOC Produktivcode (ohne Tests/EF-Migrationen), 448 Tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entscheidungen (06.08.2026)
|
||||||
|
|
||||||
|
| # | Thema | Entscheidung | Auswirkung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Kultur-Bug | **Sofort gefixt** ✅ | Erledigt, siehe [Abschnitt 4.1](#41-erledigt--der-aktive-bug-ist-behoben) |
|
||||||
|
| 2 | Designer-Regel | **Neue Formulierung übernommen:** Layout deklarativ in `.axaml`, nicht zur Laufzeit im Code | ersetzt die WinForms-Designer-Regel |
|
||||||
|
| 3 | Windows-UI | **Variante 2**: auf heutigem Stand eingefroren; soll später **komplett aus dem Build verschwinden** | Strategie siehe [5.5](#55-windows-ui-als-fallback-variante-2) |
|
||||||
|
| 4 | `PropertyGrid` | wird durch **normale Steuerelemente** ersetzt | +5 PT ggü. Fremdbibliothek, dafür bessere UX |
|
||||||
|
| 5 | Lizenz/Watchdog | laufen jetzt über das **Deploymentcenter**, nicht mehr über Einzeldienste | **Linux-Blocker entfällt**, siehe [5.2](#52-lizenz--watchdog--jetzt-über-das-deploymentcenter) |
|
||||||
|
| 6 | Zeitzone | **einmalig bei der Installation** festgelegt, nicht im laufenden Betrieb gewechselt | vereinfacht die Umsetzung, siehe [3.3](#33-anforderung-konfigurierbare-zeitzone-entscheidung-6) |
|
||||||
|
| 7 | Threema | **entfernt** ✅; Nachfolger: **RocketChat + Telegram** | umgesetzt am 06.08.2026, siehe [5.3](#53-threema--entfernt-) |
|
||||||
|
| 8 | Charts | **LiveCharts2** bestätigt (nach Klarstellung, dass die Integration nicht komplex ist) | +1 PT ggü. ScottPlot.Avalonia |
|
||||||
|
|
||||||
|
**Netto-Effekt auf den Gesamtaufwand:** leicht gesunken auf **41–61 PT**. PropertyGrid (+5 PT) und LiveCharts2 (+1 PT) stehen gegen den bereits **erledigten** Threema-Ausbau (−2 PT), die vereinfachte Zeitzonen-Anforderung (−1 PT) und den entfallenden Lizenz-Umbau (−2 PT).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Kurzfassung
|
||||||
|
|
||||||
|
| Frage | Antwort |
|
||||||
|
|---|---|
|
||||||
|
| Ist es machbar? | Ja, ohne architektonische Sackgassen. |
|
||||||
|
| Was ist der Löwenanteil? | Die UI. ~8.300 LOC (34 % des Produktivcodes) hängen an WinForms. |
|
||||||
|
| Was ist überraschend gut? | Die **gesamte Trading-Kernlogik ist bereits portabel**: kein einziger `DllImport`, keine Registry, keine WMI, keine DPAPI, keine `SpecialFolder`, saubere `Path.Combine`-Nutzung. Nethereum, Pomelo/EF, `HttpClient`, `ClientWebSocket`, AES-GCM laufen unverändert. |
|
||||||
|
| Was ist der teuerste Einzelpunkt? | Der **`PropertyGrid`-Ersatz** durch handgebaute Steuerelemente (6 Instanzen in 4 Fenstern). |
|
||||||
|
| Gesamtaufwand | **41–61 Personentage** (davon 2 PT bereits erledigt) |
|
||||||
|
| Wichtigste Erkenntnis | **Für ~15 PT (≈ 25 %) bekommst du 80 % des Nutzens**: Der Trading-Kern läuft headless auf Linux, die WinForms-UI bleibt als eingefrorener Fallback. Siehe [Abschnitt 7](#7-fahrplan). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Bestandsaufnahme
|
||||||
|
|
||||||
|
### 1.1 Projektstruktur und Zielframeworks
|
||||||
|
|
||||||
|
| Projekt | TargetFramework | `UseWindowsForms` | Warum WinForms? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PolyTrader.App` | `net8.0-windows7.0` (`WinExe`) | ja | Shell, Launcher, 4 Core-Views |
|
||||||
|
| `PolyTrader.Core` | `net8.0-windows` | ja | **nur** UI-Contract (`ModuleUi.cs`, `WindowMenu.cs`) |
|
||||||
|
| `Modules.CopyTrading` | `net8.0-windows` | ja | 5 Views + `TradeRowColoring` (`System.Drawing.Color`) |
|
||||||
|
| `Modules.ResolutionFarming` | `net8.0-windows` | ja | 1 Fenster |
|
||||||
|
| `Modules.Supervisor` | `net8.0-windows` | ja | 1 Fenster |
|
||||||
|
| `Modules.Accounting` | `net8.0-windows` | ja | 1 Fenster |
|
||||||
|
| `PolyTrader.Tests` | `net8.0-windows` | ja | nur weil die Referenzen `-windows` sind |
|
||||||
|
| ~~`IcgSoftware.Threema.CoreMsgApi`~~ (lib) | — | — | **entfernt am 06.08.2026** (Entscheidung 7) |
|
||||||
|
|
||||||
|
Alle verbleibenden Projekte sind `-windows` – **aber die Bindung ist dünn**. Core und Module brauchen WinForms ausschließlich für den UI-Contract; die Services darunter sind plattformneutral.
|
||||||
|
|
||||||
|
### 1.2 Wo sitzt der Windows-Code?
|
||||||
|
|
||||||
|
```
|
||||||
|
WinForms/System.Drawing berührt 41 Dateien – davon:
|
||||||
|
├─ 33 reine UI-Dateien (Ui/-Ordner) → müssen ohnehin neu
|
||||||
|
└─ 5 „Ausreißer" außerhalb der Ui-Ordner → das ist die eigentliche Kopplung
|
||||||
|
├─ src/PolyTrader.Core/Modularity/ModuleUi.cs (Func<Form>, Image)
|
||||||
|
├─ src/PolyTrader.Core/Modularity/WindowMenu.cs (MenuStrip, Font)
|
||||||
|
├─ src/…CopyTrading/Logic/TradeRowColoring.cs (Color)
|
||||||
|
├─ Models/DashboardRow.cs (using System.Drawing)
|
||||||
|
└─ Program.cs (ApplicationConfiguration.Initialize)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nur 5 Dateien** stehen zwischen „Core/Module sind plattformneutral" und dem Ist-Zustand.
|
||||||
|
|
||||||
|
### 1.3 Code-Verteilung
|
||||||
|
|
||||||
|
| Kategorie | LOC | Anteil |
|
||||||
|
|---|---:|---:|
|
||||||
|
| UI (Designer + Code-Behind), App + Module | ~8.300 | 34 % |
|
||||||
|
| davon Designer-generiert (entfällt ersatzlos) | ~4.500 | 19 % |
|
||||||
|
| Services / Logik / Persistenz / Modelle | ~15.950 | 66 % |
|
||||||
|
| Tests (448 Testfälle, 48 Dateien) | ~5.300 | — |
|
||||||
|
| EF-Migrationen (unverändert übernehmbar) | 6.891 | — |
|
||||||
|
|
||||||
|
**Nur 1 von 48 Testdateien** ist UI-abhängig (`WindowMenuTests.cs`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Die UI-Portierung
|
||||||
|
|
||||||
|
### 2.1 Control-Inventar und die neue Layout-Regel
|
||||||
|
|
||||||
|
16 Fenster/UserControls, ~8.300 LOC. Aus den Designer-Dateien:
|
||||||
|
|
||||||
|
| WinForms-Control | Anzahl | Avalonia-Ersatz | Bewertung |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| `DataGridViewTextBoxColumn` | 69 | `DataGridTextColumn` | mechanisch, viel Fleißarbeit |
|
||||||
|
| `Label` | 49 | `TextBlock` | trivial |
|
||||||
|
| `ToolStripButton` | 37 | `Button` in `StackPanel` | Layout neu |
|
||||||
|
| `TabPage` / `TabControl` | 22 / 7 | `TabItem` / `TabControl` | trivial |
|
||||||
|
| `DataGridView` | 20 | `DataGrid` | siehe unten |
|
||||||
|
| `ToolStrip` | 16 | `StackPanel`/`DockPanel` | Layout neu |
|
||||||
|
| `Panel` / `Button` | 16 / 16 | direkt | trivial |
|
||||||
|
| `TextBox` / `ComboBox` | 9 / 9 | direkt | trivial |
|
||||||
|
| **`PropertyGrid`** | **6** | **handgebaute Formulare** (Entscheidung 4) | **teuerster Punkt, siehe 2.2** |
|
||||||
|
| `DateTimePicker` | 5 | `CalendarDatePicker` + `TimePicker` (getrennt!) | kleine Nacharbeit |
|
||||||
|
| `GroupBox` | 4 | `HeaderedContentControl`/`Border` | trivial |
|
||||||
|
| **`RichTextBox`** | **3** | kein Äquivalent, siehe 2.3 | mittel |
|
||||||
|
| `PictureBox` | 3 | `CartesianChart` (LiveCharts2) | siehe 2.4 |
|
||||||
|
| `SplitContainer` | 2 | `GridSplitter` | Layout neu |
|
||||||
|
| `MenuStrip` / `StatusStrip` | 1 / 1 | `Menu` / `DockPanel` | `ToolStripItemAlignment.Right` gibt es nicht → `DockPanel` |
|
||||||
|
|
||||||
|
**Entwarnung bei den DataGrids:** Die Nutzung ist erfreulich schlicht – 25× `DataSource`-Binding, 35× `DefaultCellStyle`, 8× `AutoGenerateColumns`, nur 4 `SelectionChanged`, 3 `CellContentClick`, 1 `CellDoubleClick`. **Kein Virtual Mode, kein Custom Painting, keine `CellFormatting`.** Direkt auf `ItemsSource` + `Binding StringFormat` abbildbar.
|
||||||
|
|
||||||
|
Die Zeilenfärbung (`ClosedTradesView.cs`, `OpenTradesView.cs` über `DataBindingComplete` → `row.DefaultCellStyle.BackColor`) wird zu einem `IValueConverter` auf `DataGrid.LoadingRow` – sauberer als heute.
|
||||||
|
|
||||||
|
> ### 📐 Die neue Layout-Regel (Entscheidung 2)
|
||||||
|
>
|
||||||
|
> Die bisherige Vorgabe „alle WinForms-Controls designerfähig (partial + `.Designer.cs`), nie code-only" wird ersetzt durch:
|
||||||
|
>
|
||||||
|
> **„Jede View besteht aus `View.axaml` (vollständiges Layout, deklarativ) und `View.axaml.cs` (nur Verdrahtung und Datenlogik). Steuerelemente und Layout werden NICHT zur Laufzeit im Code erzeugt."**
|
||||||
|
>
|
||||||
|
> Nutzen bleibt identisch: Layout ist vollständig inspizierbar, diffbar und an einer Stelle. Ein WYSIWYG-Designer existiert in Avalonia nicht – stattdessen der **XAML-Previewer** (VS-/Rider-Extension) mit Live-Vorschau beim Tippen.
|
||||||
|
>
|
||||||
|
> **Praktische Konsequenz für die Portierung:** `WindowMenu.Populate()` baut die Menüleiste heute komplett zur Laufzeit auf (`menu.Items.Clear()` + dynamisch erzeugte `ToolStripMenuItem`). Das verstößt gegen die neue Regel und wird zu einem deklarativen `Menu` mit `ItemsSource`-Bindung an eine `ObservableCollection<WindowMenuEntry>` – Layout im XAML, nur die Daten kommen aus dem Code. Gleiches gilt für die dynamisch angehängte Menüleiste in `ShellUiHost.AttachWindowMenu()`.
|
||||||
|
|
||||||
|
### 2.2 `PropertyGrid` → normale Steuerelemente (Entscheidung 4)
|
||||||
|
|
||||||
|
Betroffen sind **4 Fenster mit 6 Grid-Instanzen**:
|
||||||
|
|
||||||
|
| Datei | Instanz | Gebundenes Objekt |
|
||||||
|
|---|---|---|
|
||||||
|
| `Ui/Views/SettingsView.Designer.cs:21,33` | `propertyGrid`, `pgAccount` | `ServerSettings`, Account-Daten |
|
||||||
|
| `…CopyTrading/Ui/AccountSettingsView.Designer.cs:25` | `pgSettings` | `CopyTradingAccountSettings` |
|
||||||
|
| `…CopyTrading/Ui/MasterTradersView.Designer.cs:41` | `pgDetail` | `TrackedTrader` |
|
||||||
|
| `…ResolutionFarming/Ui/ResolutionFarmingMainForm.Designer.cs:38` | `pgSettings` | Farming-Settings |
|
||||||
|
|
||||||
|
**Vorarbeit, die sich jetzt auszahlt:** Die Modelle tragen bereits `[Description]`-Attribute als Inline-Erklärungen (`CopyTradingAccountSettings.cs:11`, `TrackedTrader.cs:10`) und `[Browsable(false)]` zum Ausblenden von Geheimnissen (`ServerSettings.cs:107,146` – Watchdog-Token und Lizenzschlüssel). **Diese Attribute sind die Spezifikation der neuen Formulare** – Beschriftung, Tooltip und Sichtbarkeit jedes Feldes stehen schon im Code. Das reduziert die Denkarbeit auf Layout und Validierung.
|
||||||
|
|
||||||
|
**Was die handgebauten Formulare besser können als das `PropertyGrid`:**
|
||||||
|
- **Eingabevalidierung vor dem Speichern** statt erst beim Commit der Zelle (bei Risiko-Limits und Preisgrenzen relevant)
|
||||||
|
- **Einheiten direkt am Feld** (USDC, %, bps, Minuten) statt im Beschreibungstext
|
||||||
|
- **Fachliche Gruppierung** statt alphabetischer Reflection-Reihenfolge
|
||||||
|
- **Abhängige Felder ausgrauen** (z. B. Ladder-Parameter nur bei aktivierter Sell-Ladder)
|
||||||
|
|
||||||
|
**Aufwand:** ~8–10 PT statt ~2 PT mit Fremdbibliothek. Der Aufschlag ist bewusst und kauft dauerhafte Wartbarkeit ohne Fremdabhängigkeit.
|
||||||
|
|
||||||
|
### 2.3 `RichTextBox` → Terminal-Fenster
|
||||||
|
|
||||||
|
`TerminalView` (378 + 195 LOC) und `SupervisorMainForm` (Chat) nutzen `RichTextBox` mit **Farbausgabe pro Log-Zeile**. Avalonia hat kein `RichTextBox`.
|
||||||
|
|
||||||
|
**Empfehlung:** `ItemsControl` über eine `ObservableCollection<LogLine>` mit Farb-Converter – virtualisiert, deklarativ (passt zur neuen Layout-Regel), und schneller als heute. `TerminalView.cs:186` implementiert bereits ein Auto-Clear bei RAM-Limit; der Performance-Druck ist bekannt, ein virtualisiertes `ItemsControl` löst ihn an der Wurzel.
|
||||||
|
|
||||||
|
### 2.4 Charts: ScottPlot → LiveCharts2 (Entscheidung 8)
|
||||||
|
|
||||||
|
Nur **3 Charts**, alle in `DashboardView.RenderCharts()` (Equity-Kurve, PnL je Modul, PnL je Tag). Die Auswertungslogik liegt bereits pur in `TradeAnalytics` und bleibt **komplett unverändert**.
|
||||||
|
|
||||||
|
Der heutige Weg (`plot.GetImage(w,h).GetImageBytes()` → `System.Drawing.Bitmap` → `PictureBox`, `DashboardView.cs:207-227`) entfällt und wird zu drei deklarativen `CartesianChart`-Elementen im XAML – was der neuen Layout-Regel ohnehin besser entspricht als das heutige Rendern im Code.
|
||||||
|
|
||||||
|
> **Zur Einordnung deiner Sorge:** Der Unterschied ist kleiner als befürchtet. `ScottPlot.Avalonia` wäre ~1 PT günstiger gewesen, nicht mehr – bei **drei** Charts fällt der Integrationsvorteil kaum ins Gewicht. LiveCharts2 bringt dafür Animationen, Tooltips und Zoom out of the box, die du bei ScottPlot nachbauen müsstest. **Die Entscheidung für LiveCharts2 ist bei dieser Chart-Anzahl gut begründet** – du zahlst dafür keinen nennenswerten Aufpreis.
|
||||||
|
|
||||||
|
### 2.5 `System.Drawing.Common` muss raus
|
||||||
|
|
||||||
|
Seit **.NET 7 ist `System.Drawing.Common` Windows-only** und wirft auf Linux `PlatformNotSupportedException`. Betroffen:
|
||||||
|
|
||||||
|
- `ModuleUi.cs:32` – `System.Drawing.Image? Icon` → `Avalonia.Media.IImage`
|
||||||
|
- `TradeRowColoring.cs:11-13` – `Color.FromArgb(...)` → eigenes Farbtupel oder `Avalonia.Media.Color`
|
||||||
|
- `WindowMenu.cs` – `Font`, `FontStyle.Bold`
|
||||||
|
- `Models/DashboardRow.cs` – `using System.Drawing` (vermutlich verwaist, prüfen)
|
||||||
|
- `DashboardView.cs:222-225` – entfällt mit LiveCharts2
|
||||||
|
|
||||||
|
`TradeRowColoringTests.cs` testet die Farben direkt → zieht mit (klein).
|
||||||
|
|
||||||
|
### 2.6 PDF-Export: `PDFsharp-MigraDoc-GDI` → Core-Build
|
||||||
|
|
||||||
|
`Modules.Accounting` referenziert `PDFsharp-MigraDoc-GDI` 6.2.4 – der **GDI+-Build ist Windows-only**. Ersatz: `PDFsharp-MigraDoc` (Core-Build), API-kompatibel.
|
||||||
|
|
||||||
|
**Aber:** Der Core-Build findet auf Linux keine Systemschriften. `PdfExporter.cs:34` setzt `style.Font.Name = "Segoe UI"` – existiert auf Linux nicht. Nötig ist ein eigener **`IFontResolver`** plus eine als `EmbeddedResource` mitgelieferte Schrift (DejaVu Sans oder Open Sans, beide frei lizenziert). Einziger nennenswerter Umbau am Accounting-Modul.
|
||||||
|
|
||||||
|
`AccountingReportTests.cs:131` rendert PDFs headless im Test → zieht mit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Zeitzonen ⏰
|
||||||
|
|
||||||
|
### 3.1 Der harte Punkt
|
||||||
|
|
||||||
|
`Ui/Views/TerminalView.cs:150`:
|
||||||
|
```csharp
|
||||||
|
TimeZoneInfo berlinTz = TimeZoneInfo.FindSystemTimeZoneById("W. Europe Standard Time");
|
||||||
|
```
|
||||||
|
|
||||||
|
Eine **Windows-Zeitzonen-ID**. Seit .NET 6 mappt `FindSystemTimeZoneById` Windows-IDs auf Linux über ICU – funktioniert also *meistens*. Es bricht, sobald `InvariantGlobalization=true` gesetzt ist oder die Distro kein vollständiges ICU mitbringt (Alpine ohne `icu-libs`). Dann fliegt beim Öffnen des Terminal-Fensters eine `TimeZoneNotFoundException`.
|
||||||
|
|
||||||
|
Mit Entscheidung 6 verschwindet diese Stelle ohnehin – die Zeitzone kommt künftig aus der Konfiguration, nicht mehr hartcodiert.
|
||||||
|
|
||||||
|
### 3.2 Die Now/UtcNow-Mischung
|
||||||
|
|
||||||
|
**122× `DateTime.UtcNow` gegen 23× `DateTime.Now`.** Konsistent, solange der Rechner auf `Europe/Berlin` steht. **Linux-Server laufen praktisch immer auf UTC.** Betroffene Stellen:
|
||||||
|
|
||||||
|
| Stelle | Auswirkung bei Server-TZ = UTC |
|
||||||
|
|---|---|
|
||||||
|
| `TerminalLogger.cs:23` – `Timestamp = DateTime.Now` | Log-Zeitstempel 1–2 h versetzt |
|
||||||
|
| `TerminalLogger.cs:122`, `LauncherWidgetsPanel.cs:112` – Logdatei `{DateTime.Now:yyyy-MM-dd}.jsonl` | **Tagesgrenze der Logdateien verschiebt sich** |
|
||||||
|
| `DossierService.cs:81`, `SupervisorTools.cs:144` – suchen Logdateien per Datum | müssen dieselbe Konvention nutzen, sonst leere Dossiers |
|
||||||
|
| `DailyReportService.cs:61` | Tagesbericht schneidet an anderer Stelle |
|
||||||
|
| `AccountingMainForm.cs:57` – Periodenvorbelegung | **steuerlich relevant** – Monats-/Quartalsgrenzen |
|
||||||
|
| `PdfExporter.cs:55` – „Erstellt: …" | Belegdatum im Steuer-PDF |
|
||||||
|
| `MasterTraderAnalyticsJob.cs` | mischt `Now` (Anzeige) und `UtcNow` (Cutoffs) – funktioniert, ist aber verwirrend |
|
||||||
|
|
||||||
|
### 3.3 Anforderung: konfigurierbare Zeitzone (Entscheidung 6)
|
||||||
|
|
||||||
|
> **Ziel (präzisiert):** Die Zeitzone wird **einmalig bei der Installation** festgelegt — wie in einem Setup — und im laufenden Betrieb normalerweise nicht mehr gewechselt.
|
||||||
|
|
||||||
|
**Vorgeschlagene Umsetzung:**
|
||||||
|
|
||||||
|
1. **Neue Einstellung** `ServerSettings.ApplicationTimeZoneId` (Default `"Europe/Berlin"`). IANA-IDs verwenden – .NET akzeptiert sie seit Version 6 auf **beiden** Plattformen, Windows-IDs dagegen nur eingeschränkt auf Linux.
|
||||||
|
|
||||||
|
2. **Zentraler Zugriffspunkt** statt verstreuter `DateTime.Now`-Aufrufe:
|
||||||
|
```
|
||||||
|
IAppClock
|
||||||
|
├─ DateTime UtcNow → für alle Domänen-/Persistenzwerte
|
||||||
|
├─ DateTime Local → für Anzeige und Logdatei-Namen
|
||||||
|
└─ TimeZoneInfo TimeZone → aufgelöst aus ApplicationTimeZoneId, mit Fallback auf UTC + Warnung
|
||||||
|
```
|
||||||
|
Damit ist die Zeitzone an **einer** Stelle wirksam statt an 23.
|
||||||
|
|
||||||
|
3. **Festlegung bei der Erstinstallation**, danach schreibgeschützt-mit-Bestätigung:
|
||||||
|
- Beim **ersten Start ohne gesetzte Zeitzone** wird sie abgefragt (UI-Dialog bzw. CLI-Schalter `--set-timezone` für den headless Betrieb) und in `server_settings.xml` festgeschrieben.
|
||||||
|
- Eine **spätere Änderung** ist möglich, aber bewusst unbequem: Bestätigungsdialog mit Klartext-Folgen („Logdatei-Tagesgrenzen verschieben sich um X Stunden. Bereits geschriebene Logs und Buchungen bleiben unverändert und sind danach in der neuen Zeitzone zu lesen.") und **Wirksamkeit erst nach Neustart**.
|
||||||
|
- **Protokolleintrag** mit alter und neuer Zeitzone, damit die Verschiebung später in den Logs nachvollziehbar ist.
|
||||||
|
|
||||||
|
Weil der Regelfall die einmalige Festlegung ist, entfällt jede Komplexität für einen Wechsel *zur Laufzeit* — kein Neu-Verdrahten laufender Jobs, kein Umschalten offener Fenster. Das ist der eigentliche Gewinn dieser Entscheidung.
|
||||||
|
|
||||||
|
4. **Zusätzlich** `TZ`-Umgebungsvariable in der systemd-Unit setzen, damit auch die Ausgaben *außerhalb* der Anwendung (Logrotate, Cron, Systemlogs) zusammenpassen.
|
||||||
|
|
||||||
|
**Offene fachliche Frage:** Soll die Zeitzone im Accounting **mitwandern** oder dort fix auf der steuerlich maßgeblichen Zeitzone stehen bleiben? Ein Wechsel des Betriebsstandorts ändert nicht zwingend den steuerlichen Bezugsrahmen. Empfehlung: **zwei getrennte Einstellungen** – `ApplicationTimeZoneId` (Anzeige, Logs, Jobs) und `AccountingTimeZoneId` (Buchungsperioden, Steuerbelege), Default identisch. Kostet fast nichts, verhindert aber, dass ein Standortwechsel rückwirkend die Buchungsperioden verschiebt.
|
||||||
|
|
||||||
|
**Aufwand:** ~2 PT (Einstellung, `IAppClock`, 23 Aufrufstellen umziehen, Erstinstallations-Abfrage, Tests). Durch die Beschränkung auf die Installationszeit ~1 PT günstiger als in Revision 2 angesetzt.
|
||||||
|
|
||||||
|
### 3.4 MySQL
|
||||||
|
|
||||||
|
Pomelo speichert `DateTime` als `DATETIME` ohne Zeitzoneninformation. Bestehende Daten ändern sich beim Plattformwechsel nicht – **aber die Interpretation neuer Werte** ändert sich. Prüfen: `SELECT @@global.time_zone, @@session.time_zone;` auf dem Ziel-Server. Bei gemischt geschriebenen Daten entstehen im Accounting stumme Lücken/Überlappungen an der Tagesgrenze. Der `IAppClock` aus 3.3 löst das, indem er die Persistenz konsequent auf UTC festlegt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. String- und Zahlenformatierung
|
||||||
|
|
||||||
|
### 4.1 ERLEDIGT – der aktive Bug ist behoben ✅
|
||||||
|
|
||||||
|
`TraderMonitorService.cs` parste API-Preise kulturabhängig. **Empirisch verifiziert:**
|
||||||
|
|
||||||
|
```
|
||||||
|
de-DE "0.53" -> 53 "1234.5" -> 12345
|
||||||
|
Invariant "0.53" -> 0,53 "1234.5" -> 1234,5
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein **Faktor-100-Fehler** im Einstandspreis der Master-Trader-Auswertung.
|
||||||
|
|
||||||
|
**Behoben am 06.08.2026:**
|
||||||
|
- `TraderMonitorService.cs:1276-1280` – nutzt jetzt den bereits vorhandenen, korrekten Helper `ParseDecimal` statt duplizierter Inline-Logik. Der Helper ist `internal static` und dokumentiert.
|
||||||
|
- `PolymarketClobClient.cs:770, 784, 796, 820, 822, 827` – alle `TryParse` auf `InvariantCulture` (Regex-Gruppen sind zwar ziffernrein und waren damit faktisch sicher, aber die Klasse Fehler soll nirgends mehr im Code stehen).
|
||||||
|
- `MasterTraderAnalyticsJob.cs:140` – Unix-Timestamp-String invariant.
|
||||||
|
- **Neuer Regressionstest** `tests/PolyTrader.Tests/ApiNumberParsingTests.cs` – 10 Fälle, die unter erzwungener **de-DE-Kultur** laufen und fehlschlagen, sobald jemand die Invariant-Angabe entfernt.
|
||||||
|
|
||||||
|
**Verifikation:** Build fehlerfrei, **448 Tests grün** (438 vorher + 10 neue).
|
||||||
|
|
||||||
|
### 4.2 Ausgabeformatierung (offen)
|
||||||
|
|
||||||
|
35× `DefaultCellStyle.Format`, 19× `ToString("…")`, dazu zahlreiche `:N2`/`:F2`/`:F3` in Log- und UI-Strings – alle nach `CultureInfo.CurrentCulture`.
|
||||||
|
|
||||||
|
Auf Windows ist das `de-DE`. Auf Linux ist es **`LANG`/`LC_ALL`** – unter systemd oft **gar nicht gesetzt** → .NET fällt auf **Invariant** zurück → aus `1.234,56 USDC` wird `1,234.56 USDC`. Kein Datenfehler, aber sofort sichtbar.
|
||||||
|
|
||||||
|
**Empfehlung:**
|
||||||
|
1. **Anzeige-Kultur beim Start explizit pinnen**: `CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("de-DE")` in `Program.Main`. Damit ist die Darstellung unabhängig von `LANG`. (Sinnvollerweise ebenfalls konfigurierbar, analog zur Zeitzone – dieselbe Begründung: Betrieb im Ausland.)
|
||||||
|
2. **Globalisierungs-Analyzer aktivieren** (`CA1304`, `CA1305`, `CA1307`, `CA1310`). Sie hätten den Bug aus 4.1 gefunden.
|
||||||
|
|
||||||
|
### 4.3 Weitere Kulturthemen
|
||||||
|
|
||||||
|
- **ICU vs. NLS:** .NET nutzt seit Version 5 auf beiden Plattformen ICU – Sortier- und Vergleichssemantik bleibt gleich. Voraussetzung: **kein `InvariantGlobalization=true`** im Publish-Profil, `libicu` auf dem Zielsystem (Debian/Ubuntu Standard, Alpine nachinstallieren).
|
||||||
|
- **String-Vergleiche:** durchgängig `StringComparison.OrdinalIgnoreCase` – vorbildlich, keine Nacharbeit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Weitere plattformspezifische Punkte
|
||||||
|
|
||||||
|
### 5.1 Dateisystem – erfreulich sauber
|
||||||
|
|
||||||
|
| Prüfpunkt | Befund |
|
||||||
|
|---|---|
|
||||||
|
| `Path.Combine` | **17 von 17** korrekt, durchgängig über `AppContext.BaseDirectory` |
|
||||||
|
| `Environment.SpecialFolder` | **0 Treffer** |
|
||||||
|
| Harte Windows-Pfade | **genau 1**: `ServerSettings.cs:73` – `MullvadCliPath` (konfigurierbar, nur der Default ist Windows) |
|
||||||
|
| `DllImport` / `LibraryImport` | **0 Treffer** |
|
||||||
|
| Registry / WMI / DPAPI / EventLog | **0 Treffer** im eigenen Code |
|
||||||
|
|
||||||
|
**Groß-/Kleinschreibung:** Linux ist case-sensitive. Kritisch: `server_settings.xml`, `appsettings.Local.json`, `master.key`, `openrouter.key`, `Logs/`, `favicon.ico`, die 24 PNGs in `Resources/`. Im Code konsistent – trotzdem beim ersten Linux-Build gezielt prüfen, ebenso die `\`-Pfade in den `.csproj` (MSBuild normalisiert sie, aber es ist eine bekannte Stolperstelle).
|
||||||
|
|
||||||
|
**Dateirechte:** `master.key` und `openrouter.key` liegen als Klartext neben der Anwendung (`SettingsView.cs:301,356`, `Program.cs:393`, `OpenRouterClient.cs:46`). Auf Linux erzeugt `File.WriteAllText` mit Standard-umask **`644` – world-readable**. Nach jedem Schreiben `File.SetUnixFileMode(path, UserRead | UserWrite)` (.NET 7+). Ergänzt Befund F1 des Sicherheitskonzepts.
|
||||||
|
|
||||||
|
> Das Deploymentcenter macht es beim Lizenz-Cache bereits richtig (`chmod 600 state.dat`, siehe LICENSE_INTEGRATION_GUIDE Abschnitt 3) – dieselbe Konvention für die eigenen Schlüsseldateien übernehmen.
|
||||||
|
|
||||||
|
**Zeilenenden:** `Environment.NewLine` wechselt von `\r\n` auf `\n`. `CsvExporter.cs` nutzt bereits explizites `'\n'` – korrekt.
|
||||||
|
|
||||||
|
### 5.2 Lizenz & Watchdog – jetzt über das Deploymentcenter (Entscheidung 5)
|
||||||
|
|
||||||
|
**Das war in Revision 1 ein Blocker (R4) – er ist gelöst.** Aus `J:\Softwareprojekte\Deploymentcenter\docs`:
|
||||||
|
|
||||||
|
| Thema | Alt (`LicenseLabrador.Client` 1.0.0) | Neu (Deploymentcenter, HWID v2) |
|
||||||
|
|---|---|---|
|
||||||
|
| Hardware-ID | Windows: Registry `MachineGuid`; Linux: `/etc/machine-id` → **plattformabhängig verschieden** | `2:<plattform>:<sha256>` mit dokumentierter Quellen-Priorisierung je Plattform |
|
||||||
|
| Container | jede Instanz eine neue ID → Lizenz invalidiert bei jedem Neustart | **`DEPLOYMENTCENTER_HWID`-Override** gewinnt plattformweit |
|
||||||
|
| Rechnername im Hash | — | **bewusst nicht enthalten**, Umbenennen kostet keinen Aktivierungsplatz |
|
||||||
|
| Migration | — | Client sendet `hardware_id` (v2) **und** `legacy_hardware_id` (v1); Server zieht die Aktivierung lautlos um, **ohne zusätzlichen Platz** |
|
||||||
|
| Headless | modaler Dialog blockiert den Start | CLI-Schalter `--license-status`, `--license-set-key`, `--license-deactivate` **plus** `ILicensePrompt`/`ConsoleLicensePrompt` als UI-Abstraktion |
|
||||||
|
| Cache | — | `LLS2`-Envelope, AES-256-GCM, HKDF aus HWID+Slug, Linux `0600`, kein Klartext-Rückfall |
|
||||||
|
| SDK | `netstandard2.0` | `Deploymentcenter.Client`, Multi-Targeting `netstandard2.0;net8.0` |
|
||||||
|
|
||||||
|
**Damit entfallen zwei Risiken vollständig:** die Neubindung der Lizenz beim Plattformwechsel (Migration v1→v2 ist serverseitig gelöst) und der Container-Neustart-Effekt.
|
||||||
|
|
||||||
|
**Verbleibende Arbeit** – ein Umbau, kein Blocker:
|
||||||
|
|
||||||
|
1. **Lizenz:** `Licensing/LicenseGate.cs` und `Ui/LicenseDialog.cs` von `LicenseLabrador.Client` auf `Deploymentcenter.Client` umstellen. Die API unterscheidet sich: `LicenseResult`/`LicenseState`/`GetHardwareId()` → `LicenseValidationResult` (`IsValid`, `Status`, `IsCached`, `Message`) / `HardwareId.GetHardwareId(slug)` / `ValidateAsync(slug, key, serverUrl)`. **`ILicensePrompt` implementieren** statt den Dialog direkt aufzurufen – das ist genau die Naht, die den headless-Betrieb und später die Avalonia-UI trägt. Das lokale NuGet-Paket in `lib/nuget/` wird ersetzt.
|
||||||
|
2. **Watchdog:** `WatchdogHeartbeatService` sendet bereits `source`, `instance`, `interval` – **das Payload-Schema passt schon**. Zu ändern sind nur Header (`X-Watchdog-Key` → `X-Agent-Token`) und Ziel-URL (`watchdog.mhdf.de` → `dc.mhdf.de/api/watchdog/v1/ping`). Ergänzen: `os`- und `group`-Feld, sowie der im Guide empfohlene Abschluss-Ping beim geordneten Herunterfahren.
|
||||||
|
|
||||||
|
**Aufwand:** ~2–3 PT. **Empfehlung: in Stufe L1 mitnehmen**, nicht später – der headless-Betrieb (L2) hängt an `ILicensePrompt`.
|
||||||
|
|
||||||
|
> **Sicherheitshinweis am Rande:** `server_settings.xml` enthält den Watchdog-Token und den Lizenzschlüssel im Klartext (`WatchdogToken`, `LicenseKey`). Beim Umbau auf das Deploymentcenter ist der passende Moment, beide über die `SecretProtection` laufen zu lassen oder auf Umgebungsvariablen umzustellen – passend zur bereits offenen Auflage, die Watchdog-Secrets zu rotieren.
|
||||||
|
|
||||||
|
### 5.3 Threema – entfernt ✅ (Entscheidung 7)
|
||||||
|
|
||||||
|
**Am 06.08.2026 umgesetzt.** Nachfolgekanäle werden **RocketChat und Telegram**.
|
||||||
|
|
||||||
|
**Was entfernt wurde:**
|
||||||
|
|
||||||
|
| Entfernt | Umfang |
|
||||||
|
|---|---|
|
||||||
|
| `src/PolyTrader.Core/Services/ThreemaService.cs` | 1 Datei (`BackgroundService` + Webhook-Listener) |
|
||||||
|
| `libs/Threema-MsgApi-Net-Core` (vendorte Fremdbibliothek) | 35 Dateien, aus Solution und Git entfernt (History bleibt) |
|
||||||
|
| Projektreferenz in `PolyTrader.Core.csproj` | 1 |
|
||||||
|
| Threema-Block in `ServerSettings` (7 Properties, `[Category("Threema Notifications")]`) | 36 Zeilen |
|
||||||
|
| DI-Registrierungen in `Program.cs` (Singleton + HostedService + View-Injektion) | 3 |
|
||||||
|
| `ThreemaService`-Verdrahtung in `SettingsView` | 3 Stellen |
|
||||||
|
|
||||||
|
**Was an die Stelle getreten ist — `INotificationSink`:**
|
||||||
|
|
||||||
|
```
|
||||||
|
PolyTrader.Core/Notifications/
|
||||||
|
├─ INotificationSink.cs Schnittstelle + NotificationSeverity (Info/Warning/Critical)
|
||||||
|
└─ LogNotificationSink.cs Übergangs-Implementierung: schreibt ins Terminal-Log
|
||||||
|
```
|
||||||
|
|
||||||
|
Die drei Fachdienste kennen jetzt nur noch die Schnittstelle:
|
||||||
|
|
||||||
|
| Dienst | Meldung | Severity |
|
||||||
|
|---|---|---|
|
||||||
|
| `MasterTraderAnalyticsJob` | Auto-Pause eines Master-Traders | `Warning` |
|
||||||
|
| `SellLadderService` | SELL-Floor ohne Fill erreicht, Position wird gehalten | `Critical` |
|
||||||
|
| `DailyReportService` | täglicher Supervisor-Kurzbericht | `Info` |
|
||||||
|
|
||||||
|
**Zwei Entwurfsentscheidungen, die sich später auszahlen:**
|
||||||
|
|
||||||
|
1. **No-Throw-Vertrag.** `SendAsync` wirft laut Schnittstellen-Doku **niemals** — Kanalfehler werden intern abgefangen und protokolliert. Das ist genau die Best-Practice aus dem Watchdog-Guide („Fange Netzwerkfehler stets stumm ab, damit der Ausfall des Monitoring-Servers niemals den Hauptanwendungsfluss unterbricht"). Die Aufrufer verzichten deshalb bewusst auf eigene `try/catch`-Blöcke — der Code wurde dabei kürzer, nicht länger. **Bindend für die kommenden RocketChat- und Telegram-Implementierungen.**
|
||||||
|
2. **`NotificationSeverity`** ist von Anfang an dabei, obwohl die Log-Implementierung sie kaum nutzt. Bei zwei Kanälen (RocketChat für alles, Telegram als Fallback für Kritisches — oder umgekehrt) ist die Einstufung die natürliche Routing-Information. Sie nachträglich einzuziehen hätte alle Aufrufstellen erneut angefasst.
|
||||||
|
|
||||||
|
**Nebeneffekte — vier Linux-Themen sind mit weggefallen:**
|
||||||
|
|
||||||
|
| Entfallen | War |
|
||||||
|
|---|---|
|
||||||
|
| `libsodium` 1.0.16 (Baujahr 2018) | Risiko R7: `.so` gegen alte glibc gelinkt, Ladefehler auf Ubuntu 24.04 möglich |
|
||||||
|
| `Microsoft.Win32.Registry` (`E2EHelper.cs`, `#if CoreWinOnly`) | **die einzige Registry-Nutzung im gesamten Build** |
|
||||||
|
| `HttpListener` auf `http://*:{port}/` (Webhook) | Port-Bindung + `netsh`-Fehlermeldung mit Windows-Semantik |
|
||||||
|
| `System.Web.HttpUtility` | — |
|
||||||
|
|
||||||
|
**Verifikation:** Clean-Build fehlerfrei, **448 Tests grün**, Compiler-Warnungen von **17 auf 9** gesunken (die 8 Warnungen der Threema-Bibliothek — 4× `SYSLIB0014`, 3× `CA1416`, 1× `CS0472` — sind weg).
|
||||||
|
|
||||||
|
**Testverbesserung nebenbei:** Die drei Tests, die bisher `new ThreemaService(logger, new JobManager())` als Wegwerf-Instanz injizierten, nutzen jetzt `FakeNotificationSink` — damit ist **prüfbar, ob und mit welcher Severity gemeldet wurde**. Vorher war das nicht beobachtbar.
|
||||||
|
|
||||||
|
**Offen für später:** `RocketChatNotificationSink` und `TelegramNotificationSink` implementieren und in `Program.cs` registrieren — eine Klasse je Kanal, keine Änderung an der Fachlogik. Bei mehreren aktiven Kanälen bietet sich ein `CompositeNotificationSink` an (leitet an alle weiter, jeder Kanal fängt seine Fehler selbst ab).
|
||||||
|
|
||||||
|
### 5.4 Verbleibende Netzwerkdienste
|
||||||
|
|
||||||
|
- **`HttpListener`** – nach dem Threema-Ausbau nur noch der MCP-Light-Server (`127.0.0.1:{port}/mcp`). Läuft auf Linux über die managed Implementierung, kein `netsh` nötig. Unkritisch.
|
||||||
|
- **`MullvadVpnService`**: Der CLI-Aufruf ist portabel (`UseShellExecute = false`, sauber). Auf Linux heißt die CLI `mullvad` (typisch `/usr/bin/mullvad`). **Achtung:** `HealthCheckAsync()` prüft `status.Contains("Connected")` – der Wortlaut der Linux-CLI weicht ab. **Muss gegen die echte CLI verifiziert werden**, sonst reconnectet der Dienst dauerhaft ins Leere und meldet nach 3 Fehlversuchen „unrecoverable".
|
||||||
|
- **Nethereum 6.1.0, Pomelo/EF Core 8, `HttpClient`, `ClientWebSocket`, `System.Text.Json`, AES-GCM** (OpenSSL): plattformneutral, **keine Anpassung nötig**. Das ist der gesamte Trading-Pfad.
|
||||||
|
|
||||||
|
### 5.5 Windows-UI als Fallback: Variante 2 (Entscheidung 3)
|
||||||
|
|
||||||
|
Heute laufen Generic Host und `Application.Run` im **selben Prozess**; darunter **19 `BackgroundService`-Instanzen**. Auf Linux gibt es kein „WinExe" – eine Avalonia-App braucht X11/Wayland, ein Trading-Server hat typischerweise kein Display.
|
||||||
|
|
||||||
|
**Entschieden: Variante 2 – eingefrorener Stand.** Die WinForms-UI bleibt auf dem heutigen Stand und wird nicht weiterentwickelt; später verschwindet sie **vollständig aus dem Build**, damit keine Altlast mitgeschleppt wird.
|
||||||
|
|
||||||
|
**Was das konkret heißt:**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Einfrieren** | Vor Beginn von L3 einen Git-Tag setzen (Vorschlag: `winforms-final`) und im Repo dokumentieren. Ab da keine Feature-Arbeit mehr an `Ui/` und den `Ui/`-Ordnern der Module. |
|
||||||
|
| **Fallback im Ernstfall** | Tag auschecken, bauen, starten. Greift auf dieselbe MySQL zu — Anzeige und Einstellungen bleiben voll brauchbar. |
|
||||||
|
| **Weiterentwicklung** | Läuft unverändert weiter — nur eben im **Trading-Kern**, der ab L2 headless auf Linux läuft. Eingefroren ist die *Oberfläche*, nicht die Funktion. |
|
||||||
|
| **Endgültiger Ausbau** | Nach Abschluss von L4: `Ui/`-Ordner, `PolyTrader.App` als `WinExe`, alle `.Designer.cs`/`.resx`, `UseWindowsForms`, `favicon.ico`-Einbindung und `ApplicationConfiguration.Initialize()` entfernen. **~4.500 LOC generierter Designer-Code fallen ersatzlos weg.** |
|
||||||
|
|
||||||
|
**Wichtig für die Reihenfolge:** Damit der eingefrorene Stand überhaupt lauffähig bleibt, darf **P1 die WinForms-UI nicht kaputtmachen**. Beim Entkoppeln von Core und Modulen (Zielframework `net8.0`, `System.Drawing` raus) muss der WinForms-Zweig bis zum Tag baubar bleiben — praktisch heisst das: **erst taggen, dann P1 durchziehen**. Sonst ist der Fallback genau in dem Moment weg, in dem man ihn braucht.
|
||||||
|
|
||||||
|
> **Konkreter Vorschlag zur Absicherung:** Vor P1 einmal `dotnet publish -c Release` des heutigen Stands erzeugen und das Ergebnis zusammen mit dem Tag archivieren. Ein fertig gebautes Verzeichnis ist im Ernstfall schneller einsatzbereit als ein Checkout, der erst wieder kompiliert werden muss — und beweist nebenbei, dass der eingefrorene Stand tatsächlich läuft.
|
||||||
|
|
||||||
|
**Unabhängig davon nötig:** headless-fähiger Einstiegspunkt für den Linux-Betrieb, systemd-Unit mit `Restart=always` und `Environment=TZ=…`, Master-Key über `EnvironmentFile` mit `chmod 600`, Logrotate für `Logs/*.jsonl`.
|
||||||
|
|
||||||
|
### 5.6 Tests und CI
|
||||||
|
|
||||||
|
- Testprojekt auf `net8.0` (fällt mit den Referenzen).
|
||||||
|
- `WindowMenuTests.cs` – einziger UI-Test, neu schreiben (~0,5 PT).
|
||||||
|
- `TradeRowColoringTests.cs` – zieht mit dem Farbtyp-Wechsel mit (~0,2 PT).
|
||||||
|
- `AccountingReportTests.cs:131` – braucht den neuen FontResolver.
|
||||||
|
- **`--smoke-ui`** (`Program.cs:500-570`): konstruiert alle Views headless und hat laut Kommentar bereits Designer-Regressionen gefangen. **Unbedingt erhalten** – Avalonia bietet `Avalonia.Headless`, das Muster überträgt sich gut.
|
||||||
|
- **Es gibt keine CI.** Die Portierung ist der richtige Anlass, `dotnet build` + `dotnet test` auf einem Linux-Runner einzurichten – sonst schleicht sich die Windows-Abhängigkeit unbemerkt zurück.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Aufwandsschätzung (Revision 2)
|
||||||
|
|
||||||
|
| # | Paket | Änderung ggü. Rev. 1 | Aufwand |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| ~~L0~~ | ~~Kultur-Bug~~ | **erledigt** ✅ | ~~0,5 PT~~ |
|
||||||
|
| **P0** | Zielbild, Avalonia-Spike an einer echten View, Layout-Konventionen festzurren | — | **2–3 PT** |
|
||||||
|
| **P1** | Core/Module von WinForms entkoppeln, `TargetFramework` auf `net8.0`, `System.Drawing` raus | — | **2–3 PT** |
|
||||||
|
| **P2** | Kultur & Zeit: `IAppClock`, Zeitzone bei Installation festlegen, Kultur pinnen, Analyzer | **−1 PT** (Entscheidung 6 vereinfacht) | **2–3 PT** |
|
||||||
|
| **P3** | Plattform-Kleinkram: Mullvad-Pfad + Status-Parsing, Unix-Dateirechte | **−0,5 PT** (Threema entfällt) | **1 PT** |
|
||||||
|
| ~~**P3b**~~ | ~~Threema-Ausbau + `INotificationSink`~~ | **erledigt 06.08.2026** ✅ | ~~2 PT~~ |
|
||||||
|
| **P3c** | **Lizenz/Watchdog auf Deploymentcenter umstellen** | **neu** (Entscheidung 5) | **2–3 PT** |
|
||||||
|
| **P4** | PDF-Export: PDFsharp Core + `IFontResolver` + eingebettete Schrift | — | **1–2 PT** |
|
||||||
|
| **P5** | Charts auf LiveCharts2 | **+1 PT** (Entscheidung 8) | **2 PT** |
|
||||||
|
| **P6** | UI-Shell: Launcher, Fenstermenü (deklarativ!), `ShellUiHost`, Shutdown-, Lizenz-Dialog, Widgets | — | **5–7 PT** |
|
||||||
|
| **P7** | Core-Views: Dashboard, Settings, Jobs, Terminal (inkl. **2 PropertyGrid-Ersätze** + `RichTextBox`) | **+3 PT** (Entscheidung 4) | **11–15 PT** |
|
||||||
|
| **P8** | Modul-Fenster: CopyTrading (5), ResolutionFarming, Supervisor, Accounting (inkl. **4 PropertyGrid-Ersätze**, 20 DataGrids) | **+2 PT** (Entscheidung 4) | **14–20 PT** |
|
||||||
|
| **P9** | Tests, `--smoke-ui` auf `Avalonia.Headless`, Linux-CI | — | **3–4 PT** |
|
||||||
|
| **P10** | Deployment: systemd, TZ, Logrotate, Feldtest im Zielland | **−0,5 PT** (Lizenz-Neubindung entfällt) | **3–4 PT** |
|
||||||
|
| **P11** | **WinForms endgültig ausbauen** (nach L4): `Ui/`, Designer-Dateien, `UseWindowsForms`, `WinExe` | **neu** (Entscheidung 3) | **1 PT** |
|
||||||
|
| | **Summe (offen)** | | **41–61 PT** |
|
||||||
|
|
||||||
|
*Personentage entwickelnder Arbeit. Mit Agentenunterstützung sinkt die Kalenderzeit deutlich; der Review- und Feldtest-Anteil (P9, P10) bleibt weitgehend gleich – bei einer Anwendung, die echtes Geld bewegt, ist das die richtige Reihenfolge.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Fahrplan
|
||||||
|
|
||||||
|
### Die zentrale Erkenntnis bleibt
|
||||||
|
|
||||||
|
**P1–P4 brauchen kein Avalonia.** Sie sind auf Windows entwickelbar und verifizierbar. Zusammen mit einem headless-Einstiegspunkt hast du für **~15 PT von rund 50** den eigentlichen Nutzen:
|
||||||
|
|
||||||
|
> Der **Bot läuft auf Linux**. Die **WinForms-UI bleibt als eingefrorener Fallback** (Entscheidung 3) und greift über dieselbe MySQL auf dieselben Daten zu.
|
||||||
|
|
||||||
|
| Stufe | Inhalt | Aufwand | Ergebnis |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| ~~**L0**~~ | ~~Kultur-Bug~~ | — | ✅ **erledigt** – Faktor-100-Preisfehler behoben, 10 Regressionstests |
|
||||||
|
| ~~**L0b**~~ | ~~Threema-Ausbau + `INotificationSink`~~ | — | ✅ **erledigt** – 36 Dateien raus, 448 Tests grün, 8 Warnungen weniger |
|
||||||
|
| **L1** | Tag `winforms-final` setzen → dann P0 + P1 + P2 + P3 + P3c + P4 | 11–15 PT | Core/Module sind `net8.0` und plattformneutral; Zeitzone bei Installation festlegbar; Deploymentcenter angebunden; Tests laufen auf Linux |
|
||||||
|
| **L2** | Headless-Einstiegspunkt + systemd + Feldtest im Zielland | 3–5 PT | **Trading läuft headless auf Linux.** ⭐ Meilenstein |
|
||||||
|
| **L3** | P5 + P6 + P7 (Charts, Shell, Core-Views) | 18–24 PT | Avalonia-Grundgerüst steht; Dashboard, Settings, Terminal nutzbar |
|
||||||
|
| **L4** | P8 + P9 (Modul-Fenster, Tests, CI) | 17–24 PT | WinForms funktional abgelöst |
|
||||||
|
| **L5** | P11 – WinForms endgültig aus dem Build entfernen | 1 PT | Keine Altlast mehr; ~4.500 LOC Designer-Code fallen weg |
|
||||||
|
|
||||||
|
### Reihenfolge-Begründung
|
||||||
|
|
||||||
|
- **Der Tag `winforms-final` kommt VOR P1.** P1 entkoppelt Core und Module von WinForms — danach ist der alte Stand nicht mehr aus dem Hauptzweig baubar. Erst taggen (und einmal `dotnet publish` archivieren), dann umbauen. Sonst ist der Fallback genau dann weg, wenn man ihn braucht.
|
||||||
|
- **P3c (Deploymentcenter) gehört nach L1, nicht später** – der headless-Betrieb in L2 hängt an der `ILicensePrompt`-Abstraktion. Ohne sie blockiert der Lizenzdialog den Start auf einem Server ohne Display.
|
||||||
|
- **P2 vor L2** – die Zeitzone muss feststehen, bevor der erste Linux-Server Logs und Buchungen schreibt. Nachträglich umzustellen bedeutet gemischte Daten an der Tagesgrenze.
|
||||||
|
|
||||||
|
### Noch offene Punkte
|
||||||
|
|
||||||
|
1. **Accounting-Zeitzone getrennt von der Anwendungszeitzone?** ([3.3](#33-anforderung-konfigurierbare-zeitzone-entscheidung-6)) – Empfehlung ja, verhindert rückwirkend verschobene Buchungsperioden bei einem Standortwechsel.
|
||||||
|
2. **Anzeige-Kultur ebenfalls bei der Installation festlegbar?** ([4.2](#42-ausgabeformatierung-offen)) – naheliegend, da dieselbe Begründung wie bei der Zeitzone (Auslandsbetrieb).
|
||||||
|
3. **RocketChat und Telegram:** Wann sollen die echten `INotificationSink`-Implementierungen kommen? Bis dahin landen Auto-Pause- und Eskalationsmeldungen nur im Terminal-Log. Für den unbeaufsichtigten Linux-Betrieb (L2) wäre mindestens **ein** echter Kanal wichtig — sonst bemerkst du eine Auto-Pause erst beim nächsten Blick ins Log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risikoliste (Revision 3)
|
||||||
|
|
||||||
|
| # | Risiko | Status | Wirkung / Gegenmaßnahme |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ~~R1~~ | Kulturabhängiger Parse, Faktor-100-Preisfehler | ✅ **behoben** | gefixt + 10 Regressionstests unter de-DE |
|
||||||
|
| ~~R4~~ | Lizenz-Hardware-ID ändert sich, in Containern instabil | ✅ **gelöst** | Deploymentcenter HWID v2 mit `DEPLOYMENTCENTER_HWID`-Override und v1→v2-Migration ohne Platzverlust |
|
||||||
|
| ~~R7~~ | libsodium 1.0.16 gegen moderne glibc | ✅ **entfallen** | mit dem Threema-Ausbau am 06.08.2026 |
|
||||||
|
| **R2** | Kein WYSIWYG-Designer; Layout-Regel ist neu | offen | Im Spike (P0) an einer echten View erproben, bevor 16 Views folgen |
|
||||||
|
| **R3** | `PropertyGrid`-Ersatz durch handgebaute Formulare | offen | Teuerster Posten (~10 PT). `[Description]`-Attribute liefern die Spezifikation |
|
||||||
|
| **R5** | Zeitzonen-Semantikwechsel trifft Logs, Tagesberichte, **Accounting-Perioden** | offen | P2 **vor** L2; `IAppClock`; Wirksamkeit erst nach Neustart |
|
||||||
|
| **R6** | Headless-Server ohne Display | offen | `ILicensePrompt`/`ConsoleLicensePrompt` aus dem Deploymentcenter-SDK; headless-Einstiegspunkt in L2 |
|
||||||
|
| **R8** | Anzeigeformate kippen auf Invariant (`LANG` nicht gesetzt) | offen | Kultur in `Program.Main` pinnen |
|
||||||
|
| **R9** | Keine CI – Windows-Abhängigkeiten schleichen zurück | offen | Linux-Build in Gitea Actions ab L1 |
|
||||||
|
| **R10** | Mullvad-CLI meldet auf Linux anderen Statustext | offen | Gegen die echte CLI im Zielland verifizieren (5.4) |
|
||||||
|
| ~~R11~~ | Doppelpflege beider UIs | ✅ **entfällt** | Variante 2 gewählt (eingefrorener Stand) |
|
||||||
|
| ~~R12~~ | Benachrichtigungen fallen still | ✅ **entschärft** | `INotificationSink` eingezogen; Meldungen landen im Terminal-Log |
|
||||||
|
| **R13** | **Kein echter Benachrichtigungskanal** bis RocketChat/Telegram da sind | offen | Für den unbeaufsichtigten Betrieb (L2) mindestens einen Kanal implementieren — sonst bleibt eine Auto-Pause bis zum nächsten Log-Blick unbemerkt |
|
||||||
|
| **R14** | **Fallback-Stand nicht mehr baubar**, weil P1 vor dem Tag lief | offen | Tag `winforms-final` + archiviertes `dotnet publish` **vor** P1 (5.5) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Was **nicht** angepasst werden muss
|
||||||
|
|
||||||
|
- **Der gesamte Trading-Pfad:** `PolymarketClobClient` (EIP-712 über Nethereum), `PolymarketApiService`, `PolymarketWssClient`, `AlchemyWssClient`, `CopyTradingEngine`, `SellLadderService`, `TraderMonitorService`
|
||||||
|
- **Persistenz:** EF Core 8 + Pomelo/MySQL, alle 15 Migrationen, `EncryptedStringConverter`
|
||||||
|
- **Sicherheit:** `SecretProtection` (AES-256-GCM läuft auf Linux über OpenSSL), Master-Key-Ableitung
|
||||||
|
- **Alle Analytics:** `TradeAnalytics`, `DossierBuilder`, Supervisor-Agent, OpenRouter-Client, MCP-Light-Server
|
||||||
|
- **Accounting-Logik:** Ingest, Klassifikation, BWA, CSV-Export (nur der PDF-Export braucht den FontResolver)
|
||||||
|
- **Watchdog-Payload-Schema** (`source`/`instance`/`interval` passen bereits zur Deploymentcenter-API), Modularitäts-Infrastruktur
|
||||||
|
- **438 von 448 Tests**
|
||||||
|
|
||||||
|
Das ist der Grund, warum die Schätzung trotz 24.000 LOC bei 41–61 PT landet und nicht bei 150: **Zwei Drittel des Codes sind bereits plattformneutral, und die Trennung ist sauber.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Anhang: verifizierte Fundstellen
|
||||||
|
|
||||||
|
### Behoben am 06.08.2026
|
||||||
|
|
||||||
|
| Thema | Datei:Zeile |
|
||||||
|
|---|---|
|
||||||
|
| Kulturabhängiger Preis-Parse (Faktor 100) | `…CopyTrading/Services/TraderMonitorService.cs:1276-1280` → nutzt jetzt `ParseDecimal` |
|
||||||
|
| `ParseDecimal` testbar + dokumentiert | `…CopyTrading/Services/TraderMonitorService.cs:1223-1234` |
|
||||||
|
| Kulturabhängige Parses (Regex-Gruppen) | `…Core/Services/PolymarketClobClient.cs:770, 784, 796, 820, 822, 827` |
|
||||||
|
| Kulturabhängiger Unix-Timestamp | `…CopyTrading/Services/MasterTraderAnalyticsJob.cs:140` |
|
||||||
|
| Neuer Regressionstest (10 Fälle, de-DE) | `tests/PolyTrader.Tests/ApiNumberParsingTests.cs` |
|
||||||
|
| Threema vollständig entfernt | `ThreemaService.cs`, `libs/Threema-MsgApi-Net-Core/` (35 Dateien), `ServerSettings` (7 Properties), `Program.cs`, `SettingsView.cs`, `PolyTrader.Core.csproj`, `PolyTraderSharp.sln` |
|
||||||
|
| Neutraler Benachrichtigungsausgang | `src/PolyTrader.Core/Notifications/INotificationSink.cs`, `LogNotificationSink.cs` |
|
||||||
|
| Fachdienste auf `INotificationSink` umgestellt | `MasterTraderAnalyticsJob.cs`, `SellLadderService.cs`, `DailyReportService.cs` |
|
||||||
|
| Testdouble mit Assertion-Möglichkeit | `tests/PolyTrader.Tests/Fakes/FakeNotificationSink.cs` |
|
||||||
|
|
||||||
|
### Offen
|
||||||
|
|
||||||
|
| Thema | Datei:Zeile |
|
||||||
|
|---|---|
|
||||||
|
| Windows-Zeitzonen-ID hartcodiert | `Ui/Views/TerminalView.cs:150` |
|
||||||
|
| Harter Windows-Pfad (Mullvad) | `src/PolyTrader.Core/Models/ServerSettings.cs:73` |
|
||||||
|
| Mullvad-Statusprüfung (Wortlaut) | `src/PolyTrader.Core/Services/MullvadVpnService.cs:HealthCheckAsync` |
|
||||||
|
| UI-Contract mit WinForms-Typen | `src/PolyTrader.Core/Modularity/ModuleUi.cs:32, 36` |
|
||||||
|
| Menü wird zur Laufzeit gebaut (neue Layout-Regel) | `src/PolyTrader.Core/Modularity/WindowMenu.cs:Populate`, `Ui/ShellUiHost.cs:AttachWindowMenu` |
|
||||||
|
| `System.Drawing.Color` in Modul-Logik | `…CopyTrading/Logic/TradeRowColoring.cs:11-13` |
|
||||||
|
| ScottPlot → `System.Drawing.Bitmap` | `Ui/Views/DashboardView.cs:207-227` |
|
||||||
|
| Windows-only Schrift im PDF | `…Accounting/Logic/PdfExporter.cs:34` |
|
||||||
|
| Schlüsseldateien ohne Rechteschutz | `Ui/Views/SettingsView.cs:301, 356`; `Program.cs:393` |
|
||||||
|
| Headless-Smoke-Test (erhaltenswert) | `Program.cs:500-570` |
|
||||||
|
| Lizenz-Gate mit modalem Dialog | `Licensing/LicenseGate.cs`, `Program.cs:98` |
|
||||||
|
| Secrets im Klartext | `server_settings.xml` (`WatchdogToken`, `LicenseKey`) |
|
||||||
|
|
||||||
|
### Referenzen
|
||||||
|
|
||||||
|
- `J:\Softwareprojekte\Deploymentcenter\docs\LICENSE_INTEGRATION_GUIDE.md` – Hardware-ID v2, `LLS2`-Cache, CLI-Schalter, v1→v2-Migration
|
||||||
|
- `J:\Softwareprojekte\Deploymentcenter\docs\WATCHDOG_INTEGRATION_GUIDE.md` – Ping-API, `X-Agent-Token`, Payload-Schema
|
||||||
|
- `J:\Softwareprojekte\Deploymentcenter\client-dotnet\Deploymentcenter.Client` – SDK-Quellen (`LicenseClient`, `HardwareId`, `ILicensePrompt`, `StateStore`)
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Configuration;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Exceptions;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Results;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Facilitates HTTPS communication with the Threema Message API.
|
|
||||||
/// </summary>
|
|
||||||
public class APIConnector
|
|
||||||
{
|
|
||||||
public const string DEFAULTAPIURL = "https://msgapi.threema.ch/";
|
|
||||||
|
|
||||||
private readonly string apiUrl;
|
|
||||||
private readonly PublicKeyStore publicKeyStore;
|
|
||||||
private readonly string apiIdentity;
|
|
||||||
private readonly string secret;
|
|
||||||
|
|
||||||
public APIConnector(string apiIdentity, string secret, PublicKeyStore publicKeyStore) :
|
|
||||||
this(apiIdentity, secret, APIConnector.DEFAULTAPIURL, publicKeyStore)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
public APIConnector(string apiIdentity, string secret, string apiUrl, PublicKeyStore publicKeyStore)
|
|
||||||
{
|
|
||||||
this.apiIdentity = apiIdentity;
|
|
||||||
this.secret = secret;
|
|
||||||
this.apiUrl = apiUrl;
|
|
||||||
|
|
||||||
if (publicKeyStore != null)
|
|
||||||
{
|
|
||||||
this.publicKeyStore = publicKeyStore;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
this.publicKeyStore = this.GetSQLiteDbProvider();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lookup credits for an ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>credits or null</returns>
|
|
||||||
public int? LookupCredits()
|
|
||||||
{
|
|
||||||
string res = DoGet(new Uri(this.apiUrl + "credits"),
|
|
||||||
MakeRequestParams());
|
|
||||||
if(res != null)
|
|
||||||
{
|
|
||||||
int credits;
|
|
||||||
int.TryParse(res, out credits);
|
|
||||||
return credits;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lookup an ID by email address. The email address will be hashed before
|
|
||||||
/// being sent to the server.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="email">the email address</param>
|
|
||||||
/// <returns>the ID, or null if not found</returns>
|
|
||||||
public string LookupEmail(string email)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Dictionary<string, string> getParams = MakeRequestParams();
|
|
||||||
|
|
||||||
byte[] emailHash = CryptTool.HashEmail(email);
|
|
||||||
|
|
||||||
return DoGet(new Uri(this.apiUrl + "lookup/email_hash/" + DataUtils.ByteArrayToHexString(emailHash)), getParams);
|
|
||||||
}
|
|
||||||
catch (FileNotFoundException)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lookup a public key by ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The ID whose public key is desired</param>
|
|
||||||
/// <returns>The corresponding public key, or null if not found</returns>
|
|
||||||
public byte[] LookupKey(string id)
|
|
||||||
{
|
|
||||||
byte[] key = this.publicKeyStore.GetPublicKey(id);
|
|
||||||
if (key == null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Dictionary<string, string> getParams = MakeRequestParams();
|
|
||||||
string pubkeyHex = DoGet(new Uri(this.apiUrl + "pubkeys/" + id), getParams);
|
|
||||||
key = DataUtils.HexStringToByteArray(pubkeyHex);
|
|
||||||
|
|
||||||
this.publicKeyStore.SetPublicKey(id, key);
|
|
||||||
}
|
|
||||||
catch (FileNotFoundException)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lookup the capabilities of a ID
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="threemaId">The ID whose capabilities should be checked</param>
|
|
||||||
/// <returns>The capabilities, or null if not found</returns>
|
|
||||||
public CapabilityResult LookupKeyCapability(string threemaId)
|
|
||||||
{
|
|
||||||
string res = DoGet(new Uri(this.apiUrl + "capabilities/" + threemaId),
|
|
||||||
MakeRequestParams());
|
|
||||||
if (res != null)
|
|
||||||
{
|
|
||||||
return new CapabilityResult(threemaId, res.Split(','));
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lookup an ID by phone number. The phone number will be hashed before
|
|
||||||
/// being sent to the server.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="phoneNumber">the phone number in E.164 format</param>
|
|
||||||
/// <returns>the ID, or null if not found</returns>
|
|
||||||
public string LookupPhone(string phoneNumber)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Dictionary<string, string> getParams = MakeRequestParams();
|
|
||||||
|
|
||||||
byte[] phoneHash = CryptTool.HashPhoneNo(phoneNumber);
|
|
||||||
|
|
||||||
return DoGet(new Uri(this.apiUrl + "lookup/phone_hash/" + DataUtils.ByteArrayToHexString(phoneHash)), getParams);
|
|
||||||
}
|
|
||||||
catch (FileNotFoundException)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Download a file given its blob ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="blobId">The blob ID of the file</param>
|
|
||||||
/// <returns>Encrypted file data</returns>
|
|
||||||
public byte[] DownloadFile(byte[] blobId)
|
|
||||||
{
|
|
||||||
return this.DownloadFile(blobId, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Download a file given its blob ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="blobId">The blob ID of the file</param>
|
|
||||||
/// <param name="progressListener">An object that will receive progress information, or null</param>
|
|
||||||
/// <returns>Encrypted file data</returns>
|
|
||||||
public byte[] DownloadFile(byte[] blobId, IProgressListener progressListener)
|
|
||||||
{
|
|
||||||
string queryString = MakeUrlEncoded(MakeRequestParams());
|
|
||||||
Uri blobUrl = new Uri(string.Format(this.apiUrl + "blobs/{0}?{1}",
|
|
||||||
DataUtils.ByteArrayToHexString(blobId), queryString));
|
|
||||||
|
|
||||||
byte[] blob;
|
|
||||||
|
|
||||||
WebRequest request = WebRequest.CreateHttp(blobUrl);
|
|
||||||
request.Method = "GET";
|
|
||||||
request.Timeout = 20 * 1000;
|
|
||||||
|
|
||||||
using (WebResponse response = request.GetResponse())
|
|
||||||
using (Stream stream = response.GetResponseStream())
|
|
||||||
{
|
|
||||||
blob = DataUtils.StreamToBytes(stream, progressListener);
|
|
||||||
|
|
||||||
stream.Close();
|
|
||||||
response.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
return blob;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Upload a file.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fileEncryptionResult">The result of the file encryption (i.e. encrypted file data)</param>
|
|
||||||
/// <returns>the result of the upload</returns>
|
|
||||||
public UploadResult UploadFile(EncryptResult fileEncryptionResult)
|
|
||||||
{
|
|
||||||
string attachmentName = "blob";
|
|
||||||
string attachmentFileName = "blob.file";
|
|
||||||
string crlf = "\r\n";
|
|
||||||
string twoHyphens = "--";
|
|
||||||
|
|
||||||
char[] chars = "-_1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".ToCharArray();
|
|
||||||
string boundary = string.Empty;
|
|
||||||
Random rand = new Random();
|
|
||||||
int count = rand.Next(11) + 30;
|
|
||||||
for (int i = 0; i < count; i++)
|
|
||||||
{
|
|
||||||
boundary += chars[rand.Next(chars.Length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] header = Encoding.UTF8.GetBytes(twoHyphens + boundary + crlf +
|
|
||||||
"Content-Disposition: form-data; name=\"" + attachmentName + "\";filename=\"" + attachmentFileName + "\"" + crlf + crlf
|
|
||||||
);
|
|
||||||
byte[] footer = Encoding.UTF8.GetBytes(crlf + twoHyphens + boundary + twoHyphens + crlf);
|
|
||||||
byte[] postData = new byte[header.Length + fileEncryptionResult.Result.Length + footer.Length];
|
|
||||||
|
|
||||||
header.CopyTo(postData, 0);
|
|
||||||
fileEncryptionResult.Result.CopyTo(postData, header.Length);
|
|
||||||
footer.CopyTo(postData, header.Length + fileEncryptionResult.Result.Length);
|
|
||||||
|
|
||||||
string queryString = MakeUrlEncoded(MakeRequestParams());
|
|
||||||
Uri url = new Uri(this.apiUrl + "upload_blob?" + queryString);
|
|
||||||
|
|
||||||
WebRequest request = WebRequest.CreateHttp(url);
|
|
||||||
request.Method = "POST";
|
|
||||||
|
|
||||||
if (!WebHeaderCollection.IsRestricted("Connection"))
|
|
||||||
{
|
|
||||||
request.Headers.Set(HttpRequestHeader.Connection, "Keep-Alive");
|
|
||||||
}
|
|
||||||
if (!WebHeaderCollection.IsRestricted("CacheControl"))
|
|
||||||
{
|
|
||||||
request.Headers.Add(HttpRequestHeader.CacheControl, "no-cache");
|
|
||||||
}
|
|
||||||
|
|
||||||
request.ContentType = "multipart/form-data;boundary=" + boundary;
|
|
||||||
request.ContentLength = postData.Length;
|
|
||||||
|
|
||||||
using (Stream stream = request.GetRequestStream())
|
|
||||||
{
|
|
||||||
stream.Write(postData, 0, postData.Length);
|
|
||||||
stream.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
string responseData;
|
|
||||||
HttpStatusCode responseCode = GetResponse(request, out responseData);
|
|
||||||
|
|
||||||
return new UploadResult((int)responseCode, responseData != null ? DataUtils.HexStringToByteArray(responseData) : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Send an end-to-end encrypted message.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="to">recipient ID</param>
|
|
||||||
/// <param name="nonce">nonce used for encryption (24 bytes)</param>
|
|
||||||
/// <param name="box">encrypted message data (max. 4000 bytes)</param>
|
|
||||||
/// <returns>message ID</returns>
|
|
||||||
public string SendE2EMessage(string to, byte[] nonce, byte[] box)
|
|
||||||
{
|
|
||||||
Dictionary<string, string> postParams = MakeRequestParams();
|
|
||||||
postParams.Add("to", to);
|
|
||||||
postParams.Add("nonce", DataUtils.ByteArrayToHexString(nonce));
|
|
||||||
postParams.Add("box", DataUtils.ByteArrayToHexString(box));
|
|
||||||
|
|
||||||
return DoPost(new Uri(this.apiUrl + "send_e2e"), postParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Send a text message with server-side encryption.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="to">recipient ID</param>
|
|
||||||
/// <param name="text">message text (max. 3500 bytes)</param>
|
|
||||||
/// <returns>message ID</returns>
|
|
||||||
public string SendTextMessageSimple(string to, string text)
|
|
||||||
{
|
|
||||||
Dictionary<string, string> postParams = MakeRequestParams();
|
|
||||||
postParams.Add("to", to);
|
|
||||||
postParams.Add("text", text);
|
|
||||||
|
|
||||||
return DoPost(new Uri(this.apiUrl + "send_simple"), postParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string DoGet(Uri url, Dictionary<string, string> getParams)
|
|
||||||
{
|
|
||||||
if (getParams != null)
|
|
||||||
{
|
|
||||||
string queryString = MakeUrlEncoded(getParams);
|
|
||||||
|
|
||||||
url = new Uri(url.ToString() + "?" + queryString);
|
|
||||||
}
|
|
||||||
|
|
||||||
WebRequest request = WebRequest.CreateHttp(url);
|
|
||||||
request.Method = "GET";
|
|
||||||
|
|
||||||
string responseData;
|
|
||||||
HttpStatusCode responseCode = GetResponse(request, out responseData);
|
|
||||||
if (responseCode != HttpStatusCode.OK)
|
|
||||||
{
|
|
||||||
throw new HttpListenerException((int)responseCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseData;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string DoPost(Uri url, Dictionary<string, string> postParams)
|
|
||||||
{
|
|
||||||
ASCIIEncoding encoding = new ASCIIEncoding();
|
|
||||||
byte[] postData = encoding.GetBytes(MakeUrlEncoded(postParams));
|
|
||||||
|
|
||||||
WebRequest request = WebRequest.CreateHttp(url);
|
|
||||||
request.Method = "POST";
|
|
||||||
request.Headers.Add(HttpRequestHeader.AcceptCharset, "utf-8");
|
|
||||||
request.ContentType = "application/x-www-form-urlencoded";
|
|
||||||
request.ContentLength = postData.Length;
|
|
||||||
|
|
||||||
using (Stream stream = request.GetRequestStream())
|
|
||||||
{
|
|
||||||
stream.Write(postData, 0, postData.Length);
|
|
||||||
stream.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
string responseData;
|
|
||||||
HttpStatusCode responseCode = GetResponse(request, out responseData);
|
|
||||||
if (responseCode != HttpStatusCode.OK)
|
|
||||||
{
|
|
||||||
throw new HttpListenerException((int)responseCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseData;
|
|
||||||
}
|
|
||||||
|
|
||||||
private HttpStatusCode GetResponse(WebRequest request, out string responseData)
|
|
||||||
{
|
|
||||||
responseData = null;
|
|
||||||
|
|
||||||
WebResponse response = request.GetResponse();
|
|
||||||
HttpStatusCode status = ((HttpWebResponse)response).StatusCode;
|
|
||||||
if (status == HttpStatusCode.OK)
|
|
||||||
{
|
|
||||||
using (Stream stream = response.GetResponseStream())
|
|
||||||
using (StreamReader reader = new StreamReader(stream))
|
|
||||||
{
|
|
||||||
responseData = reader.ReadToEnd();
|
|
||||||
|
|
||||||
reader.Close();
|
|
||||||
stream.Close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response.Close();
|
|
||||||
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Dictionary<string,string> MakeRequestParams()
|
|
||||||
{
|
|
||||||
Dictionary<string, string> postParams = new Dictionary<string, string>();
|
|
||||||
postParams.Add("from", apiIdentity);
|
|
||||||
postParams.Add("secret", secret);
|
|
||||||
return postParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String MakeUrlEncoded(Dictionary<string, string> parameters)
|
|
||||||
{
|
|
||||||
StringBuilder s = new StringBuilder();
|
|
||||||
|
|
||||||
foreach (KeyValuePair<String,String> param in parameters)
|
|
||||||
{
|
|
||||||
if (s.Length > 0)
|
|
||||||
{
|
|
||||||
s.Append('&');
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Append(param.Key);
|
|
||||||
s.Append('=');
|
|
||||||
s.Append(DataUtils.Utf8Endcode(WebUtility.UrlEncode(param.Value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private PublicKeyStore GetSQLiteDbProvider()
|
|
||||||
{
|
|
||||||
PublicKeyStore store = null;
|
|
||||||
|
|
||||||
var builder = new ConfigurationBuilder()
|
|
||||||
.SetBasePath(Directory.GetCurrentDirectory())
|
|
||||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
|
|
||||||
IConfigurationRoot configuration = builder.Build();
|
|
||||||
string connectionString = configuration.GetConnectionString("SQLiteConnectionString");
|
|
||||||
if (!String.IsNullOrWhiteSpace(connectionString))
|
|
||||||
{
|
|
||||||
store = new PublicKeyStoreDb(connectionString);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
store = new PublicKeyStoreNone();
|
|
||||||
}
|
|
||||||
|
|
||||||
return store;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Messages;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Results;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Com
|
|
||||||
{
|
|
||||||
[ComVisible(true)]
|
|
||||||
[ProgId("Threema.MsgApi.Com.CryptTool")]
|
|
||||||
[Guid("0B98D653-CD23-4827-9C5B-AEC0DCFBD142")]
|
|
||||||
public class CryptToolWrapper : ICryptToolWrapper
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to encrypt text <see cref="Threema.MsgApi.CryptTool.EncryptTextMessage"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">Text to encrypt</param>
|
|
||||||
/// <param name="senderPrivateKey">Sender private key as hex-string</param>
|
|
||||||
/// <param name="recipientPublicKey">Recipient public key as hex-string</param>
|
|
||||||
/// <returns>Array with encrypted text, nonce and size</returns>
|
|
||||||
public ArrayList EncryptTextMessage(string text, string senderPrivateKey, string recipientPublicKey)
|
|
||||||
{
|
|
||||||
byte[] privateKey = GetKey(senderPrivateKey, Key.KeyType.PRIVATE);
|
|
||||||
byte[] publicKey = GetKey(recipientPublicKey, Key.KeyType.PUBLIC);
|
|
||||||
|
|
||||||
string textEncoded = DataUtils.Utf8Endcode(text);
|
|
||||||
|
|
||||||
EncryptResult encryptResult = CryptTool.EncryptTextMessage(textEncoded, privateKey, publicKey);
|
|
||||||
|
|
||||||
var result = new ArrayList();
|
|
||||||
result.Add(DataUtils.ByteArrayToHexString(encryptResult.Result));
|
|
||||||
result.Add(DataUtils.ByteArrayToHexString(encryptResult.Nonce));
|
|
||||||
result.Add(encryptResult.Size.ToString());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to decrypt box <see cref="Threema.MsgApi.CryptTool.DecryptMessage"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="box">Encrypted box as hex-straing</param>
|
|
||||||
/// <param name="recipientPrivateKey">Recipient private key as hex-string</param>
|
|
||||||
/// <param name="senderPublicKey">Sender public key as hex-string</param>
|
|
||||||
/// <param name="nonce">Nonce as hex-string</param>
|
|
||||||
/// <returns>Array with type and decrypted message</returns>
|
|
||||||
public ArrayList DecryptMessage(string box, string recipientPrivateKey, string senderPublicKey, string nonce)
|
|
||||||
{
|
|
||||||
byte[] privateKey = GetKey(recipientPrivateKey, Key.KeyType.PRIVATE);
|
|
||||||
byte[] publicKey = GetKey(senderPublicKey, Key.KeyType.PUBLIC);
|
|
||||||
byte[] nonceBytes = DataUtils.HexStringToByteArray(nonce);
|
|
||||||
byte[] boxBytes = DataUtils.HexStringToByteArray(box);
|
|
||||||
|
|
||||||
ThreemaMessage message = CryptTool.DecryptMessage(boxBytes, privateKey, publicKey, nonceBytes);
|
|
||||||
|
|
||||||
var result = new ArrayList();
|
|
||||||
result.Add(message.GetTypeCode().ToString());
|
|
||||||
result.Add(message.ToString());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to hash email <see cref="Threema.MsgApi.CryptTool.HashEmail"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="email">Email adress</param>
|
|
||||||
/// <returns>Hash of email adress as hex-string</returns>
|
|
||||||
public string HashEmail(string email)
|
|
||||||
{
|
|
||||||
byte[] emailHash = CryptTool.HashEmail(email);
|
|
||||||
return DataUtils.ByteArrayToHexString(emailHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to hash email <see cref="Threema.MsgApi.CryptTool.HashPhoneNo"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="phoneNo">Phone number</param>
|
|
||||||
/// <returns>Hash of phone number as hex-string</returns>
|
|
||||||
public string HashPhoneNo(string phoneNo)
|
|
||||||
{
|
|
||||||
byte[] phoneHash = CryptTool.HashPhoneNo(phoneNo);
|
|
||||||
return DataUtils.ByteArrayToHexString(phoneHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to generate key pair <see cref="Threema.MsgApi.CryptTool.GenerateKeyPair"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="privateKeyPath">Full path name of private key file</param>
|
|
||||||
/// <param name="publicKeyPath">Full path name of public key file</param>
|
|
||||||
public void GenerateKeyPair(string privateKeyPath, string publicKeyPath)
|
|
||||||
{
|
|
||||||
byte[] privateKey = new byte[32]; //NaCl.SECRETKEYBYTES
|
|
||||||
byte[] publicKey = new byte[32]; //NaCl.PUBLICKEYBYTES
|
|
||||||
|
|
||||||
CryptTool.GenerateKeyPair(ref privateKey, ref publicKey);
|
|
||||||
|
|
||||||
// Write both keys to file
|
|
||||||
DataUtils.WriteKeyFile(privateKeyPath, new Key(Key.KeyType.PRIVATE, privateKey));
|
|
||||||
DataUtils.WriteKeyFile(publicKeyPath, new Key(Key.KeyType.PUBLIC, publicKey));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to derive public key <see cref="CryptTool.DerivePublicKey"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="privateKey">private key as file path or hex-string</param>
|
|
||||||
/// <returns>Public key as hex-string</returns>
|
|
||||||
public string DerivePublicKey(string privateKey)
|
|
||||||
{
|
|
||||||
byte[] privateKeyBytes = GetKey(privateKey, Key.KeyType.PRIVATE);
|
|
||||||
byte[] publicKey = CryptTool.DerivePublicKey(privateKeyBytes);
|
|
||||||
|
|
||||||
return new Key(Key.KeyType.PUBLIC, publicKey).Encode();
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] GetKey(string argument, string expectedKeyType)
|
|
||||||
{
|
|
||||||
Key key;
|
|
||||||
|
|
||||||
if (File.Exists(argument))
|
|
||||||
{
|
|
||||||
key = DataUtils.ReadKeyFile(argument, expectedKeyType);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
key = IcgSoftware.Threema.CoreMsgApi.Key.DecodeKey(argument, expectedKeyType);
|
|
||||||
}
|
|
||||||
|
|
||||||
return key.key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Results;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Com
|
|
||||||
{
|
|
||||||
[ComVisible(true)]
|
|
||||||
[Guid("4F376DEB-6F78-460A-836F-B38371712EFF")]
|
|
||||||
public interface ICryptToolWrapper
|
|
||||||
{
|
|
||||||
ArrayList EncryptTextMessage(string text, string senderPrivateKey, string recipientPublicKey);
|
|
||||||
ArrayList DecryptMessage(string box, string recipientPrivateKey, string senderPublicKey, string nonce);
|
|
||||||
string HashEmail(string email);
|
|
||||||
string HashPhoneNo(string phoneNo);
|
|
||||||
void GenerateKeyPair(string privateKeyPath, string publicKeyPath);
|
|
||||||
string DerivePublicKey(string privateKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Com
|
|
||||||
{
|
|
||||||
[ComVisible(true)]
|
|
||||||
[Guid("E9E32936-CCAB-4297-BAB6-7F3B5939F3D4")]
|
|
||||||
public interface IMessageToolWrapper
|
|
||||||
{
|
|
||||||
string SendTextMessageSimple(string to, string from, string secret, string text, string apiUrl = APIConnector.DEFAULTAPIURL);
|
|
||||||
string SendTextMessage(string to, string from, string secret, string privateKey, string text, string apiUrl = APIConnector.DEFAULTAPIURL);
|
|
||||||
string SendImageMessage(string to, string from, string secret, string privateKey, string imageFilePath, string apiUrl = APIConnector.DEFAULTAPIURL);
|
|
||||||
string SendFileMessage(string to, string from, string secret, string privateKey, string file, string thumbnail = null, string apiUrl = APIConnector.DEFAULTAPIURL);
|
|
||||||
string LookupEmail(string email, string from, string secret, string apiUrl = APIConnector.DEFAULTAPIURL);
|
|
||||||
string LookupPhone(string phoneNo, string from, string secret, string apiUrl = APIConnector.DEFAULTAPIURL);
|
|
||||||
string LookupKey(string threemaId, string from, string secret, string apiUrl = APIConnector.DEFAULTAPIURL);
|
|
||||||
ArrayList LookupKeyCapability(string threemaId, string from, string secret, string apiUrl = APIConnector.DEFAULTAPIURL);
|
|
||||||
int? LookupCredits(string from, string secret, string apiUrl = APIConnector.DEFAULTAPIURL);
|
|
||||||
ArrayList ReceiveMessage(string id, string from, string secret, string privateKey, string messageId, string nonce, string box, string outputFolder = null, string apiUrl = APIConnector.DEFAULTAPIURL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-221
@@ -1,221 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Helpers;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Results;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Com
|
|
||||||
{
|
|
||||||
[ComVisible(true)]
|
|
||||||
[ProgId("Threema.MsgApi.Com.MessageTool")]
|
|
||||||
[Guid("097F1D26-B38E-4501-845D-6DE7DBFB5EAA")]
|
|
||||||
public class MessageToolWrapper : IMessageToolWrapper
|
|
||||||
{
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to send simple message <see cref="Threema.MsgApi.APIConnector.SendTextMessageSimple"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="to">Recipient id</param>
|
|
||||||
/// <param name="from">Sender id</param>
|
|
||||||
/// <param name="secret">Sender sercret</param>
|
|
||||||
/// <param name="text">Text message</param>
|
|
||||||
/// <param name="apiUrl">Optional api url</param>
|
|
||||||
/// <returns>Message id</returns>
|
|
||||||
public string SendTextMessageSimple(string to, string from, string secret, string text, string apiUrl = APIConnector.DEFAULTAPIURL)
|
|
||||||
{
|
|
||||||
APIConnector apiConnector = this.CreateConnector(from, secret, apiUrl);
|
|
||||||
return apiConnector.SendTextMessageSimple(to, DataUtils.Utf8Endcode(text));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to send text message E2E <see cref="Threema.MsgApi.E2EHelper.SendTextMessage"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="to">Recipient id</param>
|
|
||||||
/// <param name="from">Sender id</param>
|
|
||||||
/// <param name="secret">Sender sercret</param>
|
|
||||||
/// <param name="privateKey">Sender private key</param>
|
|
||||||
/// <param name="text">Text message</param>
|
|
||||||
/// <param name="apiUrl">Optional api url</param>
|
|
||||||
/// <returns>Message id</returns>
|
|
||||||
public string SendTextMessage(string to, string from, string secret, string privateKey, string text, string apiUrl = APIConnector.DEFAULTAPIURL)
|
|
||||||
{
|
|
||||||
byte[] privateKeyBytes = GetKey(privateKey, Key.KeyType.PRIVATE);
|
|
||||||
|
|
||||||
E2EHelper e2EHelper = new E2EHelper(this.CreateConnector(from, secret, apiUrl), privateKeyBytes);
|
|
||||||
return e2EHelper.SendTextMessage(to, DataUtils.Utf8Endcode(text));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to send image message E2E <see cref="Threema.MsgApi.E2EHelper.SendImageMessage"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="to">Recipient id</param>
|
|
||||||
/// <param name="from">Sender id</param>
|
|
||||||
/// <param name="secret">Sender sercret</param>
|
|
||||||
/// <param name="privateKey">Sender private key</param>
|
|
||||||
/// <param name="imageFilePath">File path to image</param>
|
|
||||||
/// <param name="apiUrl">Optional api url</param>
|
|
||||||
/// <returns>Message id</returns>
|
|
||||||
public string SendImageMessage(string to, string from, string secret, string privateKey, string imageFilePath, string apiUrl = APIConnector.DEFAULTAPIURL)
|
|
||||||
{
|
|
||||||
byte[] privateKeyBytes = GetKey(privateKey, Key.KeyType.PRIVATE);
|
|
||||||
|
|
||||||
E2EHelper e2EHelper = new E2EHelper(this.CreateConnector(from, secret, apiUrl), privateKeyBytes);
|
|
||||||
return e2EHelper.SendImageMessage(to, imageFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to send file message E2E <see cref="Threema.MsgApi.E2EHelper.SendFileMessage"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="to">Recipient id</param>
|
|
||||||
/// <param name="from">Sender id</param>
|
|
||||||
/// <param name="secret">Sender sercret</param>
|
|
||||||
/// <param name="privateKey">Sender private key</param>
|
|
||||||
/// <param name="file">File path to file</param>
|
|
||||||
/// <param name="thumbnail">File path to thumbnail</param>
|
|
||||||
/// <param name="apiUrl">Optional api url</param>
|
|
||||||
/// <returns>Message id</returns>
|
|
||||||
public string SendFileMessage(string to, string from, string secret, string privateKey, string file, string thumbnail = null, string apiUrl = APIConnector.DEFAULTAPIURL)
|
|
||||||
{
|
|
||||||
byte[] privateKeyBytes = GetKey(privateKey, Key.KeyType.PRIVATE);
|
|
||||||
FileInfo fileInfo = file != null ? new FileInfo(file) : null;
|
|
||||||
FileInfo thumbnailInfo = thumbnail != null ? new FileInfo(thumbnail) : null;
|
|
||||||
|
|
||||||
E2EHelper e2EHelper = new E2EHelper(this.CreateConnector(from, secret, apiUrl), privateKeyBytes);
|
|
||||||
return e2EHelper.SendFileMessage(to, fileInfo, thumbnailInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to id lookup via email <see cref="Threema.MsgApi.APIConnector.LookupEmail"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="email">Email for lookup</param>
|
|
||||||
/// <param name="from">Sender id</param>
|
|
||||||
/// <param name="secret">Sender secret</param>
|
|
||||||
/// <param name="apiUrl">Optional api url</param>
|
|
||||||
/// <returns>id</returns>
|
|
||||||
public string LookupEmail(string email, string from, string secret, string apiUrl = APIConnector.DEFAULTAPIURL)
|
|
||||||
{
|
|
||||||
APIConnector apiConnector = this.CreateConnector(from, secret, apiUrl);
|
|
||||||
return apiConnector.LookupEmail(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to id lookup via phone number <see cref="Threema.MsgApi.APIConnector.LookupPhone"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="phoneNo">Phone number for lookup</param>
|
|
||||||
/// <param name="from">Sender id</param>
|
|
||||||
/// <param name="secret">Sender secret</param>
|
|
||||||
/// <param name="apiUrl">Optional api url</param>
|
|
||||||
/// <returns>id</returns>
|
|
||||||
public string LookupPhone(string phoneNo, string from, string secret, string apiUrl = APIConnector.DEFAULTAPIURL)
|
|
||||||
{
|
|
||||||
APIConnector apiConnector = this.CreateConnector(from, secret, apiUrl);
|
|
||||||
return apiConnector.LookupPhone(phoneNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to lookup/fetch public key <see cref="Threema.MsgApi.APIConnector.LookupKey"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="threemaId">Id for lookup</param>
|
|
||||||
/// <param name="from">Sender id</param>
|
|
||||||
/// <param name="secret">Sender secret</param>
|
|
||||||
/// <param name="apiUrl">Optional api url</param>
|
|
||||||
/// <returns>public key has hex-string</returns>
|
|
||||||
public string LookupKey(string threemaId, string from, string secret, string apiUrl = APIConnector.DEFAULTAPIURL)
|
|
||||||
{
|
|
||||||
APIConnector apiConnector = this.CreateConnector(from, secret, apiUrl);
|
|
||||||
byte[] publicKey = apiConnector.LookupKey(threemaId);
|
|
||||||
if (publicKey != null)
|
|
||||||
{
|
|
||||||
return new Key(Key.KeyType.PUBLIC, publicKey).Encode();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to lookup capabilities <see cref="Threema.MsgApi.APIConnector.LookupKeyCapability"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="threemaId">Id for lookup</param>
|
|
||||||
/// <param name="from">Sender id</param>
|
|
||||||
/// <param name="secret">Sender secret</param>
|
|
||||||
/// <param name="apiUrl">Optional api url</param>
|
|
||||||
/// <returns>Array with capatilities</returns>
|
|
||||||
public System.Collections.ArrayList LookupKeyCapability(string threemaId, string from, string secret, string apiUrl = APIConnector.DEFAULTAPIURL)
|
|
||||||
{
|
|
||||||
CapabilityResult capabilities = this.CreateConnector(from, secret, apiUrl)
|
|
||||||
.LookupKeyCapability(threemaId);
|
|
||||||
|
|
||||||
var result = new ArrayList();
|
|
||||||
capabilities.Capabilities.ToList().ForEach(c => result.Add(c));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to lookup credits <see cref="Threema.MsgApi.APIConnector.LookupCredits"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="from">From id</param>
|
|
||||||
/// <param name="secret">From secret</param>
|
|
||||||
/// <param name="apiUrl">Optional api url</param>
|
|
||||||
/// <returns>credits or null</returns>
|
|
||||||
public int? LookupCredits(string from, string secret, string apiUrl = APIConnector.DEFAULTAPIURL)
|
|
||||||
{
|
|
||||||
return this.CreateConnector(from, secret, apiUrl).LookupCredits();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrapper to receive message and download files <see cref="Threema.MsgApi.Helpers.E2EHelper.ReceiveMessage"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">Sender id</param>
|
|
||||||
/// <param name="from">From id</param>
|
|
||||||
/// <param name="secret">From secret</param>
|
|
||||||
/// <param name="privateKey">From private key</param>
|
|
||||||
/// <param name="messageId">Message id</param>
|
|
||||||
/// <param name="nonce">Nonce as hex-string</param>
|
|
||||||
/// <param name="box">Box message as hex-string</param>
|
|
||||||
/// <param name="outputFolder">Optional path to output folder</param>
|
|
||||||
/// <param name="apiUrl">Optional api url</param>
|
|
||||||
/// <returns>Array with message-type, message-id and message</returns>
|
|
||||||
public ArrayList ReceiveMessage(string id, string from, string secret, string privateKey, string messageId, string nonce, string box, string outputFolder = null, string apiUrl = APIConnector.DEFAULTAPIURL)
|
|
||||||
{
|
|
||||||
byte[] privateKeyBytes = GetKey(privateKey, Key.KeyType.PRIVATE);
|
|
||||||
byte[] nonceBytes = DataUtils.HexStringToByteArray(nonce);
|
|
||||||
|
|
||||||
E2EHelper e2EHelper = new E2EHelper(this.CreateConnector(from, secret, apiUrl), privateKeyBytes);
|
|
||||||
|
|
||||||
byte[] boxBytes = DataUtils.HexStringToByteArray(box);
|
|
||||||
|
|
||||||
ReceiveMessageResult res = e2EHelper.ReceiveMessage(id, messageId, boxBytes, nonceBytes, outputFolder);
|
|
||||||
|
|
||||||
ArrayList result = new ArrayList();
|
|
||||||
result.Add(res.Message.GetTypeCode().ToString());
|
|
||||||
result.Add(res.MessageId);
|
|
||||||
result.Add(res.Message.ToString());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private APIConnector CreateConnector(string gatewayId, string secret, string apiUrl)
|
|
||||||
{
|
|
||||||
return new APIConnector(gatewayId, secret, apiUrl, new PublicKeyStoreNone());
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] GetKey(string argument, string expectedKeyType)
|
|
||||||
{
|
|
||||||
Key key;
|
|
||||||
|
|
||||||
if (File.Exists(argument))
|
|
||||||
{
|
|
||||||
key = DataUtils.ReadKeyFile(argument, expectedKeyType);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
key = IcgSoftware.Threema.CoreMsgApi.Key.DecodeKey(argument, expectedKeyType);
|
|
||||||
}
|
|
||||||
|
|
||||||
return key.key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Exceptions;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Messages;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Results;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Contains static methods to do various Threema cryptography related tasks.
|
|
||||||
/// </summary>
|
|
||||||
public class CryptTool
|
|
||||||
{
|
|
||||||
// HMAC-SHA256 keys for email/mobile phone hashing
|
|
||||||
private static readonly byte[] EMAIL_HMAC_KEY = new byte[] {(byte)0x30,(byte)0xa5,(byte)0x50,(byte)0x0f,(byte)0xed,(byte)0x97,(byte)0x01,(byte)0xfa,(byte)0x6d,(byte)0xef,(byte)0xdb,(byte)0x61,(byte)0x08,(byte)0x41,(byte)0x90,(byte)0x0f,(byte)0xeb,(byte)0xb8,(byte)0xe4,(byte)0x30,(byte)0x88,(byte)0x1f,(byte)0x7a,(byte)0xd8,(byte)0x16,(byte)0x82,(byte)0x62,(byte)0x64,(byte)0xec,(byte)0x09,(byte)0xba,(byte)0xd7};
|
|
||||||
private static readonly byte[] PHONENO_HMAC_KEY = new byte[] {(byte)0x85,(byte)0xad,(byte)0xf8,(byte)0x22,(byte)0x69,(byte)0x53,(byte)0xf3,(byte)0xd9,(byte)0x6c,(byte)0xfd,(byte)0x5d,(byte)0x09,(byte)0xbf,(byte)0x29,(byte)0x55,(byte)0x5e,(byte)0xb9,(byte)0x55,(byte)0xfc,(byte)0xd8,(byte)0xaa,(byte)0x5e,(byte)0xc4,(byte)0xf9,(byte)0xfc,(byte)0xd8,(byte)0x69,(byte)0xe2,(byte)0x58,(byte)0x37,(byte)0x07,(byte)0x23};
|
|
||||||
|
|
||||||
private static readonly byte[] FILE_NONCE = new byte[] {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01};
|
|
||||||
private static readonly byte[] FILE_THUMBNAIL_NONCE = new byte[] {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x02};
|
|
||||||
|
|
||||||
private const int SYMMKEYBYTES = 32;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encrypt a text message.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">the text to be encrypted (max. 3500 bytes)</param>
|
|
||||||
/// <param name="senderPrivateKey">the private key of the sending ID</param>
|
|
||||||
/// <param name="recipientPublicKey">the public key of the receiving ID</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public static EncryptResult EncryptTextMessage(String text, byte[] senderPrivateKey, byte[] recipientPublicKey)
|
|
||||||
{
|
|
||||||
return EncryptMessage(new TextMessage(text), senderPrivateKey, recipientPublicKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encrypt an image message.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="encryptResult">result of the image encryption</param>
|
|
||||||
/// <param name="uploadResult">result of the upload</param>
|
|
||||||
/// <param name="senderPrivateKey">the private key of the sending ID</param>
|
|
||||||
/// <param name="recipientPublicKey">the public key of the receiving ID</param>
|
|
||||||
/// <returns>encrypted result</returns>
|
|
||||||
public static EncryptResult EncryptImageMessage(EncryptResult encryptResult, UploadResult uploadResult, byte[] senderPrivateKey, byte[] recipientPublicKey)
|
|
||||||
{
|
|
||||||
return EncryptMessage(
|
|
||||||
new ImageMessage(uploadResult.BlobId,
|
|
||||||
encryptResult.Size,
|
|
||||||
encryptResult.Nonce),
|
|
||||||
senderPrivateKey,
|
|
||||||
recipientPublicKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encrypt a file message.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="encryptResult">result of the file data encryption</param>
|
|
||||||
/// <param name="uploadResult">result of the upload</param>
|
|
||||||
/// <param name="mimeType">MIME type of the file</param>
|
|
||||||
/// <param name="fileName">File name</param>
|
|
||||||
/// <param name="fileSize">Size of the file, in bytes</param>
|
|
||||||
/// <param name="uploadResultThumbnail">result of thumbnail upload</param>
|
|
||||||
/// <param name="senderPrivateKey">Private key of sender</param>
|
|
||||||
/// <param name="recipientPublicKey">Public key of recipient</param>
|
|
||||||
/// <returns>Result of the file message encryption (not the same as the file data encryption!)</returns>
|
|
||||||
public static EncryptResult EncryptFileMessage(EncryptResult encryptResult,
|
|
||||||
UploadResult uploadResult,
|
|
||||||
String mimeType,
|
|
||||||
String fileName,
|
|
||||||
int fileSize,
|
|
||||||
UploadResult uploadResultThumbnail,
|
|
||||||
byte[] senderPrivateKey, byte[] recipientPublicKey)
|
|
||||||
{
|
|
||||||
return EncryptMessage(
|
|
||||||
new FileMessage(uploadResult.BlobId,
|
|
||||||
encryptResult.Secret,
|
|
||||||
mimeType,
|
|
||||||
fileName,
|
|
||||||
fileSize,
|
|
||||||
uploadResultThumbnail != null ? uploadResultThumbnail.BlobId : null),
|
|
||||||
senderPrivateKey,
|
|
||||||
recipientPublicKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static EncryptResult EncryptMessage(ThreemaMessage threemaMessage, byte[] privateKey, byte[] publicKey)
|
|
||||||
{
|
|
||||||
// determine random amount of PKCS7 padding
|
|
||||||
int padbytes = new Random().Next(254) + 1;
|
|
||||||
|
|
||||||
byte[] messageBytes;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
messageBytes = threemaMessage.GetData();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// prepend type byte (0x02) to message data
|
|
||||||
byte[] data = new byte[1 + messageBytes.Length + padbytes];
|
|
||||||
data[0] = (byte)threemaMessage.GetTypeCode();
|
|
||||||
|
|
||||||
messageBytes.CopyTo(data, 1);
|
|
||||||
|
|
||||||
// append padding
|
|
||||||
for (int i = 0; i < padbytes; i++)
|
|
||||||
{
|
|
||||||
data[i + 1 + messageBytes.Length] = (byte)padbytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Encrypt(data, privateKey, publicKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Decrypt an NaCl box using the recipient's private key and the sender's public key.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="box">The box to be decrypted</param>
|
|
||||||
/// <param name="privateKey">The private key of the recipient</param>
|
|
||||||
/// <param name="publicKey">The public key of the sender</param>
|
|
||||||
/// <param name="nonce">The nonce that was used for encryption</param>
|
|
||||||
/// <returns>The decrypted data, or null if decryption failed</returns>
|
|
||||||
public static byte[] Decrypt(byte[] box, byte[] privateKey, byte[] publicKey, byte[] nonce)
|
|
||||||
{
|
|
||||||
return Sodium.PublicKeyBox.Open(box, nonce, privateKey, publicKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Decrypt symmetrically encrypted file data.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fileData">The encrypted file data</param>
|
|
||||||
/// <param name="secret">The symmetric key that was used for encryption</param>
|
|
||||||
/// <returns>The decrypted file data, or null if decryption failed</returns>
|
|
||||||
public static byte[] DecryptFileData(byte[] fileData, byte[] secret)
|
|
||||||
{
|
|
||||||
byte[] box = new byte[fileData.Length + 16];
|
|
||||||
fileData.CopyTo(box, 16);
|
|
||||||
return Sodium.SecretBox.Open(box, FILE_NONCE, secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Decrypt symmetrically encrypted file thumbnail data.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fileData">The encrypted thumbnail data</param>
|
|
||||||
/// <param name="secret">The symmetric key that was used for encryption</param>
|
|
||||||
/// <returns>The decrypted thumbnail data, or null if decryption failed</returns>
|
|
||||||
public static byte[] DecryptFileThumbnailData(byte[] fileData, byte[] secret)
|
|
||||||
{
|
|
||||||
byte[] box = new byte[fileData.Length + 16];
|
|
||||||
fileData.CopyTo(box, 16);
|
|
||||||
return Sodium.SecretBox.Open(box, FILE_THUMBNAIL_NONCE, secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Decrypt a message.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="box">the box to be decrypted</param>
|
|
||||||
/// <param name="recipientPrivateKey">the private key of the receiving ID</param>
|
|
||||||
/// <param name="senderPublicKey">the public key of the sending ID</param>
|
|
||||||
/// <param name="nonce">the nonce that was used for the encryption</param>
|
|
||||||
/// <returns>decrypted message (text or delivery receipt)</returns>
|
|
||||||
public static ThreemaMessage DecryptMessage(byte[] box, byte[] recipientPrivateKey, byte[] senderPublicKey, byte[] nonce)
|
|
||||||
{
|
|
||||||
byte[] data = Decrypt(box, recipientPrivateKey, senderPublicKey, nonce);
|
|
||||||
if (data == null)
|
|
||||||
{
|
|
||||||
throw new DecryptionFailedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove padding
|
|
||||||
int padbytes = data[data.Length - 1] & 0xFF;
|
|
||||||
int realDataLength = data.Length - padbytes;
|
|
||||||
if (realDataLength < 1)
|
|
||||||
{
|
|
||||||
// Bad message padding
|
|
||||||
throw new BadMessageException();
|
|
||||||
}
|
|
||||||
|
|
||||||
// first byte of data is type
|
|
||||||
int type = data[0] & 0xFF;
|
|
||||||
|
|
||||||
switch (type)
|
|
||||||
{
|
|
||||||
case TextMessage.TYPE_CODE:
|
|
||||||
// Text message
|
|
||||||
if (realDataLength < 2)
|
|
||||||
{
|
|
||||||
throw new BadMessageException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TextMessage(Encoding.UTF8.GetString(data.Skip(1).Take(realDataLength - 1).ToArray()));
|
|
||||||
|
|
||||||
case DeliveryReceipt.TYPE_CODE:
|
|
||||||
/* Delivery receipt */
|
|
||||||
if (realDataLength < MessageId.MESSAGE_ID_LEN + 2 || ((realDataLength - 2) % MessageId.MESSAGE_ID_LEN) != 0)
|
|
||||||
{
|
|
||||||
throw new BadMessageException();
|
|
||||||
}
|
|
||||||
|
|
||||||
DeliveryReceipt.Type receiptType = (DeliveryReceipt.Type)Enum.Parse(typeof(DeliveryReceipt.Type), Convert.ToString((int)data[1] & 0xFF));
|
|
||||||
if (receiptType == null)
|
|
||||||
{
|
|
||||||
throw new BadMessageException();
|
|
||||||
}
|
|
||||||
|
|
||||||
IEnumerable<MessageId> messageIds = new LinkedList<MessageId>();
|
|
||||||
|
|
||||||
int numMsgIds = ((realDataLength - 2) / MessageId.MESSAGE_ID_LEN);
|
|
||||||
for (int i = 0; i < numMsgIds; i++)
|
|
||||||
{
|
|
||||||
messageIds.ToList().Add(new MessageId(data, 2 + i*MessageId.MESSAGE_ID_LEN));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new DeliveryReceipt(receiptType, messageIds.ToList());
|
|
||||||
|
|
||||||
case ImageMessage.TYPE_CODE:
|
|
||||||
if(realDataLength != (1 + ThreemaMessage.BLOB_ID_LEN + 4 + ThreemaMessage.NONCEBYTES))
|
|
||||||
{
|
|
||||||
throw new BadMessageException();
|
|
||||||
}
|
|
||||||
byte[] blobId = new byte[ThreemaMessage.BLOB_ID_LEN];
|
|
||||||
data.Skip(1).Take(ThreemaMessage.BLOB_ID_LEN).ToArray().CopyTo(blobId, 0);
|
|
||||||
int size = ReadSwappedInteger(data, 1 + ThreemaMessage.BLOB_ID_LEN);
|
|
||||||
byte[] fileNonce = new byte[ThreemaMessage.NONCEBYTES];
|
|
||||||
data.Skip(1 + ThreemaMessage.BLOB_ID_LEN + 4).Take(ThreemaMessage.NONCEBYTES).ToArray().CopyTo(fileNonce, 0);
|
|
||||||
|
|
||||||
return new ImageMessage(blobId, size, fileNonce);
|
|
||||||
|
|
||||||
case FileMessage.TYPE_CODE:
|
|
||||||
ASCIIEncoding encoding = new ASCIIEncoding();
|
|
||||||
return FileMessage.FromString(encoding.GetString(data.Skip(1).Take(realDataLength - 1).ToArray()));
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new UnsupportedMessageTypeException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int ReadSwappedInteger(byte[] data, int offset)
|
|
||||||
{
|
|
||||||
return ((data[offset + 0] & 255) << 0) + ((data[offset + 1] & 255) << 8) + ((data[offset + 2] & 255) << 16) + ((data[offset + 3] & 255) << 24);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generate a new key pair.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="privateKey">is used to return the generated private key (length must be SealedPublicKeyBox.RecipientSecretKeyBytes)</param>
|
|
||||||
/// <param name="publicKey">is used to return the generated public key (length must be SealedPublicKeyBox.RecipientPublicKeyBytes)</param>
|
|
||||||
public static void GenerateKeyPair(ref byte[] privateKey, ref byte[] publicKey)
|
|
||||||
{
|
|
||||||
if (publicKey.Length != Sodium.SealedPublicKeyBox.RecipientPublicKeyBytes || privateKey.Length != Sodium.SealedPublicKeyBox.RecipientSecretKeyBytes)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Wrong key length");
|
|
||||||
}
|
|
||||||
|
|
||||||
Sodium.KeyPair keyPair = Sodium.PublicKeyBox.GenerateKeyPair();
|
|
||||||
privateKey = keyPair.PrivateKey;
|
|
||||||
publicKey = keyPair.PublicKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encrypt data using NaCl asymmetric ("box") encryption.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="data">the data to be encrypted</param>
|
|
||||||
/// <param name="privateKey">is used to return the generated private key (length must be SealedPublicKeyBox.RecipientSecretKeyBytes)</param>
|
|
||||||
/// <param name="publicKey">is used to return the generated public key (length must be SealedPublicKeyBox.RecipientPublicKeyBytes)</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public static EncryptResult Encrypt(byte[] data,byte[] privateKey, byte[] publicKey)
|
|
||||||
{
|
|
||||||
if (publicKey.Length != Sodium.SealedPublicKeyBox.RecipientPublicKeyBytes || privateKey.Length != Sodium.SealedPublicKeyBox.RecipientSecretKeyBytes)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Wrong key length");
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] nonce = RandomNonce();
|
|
||||||
byte[] box = Sodium.PublicKeyBox.Create(data, nonce, privateKey, publicKey);
|
|
||||||
return new EncryptResult(box, null, nonce);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encrypt file data using NaCl symmetric encryption with a random key.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="data">the file contents to be encrypted</param>
|
|
||||||
/// <returns>the encryption result including the random key</returns>
|
|
||||||
public static EncryptResult EncryptFileData(byte[] data)
|
|
||||||
{
|
|
||||||
//create random key
|
|
||||||
Random rnd = new Random();
|
|
||||||
byte[] encryptionKey = new byte[CryptTool.SYMMKEYBYTES];
|
|
||||||
rnd.NextBytes(encryptionKey);
|
|
||||||
|
|
||||||
//encrypt file data in-place
|
|
||||||
data = Sodium.SecretBox.Create(data, FILE_NONCE, encryptionKey);
|
|
||||||
|
|
||||||
//skip first temp 16 bytes for encryption
|
|
||||||
return new EncryptResult(data.Skip(16).ToArray(), encryptionKey, FILE_NONCE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encrypt file thumbnail data using NaCl symmetric encryption with a random key.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="data">data the file contents to be encrypted</param>
|
|
||||||
/// <param name="encryptionKey"></param>
|
|
||||||
/// <returns>the encryption result including the random key</returns>
|
|
||||||
public static EncryptResult encryptFileThumbnailData(byte[] data, byte[] encryptionKey)
|
|
||||||
{
|
|
||||||
// encrypt file data in-place
|
|
||||||
data = Sodium.SecretBox.Create(data, FILE_THUMBNAIL_NONCE, encryptionKey);
|
|
||||||
|
|
||||||
return new EncryptResult(data, encryptionKey, FILE_THUMBNAIL_NONCE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hashes an email address for identity lookup.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="email">email the email address</param>
|
|
||||||
/// <returns>the raw hash</returns>
|
|
||||||
public static byte[] HashEmail(string email)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ASCIIEncoding encoding = new ASCIIEncoding();
|
|
||||||
var hmac = new HMACSHA256(EMAIL_HMAC_KEY);
|
|
||||||
return hmac.ComputeHash(encoding.GetBytes(email.Trim())).ToArray();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.WriteLine("Error in HashEmail(): {0}", ex.Message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hashes a phone number for identity lookup.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="phoneNo">phoneNo the phone number</param>
|
|
||||||
/// <returns>the raw hash</returns>
|
|
||||||
public static byte[] HashPhoneNo(string phoneNo)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ASCIIEncoding encoding = new ASCIIEncoding();
|
|
||||||
var hmac = new HMACSHA256(PHONENO_HMAC_KEY);
|
|
||||||
return hmac.ComputeHash(encoding.GetBytes(Regex.Replace(phoneNo, "[^0-9]", "")));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.WriteLine("Error in HashPhoneNo(): {0}", ex.Message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generate a random nonce.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>random nonce</returns>
|
|
||||||
public static byte[] RandomNonce()
|
|
||||||
{
|
|
||||||
byte[] nonce = new byte[ThreemaMessage.NONCEBYTES];
|
|
||||||
new Random().NextBytes(nonce);
|
|
||||||
return nonce;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Return the public key that corresponds with a given private key.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="privateKey">The private key whose public key should be derived</param>
|
|
||||||
/// <returns>The corresponding public key.</returns>
|
|
||||||
public static byte[] DerivePublicKey(byte[] privateKey)
|
|
||||||
{
|
|
||||||
Sodium.KeyPair keyPair = Sodium.PublicKeyBox.GenerateKeyPair(privateKey);
|
|
||||||
return keyPair.PublicKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi
|
|
||||||
{
|
|
||||||
public static class DataUtils
|
|
||||||
{
|
|
||||||
private const int BUFFER_SIZE = 16384;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Convert a byte array into a hexadecimal string (lowercase).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="bytes">the bytes to encode</param>
|
|
||||||
/// <returns>hex encoded string</returns>
|
|
||||||
public static string ByteArrayToHexString(byte[] bytes)
|
|
||||||
{
|
|
||||||
var hex = BitConverter.ToString(bytes);
|
|
||||||
return hex.Replace("-", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Convert a string in hexadecimal representation to a byte array.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="s">hex string</param>
|
|
||||||
/// <returns>decoded byte array</returns>
|
|
||||||
public static byte[] HexStringToByteArray(string s)
|
|
||||||
{
|
|
||||||
string sc = s.Replace("[^0-9a-fA-F]", "");
|
|
||||||
int len = sc.Length;
|
|
||||||
byte[] data = new byte[len / 2];
|
|
||||||
for (int i = 0; i < len; i += 2)
|
|
||||||
{
|
|
||||||
System.Diagnostics.Debug.WriteLine(sc.Substring(i, 2));
|
|
||||||
data[i / 2] = Convert.ToByte(sc.Substring(i, 2), 16);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// UTF8 encoded string.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="value">string to encode</param>
|
|
||||||
/// <returns>encoded string</returns>
|
|
||||||
public static string Utf8Endcode(string value)
|
|
||||||
{
|
|
||||||
return Encoding.UTF8.GetString(Encoding.Default.GetBytes(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Read hexadecimal data from a file and return it as a byte array.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="file">input file</param>
|
|
||||||
/// <returns>the decoded data</returns>
|
|
||||||
public static byte[] ReadHexFile(string file)
|
|
||||||
{
|
|
||||||
byte[] data = null;
|
|
||||||
|
|
||||||
using (FileStream stream = File.OpenRead(file))
|
|
||||||
using (StreamReader reader = new StreamReader(stream))
|
|
||||||
{
|
|
||||||
data = HexStringToByteArray(reader.ReadLine().Trim());
|
|
||||||
reader.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Read an encoded key from a file and return it as a key instance.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="file">input file</param>
|
|
||||||
/// <returns>the decoded key</returns>
|
|
||||||
public static Key ReadKeyFile(string file)
|
|
||||||
{
|
|
||||||
return Key.DecodeKey(ReadLineFromFile(file));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Read an encoded key from a file and return it as a key instance.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="file">input file</param>
|
|
||||||
/// <param name="expectedKeyType">validates the key type (private or public)</param>
|
|
||||||
/// <returns>the decoded key</returns>
|
|
||||||
public static Key ReadKeyFile(string file, string expectedKeyType)
|
|
||||||
{
|
|
||||||
return Key.DecodeKey(ReadLineFromFile(file), expectedKeyType);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wirte stream data to byte array.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="stream">data write to byte array</param>
|
|
||||||
/// <param name="progressListener">progress</param>
|
|
||||||
/// <returns>bytes from stream</returns>
|
|
||||||
public static byte[] StreamToBytes(Stream stream, IProgressListener progressListener)
|
|
||||||
{
|
|
||||||
if (stream == null)
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException("stream must not be null.");
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] bytes;
|
|
||||||
|
|
||||||
// Content length known?
|
|
||||||
if (stream.CanSeek)
|
|
||||||
{
|
|
||||||
bytes = new byte[stream.Length];
|
|
||||||
int bytesRead = 0;
|
|
||||||
int offset = 0;
|
|
||||||
|
|
||||||
//reader.Read
|
|
||||||
while (offset < stream.Length && (bytesRead = stream.Read(bytes, offset, (int)(bytes.Length - offset))) > 0)
|
|
||||||
{
|
|
||||||
offset += bytesRead;
|
|
||||||
|
|
||||||
if (progressListener != null)
|
|
||||||
{
|
|
||||||
progressListener.updateProgress((int)(100 * offset / bytes.Length));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offset != (int)bytes.Length)
|
|
||||||
{
|
|
||||||
throw new IOException("Unexpected read size. current: " + offset + ", excepted: " + bytes.Length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Content length is unknown - need to read until EOF
|
|
||||||
byte[] buffer = new byte[BUFFER_SIZE];
|
|
||||||
|
|
||||||
using (MemoryStream outputStream = new MemoryStream())
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
//int offset = 0;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
int bytesRead = stream.Read(buffer, 0, buffer.Length);
|
|
||||||
if (bytesRead == 0)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
outputStream.Write(buffer, 0, bytesRead);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (ArgumentOutOfRangeException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
outputStream.Position = 0;
|
|
||||||
bytes = new byte[outputStream.Length];
|
|
||||||
outputStream.Read(bytes, 0, bytes.Length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Write a byte array into a file in hexadecimal format.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="file">output file</param>
|
|
||||||
/// <param name="data">the data to be written</param>
|
|
||||||
public static void WriteHexFile(string file, byte[] data)
|
|
||||||
{
|
|
||||||
using (FileStream stream = File.OpenWrite(file))
|
|
||||||
using (StreamWriter writer = new StreamWriter(stream))
|
|
||||||
{
|
|
||||||
writer.Write(ByteArrayToHexString(data));
|
|
||||||
writer.Write('\n');
|
|
||||||
writer.Close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Write an encoded key to a file
|
|
||||||
/// Encoded key format: type:hex_key.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="file">output file</param>
|
|
||||||
/// <param name="key">a key that will be encoded and written to a file</param>
|
|
||||||
public static void WriteKeyFile(string file, Key key)
|
|
||||||
{
|
|
||||||
using (FileStream stream = File.OpenWrite(file))
|
|
||||||
using (StreamWriter writer = new StreamWriter(stream))
|
|
||||||
{
|
|
||||||
writer.Write(key.Encode());
|
|
||||||
writer.Write('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ReadLineFromFile(string file)
|
|
||||||
{
|
|
||||||
string data = null;
|
|
||||||
|
|
||||||
using (FileStream stream = File.OpenRead(file))
|
|
||||||
using (StreamReader reader = new StreamReader(stream))
|
|
||||||
{
|
|
||||||
data = reader.ReadLine().Trim();
|
|
||||||
reader.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Exceptions
|
|
||||||
{
|
|
||||||
public class BadMessageException : Exception
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Exceptions
|
|
||||||
{
|
|
||||||
public class CoreMigrationException : Exception
|
|
||||||
{
|
|
||||||
public CoreMigrationException(string message) :
|
|
||||||
base(message)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Exceptions
|
|
||||||
{
|
|
||||||
class DecryptionFailedException : Exception
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Exceptions
|
|
||||||
{
|
|
||||||
public class InvalidKeyException : Exception
|
|
||||||
{
|
|
||||||
public InvalidKeyException(string message) :
|
|
||||||
base(message)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public InvalidKeyException(string message, Exception innerException) :
|
|
||||||
base(message, innerException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Exceptions
|
|
||||||
{
|
|
||||||
public class MessageParseException : Exception
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Exceptions
|
|
||||||
{
|
|
||||||
public class NotAllowedException : Exception
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Exceptions
|
|
||||||
{
|
|
||||||
class UnsupportedMessageTypeException : Exception
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
#if CoreWinOnly
|
|
||||||
using Microsoft.Win32;
|
|
||||||
#endif
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Exceptions;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Messages;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Results;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Helpers
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Helper to handle Threema end-to-end encryption.
|
|
||||||
/// </summary>
|
|
||||||
public class E2EHelper
|
|
||||||
{
|
|
||||||
private readonly APIConnector apiConnector;
|
|
||||||
private readonly byte[] privateKey;
|
|
||||||
|
|
||||||
public E2EHelper(APIConnector apiConnector, byte[] privateKey)
|
|
||||||
{
|
|
||||||
this.apiConnector = apiConnector;
|
|
||||||
this.privateKey = privateKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Decrypt a Message and download the blobs of the Message (e.g. image or file)
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="threemaId">Threema ID of the sender</param>
|
|
||||||
/// <param name="messageId">Message ID</param>
|
|
||||||
/// <param name="box">Encrypted box data of the file/image message</param>
|
|
||||||
/// <param name="nonce">Nonce that was used for message encryption</param>
|
|
||||||
/// <param name="outputFolder">Output folder for storing decrypted images/files</param>
|
|
||||||
/// <returns>Result of message reception</returns>
|
|
||||||
public ReceiveMessageResult ReceiveMessage(string threemaId, string messageId, byte[] box, byte[] nonce, string outputFolder)
|
|
||||||
{
|
|
||||||
//fetch public key
|
|
||||||
byte[] publicKey = this.apiConnector.LookupKey(threemaId);
|
|
||||||
|
|
||||||
if(publicKey == null)
|
|
||||||
{
|
|
||||||
throw new InvalidKeyException("invalid threema id");
|
|
||||||
}
|
|
||||||
|
|
||||||
ThreemaMessage message = CryptTool.DecryptMessage(box, this.privateKey, publicKey, nonce);
|
|
||||||
if(message == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
ReceiveMessageResult result = new ReceiveMessageResult(messageId, message);
|
|
||||||
|
|
||||||
if (message.GetType() == typeof(ImageMessage))
|
|
||||||
{
|
|
||||||
//download image
|
|
||||||
ImageMessage imageMessage = (ImageMessage)message;
|
|
||||||
byte[] fileData = this.apiConnector.DownloadFile(imageMessage.BlobId);
|
|
||||||
|
|
||||||
if(fileData == null)
|
|
||||||
{
|
|
||||||
throw new MessageParseException();
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] decryptedFileContent = CryptTool.Decrypt(fileData, privateKey, publicKey, imageMessage.Nonce);
|
|
||||||
FileInfo imageFile = new FileInfo(outputFolder + "/" + messageId + ".jpg");
|
|
||||||
|
|
||||||
using (FileStream stream = File.OpenWrite(imageFile.FullName))
|
|
||||||
{
|
|
||||||
stream.Write(decryptedFileContent, 0, decryptedFileContent.Length);
|
|
||||||
stream.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Files.Add(imageFile);
|
|
||||||
}
|
|
||||||
else if (message.GetType() == typeof(FileMessage))
|
|
||||||
{
|
|
||||||
//download file
|
|
||||||
FileMessage fileMessage = (FileMessage)message;
|
|
||||||
byte[] fileData = this.apiConnector.DownloadFile(fileMessage.BlobId);
|
|
||||||
|
|
||||||
byte[] decryptedFileData = CryptTool.DecryptFileData(fileData, fileMessage.EncryptionKey);
|
|
||||||
FileInfo file = new FileInfo(outputFolder + "/" + messageId + "-" + fileMessage.FileName);
|
|
||||||
|
|
||||||
using (FileStream stream = File.OpenWrite(file.FullName))
|
|
||||||
{
|
|
||||||
stream.Write(decryptedFileData, 0, decryptedFileData.Length);
|
|
||||||
stream.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Files.Add(file);
|
|
||||||
|
|
||||||
if(fileMessage.ThumbnailBlobId != null)
|
|
||||||
{
|
|
||||||
byte[] thumbnailData = this.apiConnector.DownloadFile(fileMessage.ThumbnailBlobId);
|
|
||||||
|
|
||||||
byte[] decryptedThumbnailData = CryptTool.DecryptFileThumbnailData(thumbnailData, fileMessage.EncryptionKey);
|
|
||||||
FileInfo thumbnailFile = new FileInfo(outputFolder + "/" + messageId + "-thumbnail.jpg");
|
|
||||||
using (FileStream stream = File.OpenWrite(thumbnailFile.FullName))
|
|
||||||
{
|
|
||||||
stream.Write(decryptedThumbnailData, 0, decryptedThumbnailData.Length);
|
|
||||||
stream.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Files.Add(thumbnailFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encrypt a file message and send it to the given recipient.
|
|
||||||
/// The thumbnailMessagePath can be null.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="threemaId">target Threema ID</param>
|
|
||||||
/// <param name="fileMessageFile">the file to be sent</param>
|
|
||||||
/// <param name="thumbnailMessageFile">file for thumbnail; if not set, no thumbnail will be sent</param>
|
|
||||||
/// <returns>generated message ID</returns>
|
|
||||||
public string SendFileMessage(string threemaId, FileInfo fileMessageFile, FileInfo thumbnailMessageFile)
|
|
||||||
{
|
|
||||||
//fetch public key
|
|
||||||
byte[] publicKey = this.apiConnector.LookupKey(threemaId);
|
|
||||||
|
|
||||||
if (publicKey == null)
|
|
||||||
{
|
|
||||||
throw new InvalidKeyException("invalid threema id");
|
|
||||||
}
|
|
||||||
|
|
||||||
//check capability of a key
|
|
||||||
CapabilityResult capabilityResult = this.apiConnector.LookupKeyCapability(threemaId);
|
|
||||||
if (capabilityResult == null || !capabilityResult.CanImage)
|
|
||||||
{
|
|
||||||
throw new NotAllowedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileMessageFile == null)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("fileMessageFile must not be null.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fileMessageFile.Exists)
|
|
||||||
{
|
|
||||||
throw new FileNotFoundException(fileMessageFile.FullName);
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] fileData;
|
|
||||||
using (Stream stream = File.OpenRead(fileMessageFile.FullName))
|
|
||||||
{
|
|
||||||
fileData = new byte[stream.Length];
|
|
||||||
stream.Read(fileData, 0, (int)stream.Length);
|
|
||||||
stream.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileData == null)
|
|
||||||
{
|
|
||||||
throw new IOException("invalid file");
|
|
||||||
}
|
|
||||||
|
|
||||||
//encrypt the image
|
|
||||||
EncryptResult encryptResult = CryptTool.EncryptFileData(fileData);
|
|
||||||
|
|
||||||
//upload the image
|
|
||||||
UploadResult uploadResult = apiConnector.UploadFile(encryptResult);
|
|
||||||
|
|
||||||
if(!uploadResult.IsSuccess)
|
|
||||||
{
|
|
||||||
throw new IOException("could not upload file (upload response " + uploadResult.ResponseCode + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
UploadResult uploadResultThumbnail = null;
|
|
||||||
|
|
||||||
if (thumbnailMessageFile != null && thumbnailMessageFile.Exists)
|
|
||||||
{
|
|
||||||
byte[] thumbnailData;
|
|
||||||
using (Stream stream = File.OpenRead(thumbnailMessageFile.FullName))
|
|
||||||
{
|
|
||||||
thumbnailData = new byte[stream.Length];
|
|
||||||
stream.Read(thumbnailData, 0, (int)stream.Length);
|
|
||||||
stream.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thumbnailData == null)
|
|
||||||
{
|
|
||||||
throw new IOException("invalid thumbnail file");
|
|
||||||
}
|
|
||||||
|
|
||||||
//encrypt the thumbnail
|
|
||||||
EncryptResult encryptResultThumbnail = CryptTool.encryptFileThumbnailData(fileData, encryptResult.Secret);
|
|
||||||
|
|
||||||
//upload the thumbnail
|
|
||||||
uploadResultThumbnail = this.apiConnector.UploadFile(encryptResultThumbnail);
|
|
||||||
}
|
|
||||||
|
|
||||||
//send it
|
|
||||||
EncryptResult fileMessage = CryptTool.EncryptFileMessage(
|
|
||||||
encryptResult,
|
|
||||||
uploadResult,
|
|
||||||
GetMIMEType(fileMessageFile),
|
|
||||||
fileMessageFile.Name,
|
|
||||||
(int) fileMessageFile.Length,
|
|
||||||
uploadResultThumbnail,
|
|
||||||
privateKey, publicKey);
|
|
||||||
|
|
||||||
return this.apiConnector.SendE2EMessage(
|
|
||||||
threemaId,
|
|
||||||
fileMessage.Nonce,
|
|
||||||
fileMessage.Result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encrypt an image message and send it to the given recipient.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="threemaId">threemaId target Threema ID</param>
|
|
||||||
/// <param name="imageFilePath">path to read image data from</param>
|
|
||||||
/// <returns>generated message ID</returns>
|
|
||||||
public string SendImageMessage(string threemaId, string imageFilePath)
|
|
||||||
{
|
|
||||||
//fetch public key
|
|
||||||
byte[] publicKey = this.apiConnector.LookupKey(threemaId);
|
|
||||||
|
|
||||||
if (publicKey == null)
|
|
||||||
{
|
|
||||||
throw new InvalidKeyException("invalid threema id");
|
|
||||||
}
|
|
||||||
|
|
||||||
//check capability of a key
|
|
||||||
CapabilityResult capabilityResult = this.apiConnector.LookupKeyCapability(threemaId);
|
|
||||||
if (capabilityResult == null || !capabilityResult.CanImage)
|
|
||||||
{
|
|
||||||
throw new NotAllowedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] fileData = File.ReadAllBytes(imageFilePath);
|
|
||||||
if (fileData == null)
|
|
||||||
{
|
|
||||||
throw new IOException("invalid file");
|
|
||||||
}
|
|
||||||
|
|
||||||
//encrypt the image
|
|
||||||
EncryptResult encryptResult = CryptTool.Encrypt(fileData, this.privateKey, publicKey);
|
|
||||||
|
|
||||||
//upload the image
|
|
||||||
UploadResult uploadResult = apiConnector.UploadFile(encryptResult);
|
|
||||||
|
|
||||||
if (!uploadResult.IsSuccess)
|
|
||||||
{
|
|
||||||
throw new IOException("could not upload file (upload response " + uploadResult.ResponseCode + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
//send it
|
|
||||||
EncryptResult imageMessage = CryptTool.EncryptImageMessage(encryptResult, uploadResult, this.privateKey, publicKey);
|
|
||||||
|
|
||||||
return apiConnector.SendE2EMessage(
|
|
||||||
threemaId,
|
|
||||||
imageMessage.Nonce,
|
|
||||||
imageMessage.Result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encrypt a text message and send it to the given recipient.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="threemaId">target Threema ID</param>
|
|
||||||
/// <param name="text">the text to send</param>
|
|
||||||
/// <returns>generated message ID</returns>
|
|
||||||
public string SendTextMessage(string threemaId, string text)
|
|
||||||
{
|
|
||||||
//fetch public key
|
|
||||||
byte[] publicKey = this.apiConnector.LookupKey(threemaId);
|
|
||||||
|
|
||||||
if (publicKey == null)
|
|
||||||
{
|
|
||||||
throw new InvalidKeyException("invalid threema id");
|
|
||||||
}
|
|
||||||
EncryptResult res = CryptTool.EncryptTextMessage(text, this.privateKey, publicKey);
|
|
||||||
|
|
||||||
return this.apiConnector.SendE2EMessage(threemaId, res.Nonce, res.Result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get mime type of the file extension via registry.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="file">mime type of this file</param>
|
|
||||||
/// <returns>mime type</returns>
|
|
||||||
private string GetMIMEType(FileInfo file)
|
|
||||||
{
|
|
||||||
#if CoreWinOnly
|
|
||||||
if (file == null)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("file must not be null.");
|
|
||||||
}
|
|
||||||
|
|
||||||
string mimeType = "application/unknown";
|
|
||||||
|
|
||||||
//Using Microsoft.Win32.Registry. But you lose portability and can't run the application on Linux and MacOS anymore. Implement GetMIMEType in another way.
|
|
||||||
RegistryKey regKey = Registry.ClassesRoot.OpenSubKey(
|
|
||||||
file.Extension.ToLower()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (regKey != null)
|
|
||||||
{
|
|
||||||
object contentType = regKey.GetValue("Content Type");
|
|
||||||
|
|
||||||
if (contentType != null)
|
|
||||||
{
|
|
||||||
mimeType = contentType.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mimeType;
|
|
||||||
#else
|
|
||||||
throw new CoreMigrationException("Using Microsoft.Win32.Registry. But you lose portability and can't run the application on Linux and MacOS anymore. Implement GetMIMEType in another way.");
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi
|
|
||||||
{
|
|
||||||
public interface IProgressListener
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Update the progress of an upload/download process.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="progress">in percent (0..100)</param>
|
|
||||||
void updateProgress(int progress);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
|
||||||
<DefineConstants>TRACE;DEBUG;NETCOREAPP;NETCOREAPP2_1;CoreWinOnly</DefineConstants>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.1" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="2.1.1" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="2.1.1" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="2.1.1" />
|
|
||||||
<PackageReference Include="Microsoft.Win32.Registry" Version="4.5.0" />
|
|
||||||
<!-- F4 (Sicherheit): von 11.0.2 (Advisory NU1903, high) auf 13.0.4 gehoben, = App-Version. -->
|
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
|
||||||
<PackageReference Include="Sodium.Core" Version="1.2.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Exceptions;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Encapsulates an asymmetric key, either public or private.
|
|
||||||
/// </summary>
|
|
||||||
public class Key
|
|
||||||
{
|
|
||||||
public static readonly char separator = ':';
|
|
||||||
|
|
||||||
public static class KeyType
|
|
||||||
{
|
|
||||||
public const string PRIVATE = "private";
|
|
||||||
public const string PUBLIC = "public";
|
|
||||||
}
|
|
||||||
|
|
||||||
public byte[] key;
|
|
||||||
public string type;
|
|
||||||
|
|
||||||
public Key(string type, byte[] key)
|
|
||||||
{
|
|
||||||
this.key = key;
|
|
||||||
this.type = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Decodes and validates an encoded key.
|
|
||||||
/// Encoded key format: type:hex_key
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="encodedKey">an encoded key</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public static Key DecodeKey(string encodedKey)
|
|
||||||
{
|
|
||||||
// Split key and check length
|
|
||||||
string[] keyArray = encodedKey.Split(Key.separator);
|
|
||||||
if (keyArray.Length != 2)
|
|
||||||
{
|
|
||||||
throw new InvalidKeyException("Does not contain a valid key format");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unpack key
|
|
||||||
string keyType = keyArray[0];
|
|
||||||
string keyContent = keyArray[1];
|
|
||||||
|
|
||||||
// Is this a valid hex key?
|
|
||||||
if (!Regex.IsMatch(keyContent, "[0-9a-fA-F]{64}"))
|
|
||||||
{
|
|
||||||
throw new InvalidKeyException("Does not contain a valid key");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Key(keyType, DataUtils.HexStringToByteArray(keyContent));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Decodes and validates an encoded key.
|
|
||||||
/// Encoded key format: type:hex_key
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="encodedKey">an encoded key</param>
|
|
||||||
/// <param name="expectedKeyType">the expected type of the key</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public static Key DecodeKey(String encodedKey, String expectedKeyType)
|
|
||||||
{
|
|
||||||
Key key = DecodeKey(encodedKey);
|
|
||||||
|
|
||||||
// Check key type
|
|
||||||
if (!key.type.Equals(expectedKeyType))
|
|
||||||
{
|
|
||||||
throw new InvalidKeyException("Expected key type: " + expectedKeyType + ", got: " + key.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encodes a key.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>an encoded key</returns>
|
|
||||||
public String Encode()
|
|
||||||
{
|
|
||||||
return this.type + Key.separator + DataUtils.ByteArrayToHexString(this.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi
|
|
||||||
{
|
|
||||||
class MessageId
|
|
||||||
{
|
|
||||||
public const int MESSAGE_ID_LEN = 8;
|
|
||||||
|
|
||||||
private readonly byte[] messageId;
|
|
||||||
|
|
||||||
public MessageId(byte[] messageId)
|
|
||||||
{
|
|
||||||
if (messageId.Length != MESSAGE_ID_LEN)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Bad message ID length");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.messageId = messageId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public MessageId(byte[] data, int offset)
|
|
||||||
{
|
|
||||||
if ((offset + MESSAGE_ID_LEN) > data.Length)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Bad message ID buffer length");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.messageId = new byte[MESSAGE_ID_LEN];
|
|
||||||
//System.arraycopy(data, offset, this.messageId, 0, MESSAGE_ID_LEN);
|
|
||||||
data.Skip(offset).Take(MESSAGE_ID_LEN).ToArray().CopyTo(this.messageId, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public byte[] GetMessageId
|
|
||||||
{
|
|
||||||
get { return messageId; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() {
|
|
||||||
return DataUtils.ByteArrayToHexString(messageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-101
@@ -1,101 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Messages
|
|
||||||
{
|
|
||||||
class DeliveryReceipt : ThreemaMessage
|
|
||||||
{
|
|
||||||
public const int TYPE_CODE = 0x80;
|
|
||||||
|
|
||||||
public enum Type
|
|
||||||
{
|
|
||||||
RECEIVED = 1,
|
|
||||||
READ = 2,
|
|
||||||
USER_ACK = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly Type receiptType;
|
|
||||||
private readonly List<MessageId> ackedMessageIds;
|
|
||||||
|
|
||||||
public DeliveryReceipt(Type receiptType, List<MessageId> ackedMessageIds) {
|
|
||||||
this.receiptType = receiptType;
|
|
||||||
this.ackedMessageIds = ackedMessageIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Type ReceiptType
|
|
||||||
{
|
|
||||||
get { return receiptType; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<MessageId> AckedMessageIds
|
|
||||||
{
|
|
||||||
get { return ackedMessageIds; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public override int GetTypeCode() {
|
|
||||||
return TYPE_CODE;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override byte[] GetData()
|
|
||||||
{
|
|
||||||
//Not implemented yet
|
|
||||||
return new byte[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
StringBuilder sb = new StringBuilder("Delivery receipt (");
|
|
||||||
sb.Append(receiptType);
|
|
||||||
sb.Append("): ");
|
|
||||||
int i = 0;
|
|
||||||
ackedMessageIds.ForEach(messageId =>
|
|
||||||
{
|
|
||||||
if (i != 0)
|
|
||||||
{
|
|
||||||
sb.Append(", ");
|
|
||||||
}
|
|
||||||
sb.Append(messageId);
|
|
||||||
i++;
|
|
||||||
});
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A delivery receipt type. The following types are defined:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>RECEIVED: the message has been received and decrypted on the recipient's device</li>
|
|
||||||
* <li>READ: the message has been shown to the user in the chat view
|
|
||||||
* (note that this status can be disabled)</li>
|
|
||||||
* <li>USER_ACK: the user has explicitly acknowledged the message (usually by
|
|
||||||
* long-pressing it and choosing the "acknowledge" option)</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
/*
|
|
||||||
public enum Type {
|
|
||||||
RECEIVED(1), READ(2), USER_ACK(3);
|
|
||||||
|
|
||||||
private final int code;
|
|
||||||
|
|
||||||
Type(int code) {
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getCode() {
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Type get(int code) {
|
|
||||||
for (Type t : values()) {
|
|
||||||
if (t.code == code)
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Exceptions;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Messages
|
|
||||||
{
|
|
||||||
class FileMessage : ThreemaMessage
|
|
||||||
{
|
|
||||||
public const int TYPE_CODE = 0x17;
|
|
||||||
|
|
||||||
private const string KEY_BLOB_ID = "b";
|
|
||||||
private const string KEY_THUMBNAIL_BLOB_ID = "t";
|
|
||||||
private const string KEY_ENCRYPTION_KEY = "k";
|
|
||||||
private const string KEY_MIME_TYPE = "m";
|
|
||||||
private const string KEY_FILE_NAME = "n";
|
|
||||||
private const string KEY_FILE_SIZE = "s";
|
|
||||||
private const string KEY_TYPE = "i";
|
|
||||||
|
|
||||||
private readonly byte[] blobId;
|
|
||||||
private readonly byte[] encryptionKey;
|
|
||||||
private readonly string mimeType;
|
|
||||||
private readonly string fileName;
|
|
||||||
private readonly int fileSize;
|
|
||||||
private readonly byte[] thumbnailBlobId;
|
|
||||||
|
|
||||||
public FileMessage(byte[] blobId, byte[] encryptionKey, String mimeType, String fileName, int fileSize, byte[] thumbnailBlobId)
|
|
||||||
{
|
|
||||||
this.blobId = blobId;
|
|
||||||
this.encryptionKey = encryptionKey;
|
|
||||||
this.mimeType = mimeType;
|
|
||||||
this.fileName = fileName;
|
|
||||||
this.fileSize = fileSize;
|
|
||||||
this.thumbnailBlobId = thumbnailBlobId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public byte[] BlobId
|
|
||||||
{
|
|
||||||
get { return this.blobId; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public byte[] EncryptionKey
|
|
||||||
{
|
|
||||||
get { return this.encryptionKey; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public string MimeType
|
|
||||||
{
|
|
||||||
get { return this.mimeType; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public string FileName
|
|
||||||
{
|
|
||||||
get { return this.fileName; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public int FileSize
|
|
||||||
{
|
|
||||||
get { return this.fileSize; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public byte[] ThumbnailBlobId
|
|
||||||
{
|
|
||||||
get { return this.thumbnailBlobId; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public override int GetTypeCode()
|
|
||||||
{
|
|
||||||
return TYPE_CODE;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
return string.Format("file message {0}", this.fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override byte[] GetData()
|
|
||||||
{
|
|
||||||
JObject jo = new JObject();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
jo.Add(KEY_BLOB_ID, JToken.FromObject(DataUtils.ByteArrayToHexString(this.blobId)));
|
|
||||||
if (this.thumbnailBlobId != null)
|
|
||||||
{
|
|
||||||
jo.Add(KEY_THUMBNAIL_BLOB_ID, JToken.FromObject(DataUtils.ByteArrayToHexString(this.thumbnailBlobId)));
|
|
||||||
}
|
|
||||||
jo.Add(KEY_ENCRYPTION_KEY, JToken.FromObject(DataUtils.ByteArrayToHexString(this.encryptionKey)));
|
|
||||||
jo.Add(KEY_MIME_TYPE, JToken.FromObject(this.mimeType));
|
|
||||||
jo.Add(KEY_FILE_NAME, JToken.FromObject(this.fileName));
|
|
||||||
jo.Add(KEY_FILE_SIZE, JToken.FromObject(this.fileSize));
|
|
||||||
jo.Add(KEY_TYPE, JToken.FromObject(0));
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
throw new BadMessageException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Encoding.UTF8.GetBytes(jo.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static FileMessage FromString(string json)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
JObject jo = JObject.Parse(json);
|
|
||||||
byte[] encryptionKey = DataUtils.HexStringToByteArray(jo[KEY_ENCRYPTION_KEY].Value<string>());
|
|
||||||
string mimeType = jo[KEY_MIME_TYPE].Value<string>();
|
|
||||||
int fileSize = jo[KEY_FILE_SIZE].Value<int>();
|
|
||||||
byte[] blobId = DataUtils.HexStringToByteArray(jo[KEY_BLOB_ID].Value<string>());
|
|
||||||
|
|
||||||
string fileName;
|
|
||||||
byte[] thumbnailBlobId = null;
|
|
||||||
|
|
||||||
//optional field
|
|
||||||
if (jo.Children().Any(e => e.Path.EndsWith(KEY_THUMBNAIL_BLOB_ID)))
|
|
||||||
{
|
|
||||||
thumbnailBlobId = DataUtils.HexStringToByteArray(jo[KEY_THUMBNAIL_BLOB_ID].Value<string>());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jo.Children().Any(e => e.Path.EndsWith(KEY_FILE_NAME)))
|
|
||||||
{
|
|
||||||
fileName = jo[KEY_FILE_NAME].Value<string>();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
fileName = "unnamed";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FileMessage(
|
|
||||||
blobId,
|
|
||||||
encryptionKey,
|
|
||||||
mimeType,
|
|
||||||
fileName,
|
|
||||||
fileSize,
|
|
||||||
thumbnailBlobId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
throw new BadMessageException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Messages
|
|
||||||
{
|
|
||||||
class ImageMessage : ThreemaMessage
|
|
||||||
{
|
|
||||||
public const int TYPE_CODE = 0x02;
|
|
||||||
|
|
||||||
private readonly byte[] blobId;
|
|
||||||
private readonly int size;
|
|
||||||
private readonly byte[] nonce;
|
|
||||||
|
|
||||||
public ImageMessage(byte[] blobId, int size, byte[] nonce)
|
|
||||||
{
|
|
||||||
this.blobId = blobId;
|
|
||||||
this.size = size;
|
|
||||||
this.nonce = nonce;
|
|
||||||
}
|
|
||||||
|
|
||||||
public byte[] BlobId
|
|
||||||
{
|
|
||||||
get { return this.blobId; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public int Size
|
|
||||||
{
|
|
||||||
get { return this.size; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public byte[] Nonce
|
|
||||||
{
|
|
||||||
get { return this.nonce; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public override int GetTypeCode()
|
|
||||||
{
|
|
||||||
return TYPE_CODE;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override byte[] GetData()
|
|
||||||
{
|
|
||||||
byte[] data = new byte[BLOB_ID_LEN + 4 + ThreemaMessage.NONCEBYTES];
|
|
||||||
int pos = 0;
|
|
||||||
|
|
||||||
//System.arraycopy(this.blobId, 0, data, pos, BLOB_ID_LEN);
|
|
||||||
this.blobId.CopyTo(data, 0);
|
|
||||||
pos += BLOB_ID_LEN;
|
|
||||||
|
|
||||||
//EndianUtils.writeSwappedInteger(data, pos, this.size);
|
|
||||||
byte[] size = BitConverter.GetBytes(this.size);
|
|
||||||
size.CopyTo(data, pos);
|
|
||||||
pos += 4;
|
|
||||||
|
|
||||||
//System.arraycopy(this.nonce, 0, data, pos, ThreemaMessage.NONCEBYTES);
|
|
||||||
this.nonce.CopyTo(data, pos);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
return string.Format("blob {0}", DataUtils.ByteArrayToHexString(this.blobId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Messages
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A text message that can be sent/received with end-to-end encryption via Threema.
|
|
||||||
/// </summary>
|
|
||||||
public class TextMessage : ThreemaMessage
|
|
||||||
{
|
|
||||||
public const int TYPE_CODE = 0x01;
|
|
||||||
|
|
||||||
private readonly string text;
|
|
||||||
|
|
||||||
public TextMessage(String text)
|
|
||||||
{
|
|
||||||
this.text = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Text { get { return text; } }
|
|
||||||
|
|
||||||
public override int GetTypeCode() {
|
|
||||||
return TYPE_CODE;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override byte[] GetData()
|
|
||||||
{
|
|
||||||
return Encoding.UTF8.GetBytes(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Messages
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Abstract base class of messages that can be sent with end-to-end encryption via Threema.
|
|
||||||
/// </summary>
|
|
||||||
public abstract class ThreemaMessage
|
|
||||||
{
|
|
||||||
public const int NONCEBYTES = 24;
|
|
||||||
public const int BLOB_ID_LEN = 16;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get message's raw content
|
|
||||||
/// </summary>
|
|
||||||
/// <returns></returns>
|
|
||||||
public abstract byte[] GetData();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get message's type code
|
|
||||||
/// </summary>
|
|
||||||
/// <returns></returns>
|
|
||||||
public abstract int GetTypeCode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Stores and caches public keys for Threema users. Extend this class to provide your
|
|
||||||
/// own storage implementation, e.g. in a file or database.
|
|
||||||
/// </summary>
|
|
||||||
public abstract class PublicKeyStore
|
|
||||||
{
|
|
||||||
private readonly static object lockCache = new object();
|
|
||||||
private readonly Dictionary<string, byte[]> cache = new Dictionary<string, byte[]>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the public key for a given Threema ID. The cache is checked first; if it
|
|
||||||
/// is not found in the cache, fetchPublicKey() is called.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="threemaId">The Threema ID whose public key should be obtained</param>
|
|
||||||
/// <returns>The public key, or null if not found</returns>
|
|
||||||
public byte[] GetPublicKey(string threemaId)
|
|
||||||
{
|
|
||||||
lock (lockCache)
|
|
||||||
{
|
|
||||||
byte[] pk = null;
|
|
||||||
|
|
||||||
if (this.cache.Keys.Contains(threemaId))
|
|
||||||
{
|
|
||||||
pk = this.cache[threemaId];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
pk = this.FetchPublicKey(threemaId);
|
|
||||||
if (pk != null)
|
|
||||||
{
|
|
||||||
this.cache.Add(threemaId, pk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pk;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Store the public key for a given Threema ID in the cache, and the underlying store.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="threemaId">The Threema ID whose public key should be stored</param>
|
|
||||||
/// <param name="publicKey">The corresponding public key</param>
|
|
||||||
public void SetPublicKey(string threemaId, byte[] publicKey)
|
|
||||||
{
|
|
||||||
if(publicKey != null)
|
|
||||||
{
|
|
||||||
lock (lockCache)
|
|
||||||
{
|
|
||||||
this.cache.Add(threemaId, publicKey);
|
|
||||||
this.Save(threemaId, publicKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fetch the public key for the given Threema ID from the store. Override to provide
|
|
||||||
/// your own implementation to read from the store.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="threemaId">The Threema ID whose public key should be obtained</param>
|
|
||||||
/// <returns>The public key, or null if not found</returns>
|
|
||||||
abstract protected byte[] FetchPublicKey(string threemaId);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Save the public key for a given Threema ID in the store. Override to provide
|
|
||||||
/// your own implementation to write to the store.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="threemaId">The Threema ID whose public key should be stored</param>
|
|
||||||
/// <param name="publicKey">The corresponding public key</param>
|
|
||||||
abstract protected void Save(string threemaId, byte[] publicKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Configuration;
|
|
||||||
using System.Data;
|
|
||||||
using System.Data.Common;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Exceptions;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi
|
|
||||||
{
|
|
||||||
class PublicKeyStoreDb : PublicKeyStore
|
|
||||||
{
|
|
||||||
private readonly string connectionString;
|
|
||||||
|
|
||||||
public PublicKeyStoreDb(string connectionString)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(connectionString))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("connectionString must be not null or empty.");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.connectionString = connectionString;
|
|
||||||
|
|
||||||
this.CreateDatabase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fetch public key in store for particular threema id
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="threemaId">Threema id to fetch</param>
|
|
||||||
/// <returns>Public key</returns>
|
|
||||||
protected override byte[] FetchPublicKey(string threemaId)
|
|
||||||
{
|
|
||||||
using (DbConnection connection = GetConnection(this.connectionString))
|
|
||||||
{
|
|
||||||
connection.Open();
|
|
||||||
|
|
||||||
string sql = "SELECT threema_id, key FROM public_key WHERE threema_id = @threemaId";
|
|
||||||
|
|
||||||
var command = connection.CreateCommand();
|
|
||||||
command.CommandType = CommandType.Text;
|
|
||||||
command.CommandText = sql;
|
|
||||||
|
|
||||||
var paramThreemaId = command.CreateParameter();
|
|
||||||
paramThreemaId.ParameterName = "threemaId";
|
|
||||||
paramThreemaId.Value = threemaId;
|
|
||||||
command.Parameters.Add(paramThreemaId);
|
|
||||||
|
|
||||||
byte[] publicKey = null;
|
|
||||||
|
|
||||||
using (var reader = command.ExecuteReader(CommandBehavior.SingleRow))
|
|
||||||
{
|
|
||||||
if (reader.HasRows)
|
|
||||||
{
|
|
||||||
if (reader.Read())
|
|
||||||
{
|
|
||||||
publicKey = DataUtils.HexStringToByteArray(reader[1].ToString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
return publicKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Save threema id and public key into store
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="threemaId">Threema id</param>
|
|
||||||
/// <param name="publicKey">public key</param>
|
|
||||||
protected override void Save(string threemaId, byte[] publicKey)
|
|
||||||
{
|
|
||||||
using (DbConnection connection = GetConnection(this.connectionString))
|
|
||||||
{
|
|
||||||
connection.Open();
|
|
||||||
|
|
||||||
string sql = "INSERT INTO public_key (threema_id, key) VALUES (@threemaId, @key)";
|
|
||||||
|
|
||||||
var command = connection.CreateCommand();
|
|
||||||
command.CommandType = CommandType.Text;
|
|
||||||
command.CommandText = sql;
|
|
||||||
|
|
||||||
var paramThreemaId = command.CreateParameter();
|
|
||||||
paramThreemaId.ParameterName = "threemaId";
|
|
||||||
paramThreemaId.Value = threemaId;
|
|
||||||
|
|
||||||
var paramKey = command.CreateParameter();
|
|
||||||
paramKey.ParameterName = "key";
|
|
||||||
paramKey.Value = DataUtils.ByteArrayToHexString(publicKey);
|
|
||||||
|
|
||||||
command.Parameters.Add(paramThreemaId);
|
|
||||||
command.Parameters.Add(paramKey);
|
|
||||||
command.ExecuteNonQuery();
|
|
||||||
|
|
||||||
connection.Close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create database and table for public key store
|
|
||||||
/// </summary>
|
|
||||||
private void CreateDatabase()
|
|
||||||
{
|
|
||||||
using (DbConnection connection = GetConnection(this.connectionString))
|
|
||||||
{
|
|
||||||
connection.Open();
|
|
||||||
|
|
||||||
string sql = "CREATE TABLE IF NOT EXISTS public_key (id INTEGER PRIMARY KEY AUTOINCREMENT, threema_id VARCHAR(8) NOT NULL UNIQUE, key VARCHAR(64) NOT NULL)";
|
|
||||||
|
|
||||||
var command = connection.CreateCommand();
|
|
||||||
command.CommandText = sql;
|
|
||||||
command.ExecuteNonQuery();
|
|
||||||
|
|
||||||
connection.Close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get connetion is configured in App.config
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="connectionString">Connect String</param>
|
|
||||||
/// <returns>Db Connection</returns>
|
|
||||||
private DbConnection GetConnection(string connectionString)
|
|
||||||
{
|
|
||||||
//throw new CoreMigrationException("SQLite is not supported");
|
|
||||||
return new SqliteConnection(connectionString);
|
|
||||||
|
|
||||||
//string providerName = "System.Data.SQLite";
|
|
||||||
//var dbProvider = Microsoft.Data.Sqlite.SqliteFactory.Instance;
|
|
||||||
/*
|
|
||||||
string providerName = null;
|
|
||||||
|
|
||||||
DbConnectionStringBuilder connectionStringBuilder = new DbConnectionStringBuilder { ConnectionString = connectionString };
|
|
||||||
|
|
||||||
if (connectionStringBuilder.ContainsKey("provider"))
|
|
||||||
{
|
|
||||||
providerName = connectionStringBuilder["provider"].ToString();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ConnectionStringSettings connectionStringSetting = ConfigurationManager
|
|
||||||
.ConnectionStrings
|
|
||||||
.Cast<ConnectionStringSettings>()
|
|
||||||
.FirstOrDefault(x => x.ConnectionString == connectionString);
|
|
||||||
if (connectionStringSetting != null)
|
|
||||||
{
|
|
||||||
providerName = connectionStringSetting.ProviderName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (providerName != null)
|
|
||||||
{
|
|
||||||
bool providerExists = DbProviderFactories
|
|
||||||
.GetFactoryClasses()
|
|
||||||
.Rows.Cast<DataRow>()
|
|
||||||
.Any(r => r[2].Equals(providerName));
|
|
||||||
if (providerExists)
|
|
||||||
{
|
|
||||||
DbProviderFactory factory = DbProviderFactories.GetFactory(providerName);
|
|
||||||
DbConnection dbConnection = factory.CreateConnection();
|
|
||||||
|
|
||||||
dbConnection.ConnectionString = connectionString;
|
|
||||||
return dbConnection;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi
|
|
||||||
{
|
|
||||||
public class PublicKeyStoreNone : PublicKeyStore
|
|
||||||
{
|
|
||||||
protected override byte[] FetchPublicKey(string threemaId)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Save(string threemaId, byte[] publicKey)
|
|
||||||
{
|
|
||||||
//do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-95
@@ -1,95 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Results
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Result of a capability lookup
|
|
||||||
/// </summary>
|
|
||||||
public class CapabilityResult
|
|
||||||
{
|
|
||||||
private readonly string key;
|
|
||||||
private readonly string[] capabilities;
|
|
||||||
|
|
||||||
public CapabilityResult(string key, string[] capabilities)
|
|
||||||
{
|
|
||||||
this.key = key;
|
|
||||||
this.capabilities = capabilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Key
|
|
||||||
{
|
|
||||||
get { return key; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get all capabilities as a string array.
|
|
||||||
/// </summary>
|
|
||||||
public string[] Capabilities
|
|
||||||
{
|
|
||||||
get { return capabilities; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check whether the Threema ID can receive text
|
|
||||||
/// </summary>
|
|
||||||
public bool CanText
|
|
||||||
{
|
|
||||||
get { return this.Can("text"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check whether the Threema ID can receive images
|
|
||||||
/// </summary>
|
|
||||||
public bool CanImage
|
|
||||||
{
|
|
||||||
get { return this.Can("image"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check whether the Threema ID can receive videos
|
|
||||||
/// </summary>
|
|
||||||
public bool CanVideo
|
|
||||||
{
|
|
||||||
get { return this.Can("video"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check whether the Threema ID can receive audio
|
|
||||||
/// </summary>
|
|
||||||
public bool CanAudio
|
|
||||||
{
|
|
||||||
get { return this.Can("audio"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check whether the Threema ID can receive files
|
|
||||||
/// </summary>
|
|
||||||
public bool CanFile
|
|
||||||
{
|
|
||||||
get { return this.Can("file"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
StringBuilder b = new StringBuilder();
|
|
||||||
b.Append(this.key).Append(": ");
|
|
||||||
for (int n = 0; n < this.capabilities.Length; n++) {
|
|
||||||
if (n > 0)
|
|
||||||
{
|
|
||||||
b.Append(",");
|
|
||||||
}
|
|
||||||
b.Append(this.capabilities[n]);
|
|
||||||
}
|
|
||||||
return b.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool Can(string key)
|
|
||||||
{
|
|
||||||
return this.capabilities.Any(k => k.Equals(key));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Results
|
|
||||||
{
|
|
||||||
public class EncryptResult
|
|
||||||
{
|
|
||||||
private readonly byte[] result;
|
|
||||||
private readonly byte[] secret;
|
|
||||||
private readonly byte[] nonce;
|
|
||||||
|
|
||||||
public EncryptResult(byte[] result, byte[] secret, byte[] nonce)
|
|
||||||
{
|
|
||||||
this.result = result;
|
|
||||||
this.secret = secret;
|
|
||||||
this.nonce = nonce;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// the encrypted data
|
|
||||||
/// </summary>
|
|
||||||
public byte[] Result
|
|
||||||
{
|
|
||||||
get { return this.result; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// the size (in bytes) of the encrypted data
|
|
||||||
/// </summary>
|
|
||||||
public int Size
|
|
||||||
{
|
|
||||||
get { return this.result.Length; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// the nonce that was used for encryption
|
|
||||||
/// </summary>
|
|
||||||
public byte[] Nonce
|
|
||||||
{
|
|
||||||
get { return this.nonce; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// the secret that was used for encryption (only for symmetric encryption, e.g. files)
|
|
||||||
/// </summary>
|
|
||||||
public byte[] Secret
|
|
||||||
{
|
|
||||||
get { return secret; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IcgSoftware.Threema.CoreMsgApi.Messages;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Results
|
|
||||||
{
|
|
||||||
public class ReceiveMessageResult
|
|
||||||
{
|
|
||||||
private readonly string messageId;
|
|
||||||
private readonly ThreemaMessage message;
|
|
||||||
protected List<FileInfo> files = new List<FileInfo>();
|
|
||||||
protected List<string> errors = new List<string>();
|
|
||||||
|
|
||||||
public ReceiveMessageResult(string messageId, ThreemaMessage message)
|
|
||||||
{
|
|
||||||
this.messageId = messageId;
|
|
||||||
this.message = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<FileInfo> Files
|
|
||||||
{
|
|
||||||
get { return this.files; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<string> Errors
|
|
||||||
{
|
|
||||||
get { return this.errors; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public ThreemaMessage Message
|
|
||||||
{
|
|
||||||
get { return this.message; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public string MessageId
|
|
||||||
{
|
|
||||||
get { return messageId; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace IcgSoftware.Threema.CoreMsgApi.Results
|
|
||||||
{
|
|
||||||
public class UploadResult
|
|
||||||
{
|
|
||||||
private readonly int responseCode;
|
|
||||||
private readonly byte[] blobId;
|
|
||||||
|
|
||||||
public UploadResult(int responseCode, byte[] blobId)
|
|
||||||
{
|
|
||||||
this.responseCode = responseCode;
|
|
||||||
this.blobId = blobId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// the blob ID that has been created
|
|
||||||
/// </summary>
|
|
||||||
public byte[] BlobId
|
|
||||||
{
|
|
||||||
get { return this.blobId; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// whether the upload succeeded
|
|
||||||
/// </summary>
|
|
||||||
public bool IsSuccess
|
|
||||||
{
|
|
||||||
get { return this.responseCode == 200; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// the response code of the upload
|
|
||||||
/// </summary>
|
|
||||||
public int ResponseCode
|
|
||||||
{
|
|
||||||
get { return this.responseCode; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
# Visual Studio 15
|
|
||||||
VisualStudioVersion = 15.0.27428.2043
|
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IcgSoftware.Threema.CoreMsgApi", "IcgSoftware.Threema.CoreMsgApi\IcgSoftware.Threema.CoreMsgApi.csproj", "{B5799291-7E0A-4B42-AAC8-378535E1E579}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IcgSoftware.Threema.CoreMsgApi.Test", "IcgSoftware.Threema.CoreMsgApi.Test\IcgSoftware.Threema.CoreMsgApi.Test.csproj", "{05B5EB0A-5D3B-4CAD-9C68-3292A0F17FBB}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IcgSoftware.Threema.CoreMsgApi.Console", "IcgSoftware.Threema.CoreMsgApi.Console\IcgSoftware.Threema.CoreMsgApi.Console.csproj", "{909129FB-810C-4B5D-AD92-774525BD3D77}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IcgSoftware.Threema.CoreWebApp", "IcgSoftware.Threema.CoreWebApp\IcgSoftware.Threema.CoreWebApp.csproj", "{68345BDF-E4BA-4F41-8424-F02F613529FB}"
|
|
||||||
EndProject
|
|
||||||
Global
|
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
|
||||||
Debug|Any CPU = Debug|Any CPU
|
|
||||||
Debug|x64 = Debug|x64
|
|
||||||
Debug|x86 = Debug|x86
|
|
||||||
Release|Any CPU = Release|Any CPU
|
|
||||||
Release|x64 = Release|x64
|
|
||||||
Release|x86 = Release|x86
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
|
||||||
{B5799291-7E0A-4B42-AAC8-378535E1E579}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{B5799291-7E0A-4B42-AAC8-378535E1E579}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{B5799291-7E0A-4B42-AAC8-378535E1E579}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{B5799291-7E0A-4B42-AAC8-378535E1E579}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{B5799291-7E0A-4B42-AAC8-378535E1E579}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{B5799291-7E0A-4B42-AAC8-378535E1E579}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{B5799291-7E0A-4B42-AAC8-378535E1E579}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{B5799291-7E0A-4B42-AAC8-378535E1E579}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{B5799291-7E0A-4B42-AAC8-378535E1E579}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{B5799291-7E0A-4B42-AAC8-378535E1E579}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{B5799291-7E0A-4B42-AAC8-378535E1E579}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{B5799291-7E0A-4B42-AAC8-378535E1E579}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{05B5EB0A-5D3B-4CAD-9C68-3292A0F17FBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{05B5EB0A-5D3B-4CAD-9C68-3292A0F17FBB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{05B5EB0A-5D3B-4CAD-9C68-3292A0F17FBB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{05B5EB0A-5D3B-4CAD-9C68-3292A0F17FBB}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{05B5EB0A-5D3B-4CAD-9C68-3292A0F17FBB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{05B5EB0A-5D3B-4CAD-9C68-3292A0F17FBB}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{05B5EB0A-5D3B-4CAD-9C68-3292A0F17FBB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{05B5EB0A-5D3B-4CAD-9C68-3292A0F17FBB}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{05B5EB0A-5D3B-4CAD-9C68-3292A0F17FBB}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{05B5EB0A-5D3B-4CAD-9C68-3292A0F17FBB}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{05B5EB0A-5D3B-4CAD-9C68-3292A0F17FBB}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{05B5EB0A-5D3B-4CAD-9C68-3292A0F17FBB}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{909129FB-810C-4B5D-AD92-774525BD3D77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{909129FB-810C-4B5D-AD92-774525BD3D77}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{909129FB-810C-4B5D-AD92-774525BD3D77}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{909129FB-810C-4B5D-AD92-774525BD3D77}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{909129FB-810C-4B5D-AD92-774525BD3D77}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{909129FB-810C-4B5D-AD92-774525BD3D77}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{909129FB-810C-4B5D-AD92-774525BD3D77}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{909129FB-810C-4B5D-AD92-774525BD3D77}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{909129FB-810C-4B5D-AD92-774525BD3D77}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{909129FB-810C-4B5D-AD92-774525BD3D77}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{909129FB-810C-4B5D-AD92-774525BD3D77}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{909129FB-810C-4B5D-AD92-774525BD3D77}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{68345BDF-E4BA-4F41-8424-F02F613529FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{68345BDF-E4BA-4F41-8424-F02F613529FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{68345BDF-E4BA-4F41-8424-F02F613529FB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{68345BDF-E4BA-4F41-8424-F02F613529FB}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{68345BDF-E4BA-4F41-8424-F02F613529FB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{68345BDF-E4BA-4F41-8424-F02F613529FB}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{68345BDF-E4BA-4F41-8424-F02F613529FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{68345BDF-E4BA-4F41-8424-F02F613529FB}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{68345BDF-E4BA-4F41-8424-F02F613529FB}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{68345BDF-E4BA-4F41-8424-F02F613529FB}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{68345BDF-E4BA-4F41-8424-F02F613529FB}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{68345BDF-E4BA-4F41-8424-F02F613529FB}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
|
||||||
HideSolutionNode = FALSE
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
|
||||||
SolutionGuid = {DDF51293-E36E-4B16-A826-B74150F6F825}
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2018 pt-icg
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Threema MsgApi .NET Core
|
|
||||||
|
|
||||||
This project is a .NET Core fork of the [Threema Message API SDK-NET](https://gateway.threema.ch/en/developer/sdk-net).
|
|
||||||
|
|
||||||
The Threema Message API is an interface that can be used from within customer-specific software to send and receive messages via [Threema Gateway](https://gateway.threema.ch/en).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Configuration for Testing
|
|
||||||
|
|
||||||
|
|
||||||
Configure in application.json:
|
|
||||||
|
|
||||||
- Threema
|
|
||||||
- PrivateKey
|
|
||||||
- ThreemaId
|
|
||||||
- Secret
|
|
||||||
- Remove entry "SQLiteConnectionString" or set entry for using public key store.
|
|
||||||
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
## Ignore Visual Studio temporary files, build results, and
|
|
||||||
## files generated by popular Visual Studio add-ons.
|
|
||||||
##
|
|
||||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
|
||||||
|
|
||||||
# User-specific files
|
|
||||||
*.suo
|
|
||||||
*.user
|
|
||||||
*.userosscache
|
|
||||||
*.sln.docstates
|
|
||||||
|
|
||||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
|
||||||
*.userprefs
|
|
||||||
|
|
||||||
# Build results
|
|
||||||
[Dd]ebug/
|
|
||||||
[Dd]ebugPublic/
|
|
||||||
[Rr]elease/
|
|
||||||
[Rr]eleases/
|
|
||||||
x64/
|
|
||||||
x86/
|
|
||||||
bld/
|
|
||||||
[Bb]in/
|
|
||||||
[Oo]bj/
|
|
||||||
[Ll]og/
|
|
||||||
|
|
||||||
# Visual Studio 2015/2017 cache/options directory
|
|
||||||
.vs/
|
|
||||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
|
||||||
#wwwroot/
|
|
||||||
|
|
||||||
# Visual Studio 2017 auto generated files
|
|
||||||
Generated\ Files/
|
|
||||||
|
|
||||||
# MSTest test Results
|
|
||||||
[Tt]est[Rr]esult*/
|
|
||||||
[Bb]uild[Ll]og.*
|
|
||||||
|
|
||||||
# NUNIT
|
|
||||||
*.VisualState.xml
|
|
||||||
TestResult.xml
|
|
||||||
|
|
||||||
# Build Results of an ATL Project
|
|
||||||
[Dd]ebugPS/
|
|
||||||
[Rr]eleasePS/
|
|
||||||
dlldata.c
|
|
||||||
|
|
||||||
# Benchmark Results
|
|
||||||
BenchmarkDotNet.Artifacts/
|
|
||||||
|
|
||||||
# .NET Core
|
|
||||||
project.lock.json
|
|
||||||
project.fragment.lock.json
|
|
||||||
artifacts/
|
|
||||||
|
|
||||||
# StyleCop
|
|
||||||
StyleCopReport.xml
|
|
||||||
|
|
||||||
# Files built by Visual Studio
|
|
||||||
*_i.c
|
|
||||||
*_p.c
|
|
||||||
*_h.h
|
|
||||||
*.ilk
|
|
||||||
*.meta
|
|
||||||
*.obj
|
|
||||||
*.iobj
|
|
||||||
*.pch
|
|
||||||
*.pdb
|
|
||||||
*.ipdb
|
|
||||||
*.pgc
|
|
||||||
*.pgd
|
|
||||||
*.rsp
|
|
||||||
*.sbr
|
|
||||||
*.tlb
|
|
||||||
*.tli
|
|
||||||
*.tlh
|
|
||||||
*.tmp
|
|
||||||
*.tmp_proj
|
|
||||||
*.log
|
|
||||||
*.vspscc
|
|
||||||
*.vssscc
|
|
||||||
.builds
|
|
||||||
*.pidb
|
|
||||||
*.svclog
|
|
||||||
*.scc
|
|
||||||
|
|
||||||
# Chutzpah Test files
|
|
||||||
_Chutzpah*
|
|
||||||
|
|
||||||
# Visual C++ cache files
|
|
||||||
ipch/
|
|
||||||
*.aps
|
|
||||||
*.ncb
|
|
||||||
*.opendb
|
|
||||||
*.opensdf
|
|
||||||
*.sdf
|
|
||||||
*.cachefile
|
|
||||||
*.VC.db
|
|
||||||
*.VC.VC.opendb
|
|
||||||
|
|
||||||
# Visual Studio profiler
|
|
||||||
*.psess
|
|
||||||
*.vsp
|
|
||||||
*.vspx
|
|
||||||
*.sap
|
|
||||||
|
|
||||||
# Visual Studio Trace Files
|
|
||||||
*.e2e
|
|
||||||
|
|
||||||
# TFS 2012 Local Workspace
|
|
||||||
$tf/
|
|
||||||
|
|
||||||
# Guidance Automation Toolkit
|
|
||||||
*.gpState
|
|
||||||
|
|
||||||
# ReSharper is a .NET coding add-in
|
|
||||||
_ReSharper*/
|
|
||||||
*.[Rr]e[Ss]harper
|
|
||||||
*.DotSettings.user
|
|
||||||
|
|
||||||
# JustCode is a .NET coding add-in
|
|
||||||
.JustCode
|
|
||||||
|
|
||||||
# TeamCity is a build add-in
|
|
||||||
_TeamCity*
|
|
||||||
|
|
||||||
# DotCover is a Code Coverage Tool
|
|
||||||
*.dotCover
|
|
||||||
|
|
||||||
# AxoCover is a Code Coverage Tool
|
|
||||||
.axoCover/*
|
|
||||||
!.axoCover/settings.json
|
|
||||||
|
|
||||||
# Visual Studio code coverage results
|
|
||||||
*.coverage
|
|
||||||
*.coveragexml
|
|
||||||
|
|
||||||
# NCrunch
|
|
||||||
_NCrunch_*
|
|
||||||
.*crunch*.local.xml
|
|
||||||
nCrunchTemp_*
|
|
||||||
|
|
||||||
# MightyMoose
|
|
||||||
*.mm.*
|
|
||||||
AutoTest.Net/
|
|
||||||
|
|
||||||
# Web workbench (sass)
|
|
||||||
.sass-cache/
|
|
||||||
|
|
||||||
# Installshield output folder
|
|
||||||
[Ee]xpress/
|
|
||||||
|
|
||||||
# DocProject is a documentation generator add-in
|
|
||||||
DocProject/buildhelp/
|
|
||||||
DocProject/Help/*.HxT
|
|
||||||
DocProject/Help/*.HxC
|
|
||||||
DocProject/Help/*.hhc
|
|
||||||
DocProject/Help/*.hhk
|
|
||||||
DocProject/Help/*.hhp
|
|
||||||
DocProject/Help/Html2
|
|
||||||
DocProject/Help/html
|
|
||||||
|
|
||||||
# Click-Once directory
|
|
||||||
publish/
|
|
||||||
|
|
||||||
# Publish Web Output
|
|
||||||
*.[Pp]ublish.xml
|
|
||||||
*.azurePubxml
|
|
||||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
|
||||||
# but database connection strings (with potential passwords) will be unencrypted
|
|
||||||
*.pubxml
|
|
||||||
*.publishproj
|
|
||||||
|
|
||||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
|
||||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
|
||||||
# in these scripts will be unencrypted
|
|
||||||
PublishScripts/
|
|
||||||
|
|
||||||
# NuGet Packages
|
|
||||||
*.nupkg
|
|
||||||
# The packages folder can be ignored because of Package Restore
|
|
||||||
**/[Pp]ackages/*
|
|
||||||
# except build/, which is used as an MSBuild target.
|
|
||||||
!**/[Pp]ackages/build/
|
|
||||||
# Uncomment if necessary however generally it will be regenerated when needed
|
|
||||||
#!**/[Pp]ackages/repositories.config
|
|
||||||
# NuGet v3's project.json files produces more ignorable files
|
|
||||||
*.nuget.props
|
|
||||||
*.nuget.targets
|
|
||||||
|
|
||||||
# Microsoft Azure Build Output
|
|
||||||
csx/
|
|
||||||
*.build.csdef
|
|
||||||
|
|
||||||
# Microsoft Azure Emulator
|
|
||||||
ecf/
|
|
||||||
rcf/
|
|
||||||
|
|
||||||
# Windows Store app package directories and files
|
|
||||||
AppPackages/
|
|
||||||
BundleArtifacts/
|
|
||||||
Package.StoreAssociation.xml
|
|
||||||
_pkginfo.txt
|
|
||||||
*.appx
|
|
||||||
|
|
||||||
# Visual Studio cache files
|
|
||||||
# files ending in .cache can be ignored
|
|
||||||
*.[Cc]ache
|
|
||||||
# but keep track of directories ending in .cache
|
|
||||||
!*.[Cc]ache/
|
|
||||||
|
|
||||||
# Others
|
|
||||||
ClientBin/
|
|
||||||
~$*
|
|
||||||
*~
|
|
||||||
*.dbmdl
|
|
||||||
*.dbproj.schemaview
|
|
||||||
*.jfm
|
|
||||||
*.pfx
|
|
||||||
*.publishsettings
|
|
||||||
orleans.codegen.cs
|
|
||||||
|
|
||||||
# Including strong name files can present a security risk
|
|
||||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
|
||||||
#*.snk
|
|
||||||
|
|
||||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
|
||||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
|
||||||
#bower_components/
|
|
||||||
|
|
||||||
# RIA/Silverlight projects
|
|
||||||
Generated_Code/
|
|
||||||
|
|
||||||
# Backup & report files from converting an old project file
|
|
||||||
# to a newer Visual Studio version. Backup files are not needed,
|
|
||||||
# because we have git ;-)
|
|
||||||
_UpgradeReport_Files/
|
|
||||||
Backup*/
|
|
||||||
UpgradeLog*.XML
|
|
||||||
UpgradeLog*.htm
|
|
||||||
ServiceFabricBackup/
|
|
||||||
*.rptproj.bak
|
|
||||||
|
|
||||||
# SQL Server files
|
|
||||||
*.mdf
|
|
||||||
*.ldf
|
|
||||||
*.ndf
|
|
||||||
|
|
||||||
# Business Intelligence projects
|
|
||||||
*.rdl.data
|
|
||||||
*.bim.layout
|
|
||||||
*.bim_*.settings
|
|
||||||
*.rptproj.rsuser
|
|
||||||
|
|
||||||
# Microsoft Fakes
|
|
||||||
FakesAssemblies/
|
|
||||||
|
|
||||||
# GhostDoc plugin setting file
|
|
||||||
*.GhostDoc.xml
|
|
||||||
|
|
||||||
# Node.js Tools for Visual Studio
|
|
||||||
.ntvs_analysis.dat
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Visual Studio 6 build log
|
|
||||||
*.plg
|
|
||||||
|
|
||||||
# Visual Studio 6 workspace options file
|
|
||||||
*.opt
|
|
||||||
|
|
||||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
|
||||||
*.vbw
|
|
||||||
|
|
||||||
# Visual Studio LightSwitch build output
|
|
||||||
**/*.HTMLClient/GeneratedArtifacts
|
|
||||||
**/*.DesktopClient/GeneratedArtifacts
|
|
||||||
**/*.DesktopClient/ModelManifest.xml
|
|
||||||
**/*.Server/GeneratedArtifacts
|
|
||||||
**/*.Server/ModelManifest.xml
|
|
||||||
_Pvt_Extensions
|
|
||||||
|
|
||||||
# Paket dependency manager
|
|
||||||
.paket/paket.exe
|
|
||||||
paket-files/
|
|
||||||
|
|
||||||
# FAKE - F# Make
|
|
||||||
.fake/
|
|
||||||
|
|
||||||
# JetBrains Rider
|
|
||||||
.idea/
|
|
||||||
*.sln.iml
|
|
||||||
|
|
||||||
# CodeRush
|
|
||||||
.cr/
|
|
||||||
|
|
||||||
# Python Tools for Visual Studio (PTVS)
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
||||||
# Cake - Uncomment if you are using it
|
|
||||||
# tools/**
|
|
||||||
# !tools/packages.config
|
|
||||||
|
|
||||||
# Tabs Studio
|
|
||||||
*.tss
|
|
||||||
|
|
||||||
# Telerik's JustMock configuration file
|
|
||||||
*.jmconfig
|
|
||||||
|
|
||||||
# BizTalk build output
|
|
||||||
*.btp.cs
|
|
||||||
*.btm.cs
|
|
||||||
*.odx.cs
|
|
||||||
*.xsd.cs
|
|
||||||
|
|
||||||
# OpenCover UI analysis results
|
|
||||||
OpenCover/
|
|
||||||
|
|
||||||
# Azure Stream Analytics local run output
|
|
||||||
ASALocalRun/
|
|
||||||
|
|
||||||
# MSBuild Binary and Structured Log
|
|
||||||
*.binlog
|
|
||||||
|
|
||||||
# NVidia Nsight GPU debugger configuration file
|
|
||||||
*.nvuser
|
|
||||||
|
|
||||||
# MFractors (Xamarin productivity tool) working folder
|
|
||||||
.mfractor/
|
|
||||||
|
|
||||||
# Local History for Visual Studio
|
|
||||||
.localhistory/
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Xml.Serialization;
|
using System.Xml.Serialization;
|
||||||
@@ -16,42 +16,6 @@ namespace PolyTraderSharp.Models
|
|||||||
[XmlArrayItem("Module")]
|
[XmlArrayItem("Module")]
|
||||||
public List<string> DisabledModules { get; set; } = new();
|
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")]
|
[Category("Mullvad VPN")]
|
||||||
[DisplayName("VPN Enabled")]
|
[DisplayName("VPN Enabled")]
|
||||||
[Description("Enable or disable automatic VPN rotation.")]
|
[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" />
|
<PackageReference Include="Nethereum.Web3" Version="6.1.0" />
|
||||||
</ItemGroup>
|
</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. -->
|
<!-- Erlaubt dem Testprojekt, interne Helfer (z.B. SecretProtection.Reset) zu testen. -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
<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");
|
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 (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...");
|
_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);
|
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+)");
|
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 (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...");
|
_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);
|
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+)");
|
var sizeMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"Size \([\d\.]+\) lower than the minimum: (\d+)");
|
||||||
if (sizeMatch.Success)
|
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")
|
if (orderType != "MARKET")
|
||||||
{
|
{
|
||||||
@@ -812,12 +817,15 @@ namespace PolyTraderSharp.Services
|
|||||||
decimal totalBal = 0m, activeOrders = 0m;
|
decimal totalBal = 0m, activeOrders = 0m;
|
||||||
if (balMatch1.Success)
|
if (balMatch1.Success)
|
||||||
{
|
{
|
||||||
_ = decimal.TryParse(balMatch1.Groups[1].Value, out totalBal);
|
_ = decimal.TryParse(balMatch1.Groups[1].Value, System.Globalization.NumberStyles.Any,
|
||||||
_ = decimal.TryParse(balMatch1.Groups[2].Value, out activeOrders);
|
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)
|
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;
|
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using PolyTrader.Core.Notifications;
|
||||||
using PolyTrader.Modules.CopyTrading.Logic;
|
using PolyTrader.Modules.CopyTrading.Logic;
|
||||||
using PolyTrader.Modules.CopyTrading.Persistence;
|
using PolyTrader.Modules.CopyTrading.Persistence;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
@@ -19,11 +20,11 @@ namespace PolyTraderSharp.Services
|
|||||||
private readonly IMasterTraderHistoryRepository _historyRepo;
|
private readonly IMasterTraderHistoryRepository _historyRepo;
|
||||||
private readonly ITrackedTraderRepository _traderRepo;
|
private readonly ITrackedTraderRepository _traderRepo;
|
||||||
private readonly ICopyTradeLogRepository _tradeLog;
|
private readonly ICopyTradeLogRepository _tradeLog;
|
||||||
private readonly ThreemaService _threema;
|
private readonly INotificationSink _notify;
|
||||||
private readonly JobStatusRow _jobStatus;
|
private readonly JobStatusRow _jobStatus;
|
||||||
private readonly PolymarketApiService _api;
|
private readonly PolymarketApiService _api;
|
||||||
|
|
||||||
public MasterTraderAnalyticsJob(TradingState state, CopyTradingState copyState, TerminalLogger logger, IMasterTraderHistoryRepository historyRepo, ITrackedTraderRepository traderRepo, ICopyTradeLogRepository tradeLog, ThreemaService threema, JobManager jobManager, PolymarketApiService api)
|
public MasterTraderAnalyticsJob(TradingState state, CopyTradingState copyState, TerminalLogger logger, IMasterTraderHistoryRepository historyRepo, ITrackedTraderRepository traderRepo, ICopyTradeLogRepository tradeLog, INotificationSink notify, JobManager jobManager, PolymarketApiService api)
|
||||||
{
|
{
|
||||||
_state = state;
|
_state = state;
|
||||||
_copyState = copyState;
|
_copyState = copyState;
|
||||||
@@ -31,7 +32,7 @@ namespace PolyTraderSharp.Services
|
|||||||
_historyRepo = historyRepo;
|
_historyRepo = historyRepo;
|
||||||
_traderRepo = traderRepo;
|
_traderRepo = traderRepo;
|
||||||
_tradeLog = tradeLog;
|
_tradeLog = tradeLog;
|
||||||
_threema = threema;
|
_notify = notify;
|
||||||
_api = api;
|
_api = api;
|
||||||
|
|
||||||
_jobStatus = new JobStatusRow
|
_jobStatus = new JobStatusRow
|
||||||
@@ -137,7 +138,9 @@ namespace PolyTraderSharp.Services
|
|||||||
if (tsRaw > 1000000000000) closedTs = DateTimeOffset.FromUnixTimeMilliseconds(tsRaw).UtcDateTime;
|
if (tsRaw > 1000000000000) closedTs = DateTimeOffset.FromUnixTimeMilliseconds(tsRaw).UtcDateTime;
|
||||||
else closedTs = DateTimeOffset.FromUnixTimeSeconds(tsRaw).UtcDateTime;
|
else closedTs = DateTimeOffset.FromUnixTimeSeconds(tsRaw).UtcDateTime;
|
||||||
}
|
}
|
||||||
else if (tsProp.ValueKind == JsonValueKind.String && long.TryParse(tsProp.GetString(), out long tsStrRaw))
|
else if (tsProp.ValueKind == JsonValueKind.String
|
||||||
|
&& long.TryParse(tsProp.GetString(), System.Globalization.NumberStyles.Integer,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out long tsStrRaw))
|
||||||
{
|
{
|
||||||
if (tsStrRaw > 1000000000000) closedTs = DateTimeOffset.FromUnixTimeMilliseconds(tsStrRaw).UtcDateTime;
|
if (tsStrRaw > 1000000000000) closedTs = DateTimeOffset.FromUnixTimeMilliseconds(tsStrRaw).UtcDateTime;
|
||||||
else closedTs = DateTimeOffset.FromUnixTimeSeconds(tsStrRaw).UtcDateTime;
|
else closedTs = DateTimeOffset.FromUnixTimeSeconds(tsStrRaw).UtcDateTime;
|
||||||
@@ -258,8 +261,12 @@ namespace PolyTraderSharp.Services
|
|||||||
trader.IsActive = false;
|
trader.IsActive = false;
|
||||||
trader.Reasoning = $"[Auto-Pause {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC] Copy-PnL {liveMetrics.CopyPnl:F2} USDC über {liveMetrics.TradeCount} Live-Trades (30T) unter Schwelle (-{_copyState.AutoPauseDrawdownUsd:F0}). Reaktivierung manuell.";
|
trader.Reasoning = $"[Auto-Pause {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC] Copy-PnL {liveMetrics.CopyPnl:F2} USDC über {liveMetrics.TradeCount} Live-Trades (30T) unter Schwelle (-{_copyState.AutoPauseDrawdownUsd:F0}). Reaktivierung manuell.";
|
||||||
_logger.Warning($"🛑 [AUTO-PAUSE] Master '{trader.DisplayName}' deaktiviert. Live-Copy-PnL {liveMetrics.CopyPnl:F2} / {liveMetrics.TradeCount} Trades. Reaktivierung nur manuell.");
|
_logger.Warning($"🛑 [AUTO-PAUSE] Master '{trader.DisplayName}' deaktiviert. Live-Copy-PnL {liveMetrics.CopyPnl:F2} / {liveMetrics.TradeCount} Trades. Reaktivierung nur manuell.");
|
||||||
try { await _threema.SendMessageAsync($"🛑 Auto-Pause: Master '{trader.DisplayName}' deaktiviert.\nLive-Copy-PnL 30T: {liveMetrics.CopyPnl:F2} USDC über {liveMetrics.TradeCount} Trades.\nReaktivierung manuell."); }
|
// Kein try/catch nötig: INotificationSink wirft laut Vertrag nie.
|
||||||
catch (Exception ex) { _logger.Error($"Threema Auto-Pause-Benachrichtigung fehlgeschlagen: {ex.Message}"); }
|
await _notify.SendAsync(
|
||||||
|
$"🛑 Auto-Pause: Master '{trader.DisplayName}' deaktiviert.\n" +
|
||||||
|
$"Live-Copy-PnL 30T: {liveMetrics.CopyPnl:F2} USDC über {liveMetrics.TradeCount} Trades.\n" +
|
||||||
|
"Reaktivierung manuell.",
|
||||||
|
NotificationSeverity.Warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
_copyState.Traders[trader.Id] = trader; // Hot-Path-State synchron halten
|
_copyState.Traders[trader.Id] = trader; // Hot-Path-State synchron halten
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using PolyTrader.Core.Trading;
|
using PolyTrader.Core.Trading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using PolyTrader.Core.Notifications;
|
||||||
using PolyTrader.Core.Persistence;
|
using PolyTrader.Core.Persistence;
|
||||||
using PolyTrader.Modules.CopyTrading.Logic;
|
using PolyTrader.Modules.CopyTrading.Logic;
|
||||||
using PolyTraderSharp.Models;
|
using PolyTraderSharp.Models;
|
||||||
@@ -16,7 +17,7 @@ namespace PolyTraderSharp.Services
|
|||||||
/// Master-Exit und markiert die Position <see cref="Position.ExitPending"/>. Dieser Service
|
/// Master-Exit und markiert die Position <see cref="Position.ExitPending"/>. Dieser Service
|
||||||
/// senkt das Limit stufenweise (relative Schrittweite) bis zum Floor, wenn kein Fill kommt:
|
/// senkt das Limit stufenweise (relative Schrittweite) bis zum Floor, wenn kein Fill kommt:
|
||||||
/// canceln → eine Stufe tiefer neu platzieren. Am Floor ohne Fill: Position halten +
|
/// canceln → eine Stufe tiefer neu platzieren. Am Floor ohne Fill: Position halten +
|
||||||
/// Threema-Benachrichtigung. Fills erkennt der bestehende Sync (TraderMonitorService), der die
|
/// Benachrichtigung über <see cref="INotificationSink"/>. Fills erkennt der bestehende Sync (TraderMonitorService), der die
|
||||||
/// Position entfernt → die Leiter endet dann von selbst.
|
/// Position entfernt → die Leiter endet dann von selbst.
|
||||||
///
|
///
|
||||||
/// Bewusst separat vom CLOB-Order-Code gehalten; die Preislogik ist in <see cref="SellLogic"/>
|
/// Bewusst separat vom CLOB-Order-Code gehalten; die Preislogik ist in <see cref="SellLogic"/>
|
||||||
@@ -30,7 +31,7 @@ namespace PolyTraderSharp.Services
|
|||||||
private readonly TradingState _state;
|
private readonly TradingState _state;
|
||||||
private readonly IClobClient _clob;
|
private readonly IClobClient _clob;
|
||||||
private readonly TerminalLogger _logger;
|
private readonly TerminalLogger _logger;
|
||||||
private readonly ThreemaService _threema;
|
private readonly INotificationSink _notify;
|
||||||
private readonly IPositionRepository _positionRepo;
|
private readonly IPositionRepository _positionRepo;
|
||||||
private readonly IOrderEventLog _orderEvents;
|
private readonly IOrderEventLog _orderEvents;
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ namespace PolyTraderSharp.Services
|
|||||||
TradingState state,
|
TradingState state,
|
||||||
IClobClient clob,
|
IClobClient clob,
|
||||||
TerminalLogger logger,
|
TerminalLogger logger,
|
||||||
ThreemaService threema,
|
INotificationSink notify,
|
||||||
IPositionRepository positionRepo,
|
IPositionRepository positionRepo,
|
||||||
IOrderEventLog orderEvents)
|
IOrderEventLog orderEvents)
|
||||||
{
|
{
|
||||||
@@ -47,7 +48,7 @@ namespace PolyTraderSharp.Services
|
|||||||
_state = state;
|
_state = state;
|
||||||
_clob = clob;
|
_clob = clob;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_threema = threema;
|
_notify = notify;
|
||||||
_positionRepo = positionRepo;
|
_positionRepo = positionRepo;
|
||||||
_orderEvents = orderEvents;
|
_orderEvents = orderEvents;
|
||||||
}
|
}
|
||||||
@@ -269,7 +270,8 @@ namespace PolyTraderSharp.Services
|
|||||||
$"Konto: {account.Name}\nMarkt: {ladder.MarketQuestion}\n" +
|
$"Konto: {account.Name}\nMarkt: {ladder.MarketQuestion}\n" +
|
||||||
$"Floor: {ladder.Floor:F3} (Master-Exit war {ladder.ReferencePrice:F3})";
|
$"Floor: {ladder.Floor:F3} (Master-Exit war {ladder.ReferencePrice:F3})";
|
||||||
_logger.Warning($"🛑 [SELL-LEITER Floor] {account.Name} | {ladder.MarketQuestion} | Floor {ladder.Floor:F3} ohne Fill – halte Position.");
|
_logger.Warning($"🛑 [SELL-LEITER Floor] {account.Name} | {ladder.MarketQuestion} | Floor {ladder.Floor:F3} ohne Fill – halte Position.");
|
||||||
try { await _threema.SendMessageAsync(msg); } catch (Exception ex) { _logger.Error($"Threema-Benachrichtigung fehlgeschlagen: {ex.Message}"); }
|
// Kein try/catch nötig: INotificationSink wirft laut Vertrag nie.
|
||||||
|
await _notify.SendAsync(msg, NotificationSeverity.Critical);
|
||||||
}
|
}
|
||||||
ladder.LastActionAt = DateTime.UtcNow; // Re-Notify-Spam vermeiden
|
ladder.LastActionAt = DateTime.UtcNow; // Re-Notify-Spam vermeiden
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1220,7 +1220,13 @@ namespace PolyTraderSharp.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private decimal ParseDecimal(JsonElement prop)
|
/// <summary>
|
||||||
|
/// Liest einen Zahlenwert, der von der API je nach Endpunkt als JSON-Zahl ODER als String
|
||||||
|
/// ("0.53") kommt. Das String-Parsing ist bewusst invariant: unter de-DE gilt "." als
|
||||||
|
/// Tausendertrennzeichen, "0.53" würde sonst als 53 gelesen (Faktor-100-Fehler im Preis).
|
||||||
|
/// <c>internal static</c>, damit die Kultur-Unabhängigkeit testbar bleibt.
|
||||||
|
/// </summary>
|
||||||
|
internal static decimal ParseDecimal(JsonElement prop)
|
||||||
{
|
{
|
||||||
if (prop.ValueKind == JsonValueKind.Number) return prop.GetDecimal();
|
if (prop.ValueKind == JsonValueKind.Number) return prop.GetDecimal();
|
||||||
if (prop.ValueKind == JsonValueKind.String && decimal.TryParse(prop.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) return parsed;
|
if (prop.ValueKind == JsonValueKind.String && decimal.TryParse(prop.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) return parsed;
|
||||||
@@ -1273,19 +1279,11 @@ namespace PolyTraderSharp.Services
|
|||||||
|
|
||||||
foreach (var act in elements)
|
foreach (var act in elements)
|
||||||
{
|
{
|
||||||
decimal price = 0m;
|
// Über ParseDecimal (invariant) statt inline: die API liefert Preis/Size je nach
|
||||||
if (act.TryGetProperty("price", out var priceProp))
|
// Endpunkt als Zahl ODER als String ("0.53"). Kulturabhängiges Parsen läse "."
|
||||||
{
|
// unter de-DE als Tausendertrennzeichen – aus 0,53 würde 53 (Faktor-100-Fehler).
|
||||||
if (priceProp.ValueKind == JsonValueKind.Number) price = priceProp.GetDecimal();
|
decimal price = act.TryGetProperty("price", out var priceProp) ? ParseDecimal(priceProp) : 0m;
|
||||||
else if (priceProp.ValueKind == JsonValueKind.String) decimal.TryParse(priceProp.GetString(), out price);
|
decimal size = act.TryGetProperty("size", out var sizeProp) ? ParseDecimal(sizeProp) : 0m;
|
||||||
}
|
|
||||||
|
|
||||||
decimal size = 0m;
|
|
||||||
if (act.TryGetProperty("size", out var sizeProp))
|
|
||||||
{
|
|
||||||
if (sizeProp.ValueKind == JsonValueKind.Number) size = sizeProp.GetDecimal();
|
|
||||||
else if (sizeProp.ValueKind == JsonValueKind.String) decimal.TryParse(sizeProp.GetString(), out size);
|
|
||||||
}
|
|
||||||
|
|
||||||
totalSize += size;
|
totalSize += size;
|
||||||
weightedPriceSum += (price * size);
|
weightedPriceSum += (price * size);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
|
||||||
namespace PolyTrader.Modules.ResolutionFarming.Models
|
namespace PolyTrader.Modules.ResolutionFarming.Models
|
||||||
{
|
{
|
||||||
@@ -72,7 +72,7 @@ namespace PolyTrader.Modules.ResolutionFarming.Models
|
|||||||
|
|
||||||
[Category("03. Risiko")]
|
[Category("03. Risiko")]
|
||||||
[DisplayName("Tagesverlust-Kill-Switch (USDC)")]
|
[DisplayName("Tagesverlust-Kill-Switch (USDC)")]
|
||||||
[Description("Überschreitet der realisierte Tagesverlust diesen Betrag, pausiert das Modul + Threema-Warnung. 0 = deaktiviert.")]
|
[Description("Überschreitet der realisierte Tagesverlust diesen Betrag, pausiert das Modul + Warnmeldung. 0 = deaktiviert.")]
|
||||||
public decimal DailyLossKillSwitchUsd { get; set; } = 0m;
|
public decimal DailyLossKillSwitchUsd { get; set; } = 0m;
|
||||||
|
|
||||||
// ----- Ausführung -----
|
// ----- Ausführung -----
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using PolyTrader.Core.Notifications;
|
||||||
using PolyTrader.Modules.Supervisor.Agent;
|
using PolyTrader.Modules.Supervisor.Agent;
|
||||||
using PolyTrader.Modules.Supervisor.Persistence;
|
using PolyTrader.Modules.Supervisor.Persistence;
|
||||||
using PolyTraderSharp.Services;
|
using PolyTraderSharp.Services;
|
||||||
@@ -10,7 +11,8 @@ namespace PolyTrader.Modules.Supervisor.Services
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Täglicher Supervisor-Bericht (S-3): lässt den Agenten einmal am Tag eine Kurz-Analyse der
|
/// Täglicher Supervisor-Bericht (S-3): lässt den Agenten einmal am Tag eine Kurz-Analyse der
|
||||||
/// letzten 24 h erstellen (KPIs, Auffälligkeiten, Fehler) und sendet sie via Threema; zusätzlich
|
/// letzten 24 h erstellen (KPIs, Auffälligkeiten, Fehler) und sendet sie über den konfigurierten
|
||||||
|
/// Benachrichtigungskanal (<see cref="INotificationSink"/>); zusätzlich
|
||||||
/// als sup_report gespeichert. OPT-IN: läuft nur, wenn POLYTRADER_SUPERVISOR_DAILY die Ziel-Stunde
|
/// als sup_report gespeichert. OPT-IN: läuft nur, wenn POLYTRADER_SUPERVISOR_DAILY die Ziel-Stunde
|
||||||
/// (0–23, lokale Zeit) enthält — und braucht den OpenRouter-Key (sonst still übersprungen).
|
/// (0–23, lokale Zeit) enthält — und braucht den OpenRouter-Key (sonst still übersprungen).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -25,12 +27,12 @@ namespace PolyTrader.Modules.Supervisor.Services
|
|||||||
|
|
||||||
private readonly SupervisorAgent _agent;
|
private readonly SupervisorAgent _agent;
|
||||||
private readonly ISupervisorReportRepository _reports;
|
private readonly ISupervisorReportRepository _reports;
|
||||||
private readonly Func<string, Task> _sendAsync; // Threema-Versand (injizierbar für Tests)
|
private readonly Func<string, Task> _sendAsync; // Versand (injizierbar für Tests)
|
||||||
private readonly TerminalLogger _logger;
|
private readonly TerminalLogger _logger;
|
||||||
|
|
||||||
public DailyReportService(SupervisorAgent agent, ISupervisorReportRepository reports,
|
public DailyReportService(SupervisorAgent agent, ISupervisorReportRepository reports,
|
||||||
ThreemaService threema, TerminalLogger logger)
|
INotificationSink notify, TerminalLogger logger)
|
||||||
: this(agent, reports, async msg => await threema.SendMessageAsync(msg), logger) { }
|
: this(agent, reports, async msg => await notify.SendAsync(msg, NotificationSeverity.Info), logger) { }
|
||||||
|
|
||||||
internal DailyReportService(SupervisorAgent agent, ISupervisorReportRepository reports,
|
internal DailyReportService(SupervisorAgent agent, ISupervisorReportRepository reports,
|
||||||
Func<string, Task> sendAsync, TerminalLogger logger)
|
Func<string, Task> sendAsync, TerminalLogger logger)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -43,7 +43,7 @@ namespace PolyTrader.Modules.Supervisor
|
|||||||
services.AddSingleton<Persistence.ISupervisorReportRepository, Persistence.EfSupervisorReportRepository>();
|
services.AddSingleton<Persistence.ISupervisorReportRepository, Persistence.EfSupervisorReportRepository>();
|
||||||
services.AddSingleton<Persistence.ISupervisorCounterfactualRepository, Persistence.EfSupervisorCounterfactualRepository>();
|
services.AddSingleton<Persistence.ISupervisorCounterfactualRepository, Persistence.EfSupervisorCounterfactualRepository>();
|
||||||
|
|
||||||
// S-3: Counterfactual-Job (abgelehnte BUYs vs. Marktausgang) + täglicher Threema-Bericht
|
// S-3: Counterfactual-Job (abgelehnte BUYs vs. Marktausgang) + täglicher Bericht
|
||||||
// (OPT-IN via POLYTRADER_SUPERVISOR_DAILY = Stunde 0–23).
|
// (OPT-IN via POLYTRADER_SUPERVISOR_DAILY = Stunde 0–23).
|
||||||
services.AddSingleton<Counterfactual.ICounterfactualResolutionSource, Counterfactual.ApiCounterfactualResolutionSource>();
|
services.AddSingleton<Counterfactual.ICounterfactualResolutionSource, Counterfactual.ApiCounterfactualResolutionSource>();
|
||||||
services.AddHostedService<Counterfactual.CounterfactualJob>();
|
services.AddHostedService<Counterfactual.CounterfactualJob>();
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using PolyTraderSharp.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PolyTrader.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Regressionsschutz für den Kultur-Bug in der Aktivitäts-Auswertung: Polymarket liefert
|
||||||
|
/// <c>price</c>/<c>size</c> je nach Endpunkt als JSON-Zahl ODER als String ("0.53"). Wurde der
|
||||||
|
/// String kulturabhängig geparst, las de-DE den Punkt als Tausendertrennzeichen – aus 0,53 wurde
|
||||||
|
/// 53 (Faktor-100-Fehler im Einstandspreis der Master-Trader-Auswertung).
|
||||||
|
///
|
||||||
|
/// Die Tests setzen die Thread-Kultur bewusst auf de-DE, damit sie auch auf einer invarianten
|
||||||
|
/// Umgebung (z.B. Linux-CI ohne LANG) fehlschlagen würden, sobald jemand die Invariant-Angabe
|
||||||
|
/// wieder entfernt.
|
||||||
|
/// </summary>
|
||||||
|
public class ApiNumberParsingTests
|
||||||
|
{
|
||||||
|
/// <summary>Führt die Prüfung unter erzwungener de-DE-Kultur aus und stellt sie danach wieder her.</summary>
|
||||||
|
private static void UnderGermanCulture(System.Action assertions)
|
||||||
|
{
|
||||||
|
var previous = Thread.CurrentThread.CurrentCulture;
|
||||||
|
Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE");
|
||||||
|
try { assertions(); }
|
||||||
|
finally { Thread.CurrentThread.CurrentCulture = previous; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement Prop(string json) =>
|
||||||
|
JsonDocument.Parse(json).RootElement.GetProperty("v");
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("\"0.53\"", 0.53)] // der eigentliche Bug: wurde unter de-DE zu 53
|
||||||
|
[InlineData("\"0.045\"", 0.045)]
|
||||||
|
[InlineData("\"1234.5\"", 1234.5)] // ohne Invariant unter de-DE: 12345
|
||||||
|
[InlineData("\"0\"", 0)]
|
||||||
|
public void ParseDecimal_reads_string_values_invariantly(string jsonValue, double expected)
|
||||||
|
{
|
||||||
|
UnderGermanCulture(() =>
|
||||||
|
Assert.Equal((decimal)expected, TraderMonitorService.ParseDecimal(Prop($"{{\"v\":{jsonValue}}}"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("0.53", 0.53)]
|
||||||
|
[InlineData("1234.5", 1234.5)]
|
||||||
|
public void ParseDecimal_reads_numeric_values(string jsonValue, double expected)
|
||||||
|
{
|
||||||
|
UnderGermanCulture(() =>
|
||||||
|
Assert.Equal((decimal)expected, TraderMonitorService.ParseDecimal(Prop($"{{\"v\":{jsonValue}}}"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("null")]
|
||||||
|
[InlineData("\"\"")]
|
||||||
|
[InlineData("\"n/a\"")]
|
||||||
|
[InlineData("true")]
|
||||||
|
public void ParseDecimal_returns_zero_for_unusable_values(string jsonValue)
|
||||||
|
{
|
||||||
|
UnderGermanCulture(() =>
|
||||||
|
Assert.Equal(0m, TraderMonitorService.ParseDecimal(Prop($"{{\"v\":{jsonValue}}}"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -40,10 +40,10 @@ namespace PolyTrader.Tests
|
|||||||
var posRepo = new FakePositionRepository();
|
var posRepo = new FakePositionRepository();
|
||||||
var marketRepo = new FakeMarketRepository();
|
var marketRepo = new FakeMarketRepository();
|
||||||
var accountRepo = new FakeAccountRepository();
|
var accountRepo = new FakeAccountRepository();
|
||||||
var threema = new ThreemaService(logger, new JobManager());
|
var notify = new FakeNotificationSink();
|
||||||
var journal = new FakeDecisionJournal();
|
var journal = new FakeDecisionJournal();
|
||||||
var orderEvents = new FakeOrderEventLog();
|
var orderEvents = new FakeOrderEventLog();
|
||||||
var ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo, orderEvents);
|
var ladder = new SellLadderService(copy, state, clob, logger, notify, posRepo, orderEvents);
|
||||||
|
|
||||||
var signalCh = Channel.CreateUnbounded<CopySignal>();
|
var signalCh = Channel.CreateUnbounded<CopySignal>();
|
||||||
var closedCh = Channel.CreateUnbounded<ClosedTrade>();
|
var closedCh = Channel.CreateUnbounded<ClosedTrade>();
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using PolyTrader.Core.Notifications;
|
||||||
|
|
||||||
|
namespace PolyTrader.Tests.Fakes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// In-Memory-Stub des Benachrichtigungsausgangs – Tests können asserten, WAS gemeldet wurde
|
||||||
|
/// (zuvor bekamen die Tests eine echte ThreemaService-Instanz, die nichts prüfbar machte).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeNotificationSink : INotificationSink
|
||||||
|
{
|
||||||
|
public List<(string Message, NotificationSeverity Severity)> Sent { get; } = new();
|
||||||
|
|
||||||
|
public Task SendAsync(string message, NotificationSeverity severity = NotificationSeverity.Info,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Sent.Add((message, severity));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using PolyTrader.Tests.Fakes;
|
using PolyTrader.Tests.Fakes;
|
||||||
@@ -25,9 +25,9 @@ namespace PolyTrader.Tests
|
|||||||
var state = new TradingState();
|
var state = new TradingState();
|
||||||
clob ??= new FakeClobClient();
|
clob ??= new FakeClobClient();
|
||||||
var logger = new TerminalLogger();
|
var logger = new TerminalLogger();
|
||||||
var threema = new ThreemaService(logger, new JobManager());
|
var notify = new FakeNotificationSink();
|
||||||
var repo = new FakePositionRepository();
|
var repo = new FakePositionRepository();
|
||||||
var svc = new SellLadderService(copy, state, clob, logger, threema, repo, new FakeOrderEventLog());
|
var svc = new SellLadderService(copy, state, clob, logger, notify, repo, new FakeOrderEventLog());
|
||||||
return (svc, copy, state, clob, repo);
|
return (svc, copy, state, clob, repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ namespace PolyTrader.Tests
|
|||||||
var (svc, copy, state, clob, _) = Build();
|
var (svc, copy, state, clob, _) = Build();
|
||||||
var (account, pos) = LiveAccountWithPosition(state);
|
var (account, pos) = LiveAccountWithPosition(state);
|
||||||
var ladder = FloorLadder();
|
var ladder = FloorLadder();
|
||||||
ladder.FloorNotified = true; // Threema-Notify überspringen
|
ladder.FloorNotified = true; // Floor-Benachrichtigung überspringen
|
||||||
copy.ExitLadders["1_" + Tok] = ladder;
|
copy.ExitLadders["1_" + Tok] = ladder;
|
||||||
clob.OpenOrdersByAsset[Tok] = new() { ("oid-1", "SELL", 0.40m) };
|
clob.OpenOrdersByAsset[Tok] = new() { ("oid-1", "SELL", 0.40m) };
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
@@ -29,8 +29,8 @@ namespace PolyTrader.Tests
|
|||||||
var posRepo = new FakePositionRepository();
|
var posRepo = new FakePositionRepository();
|
||||||
var marketRepo = new FakeMarketRepository();
|
var marketRepo = new FakeMarketRepository();
|
||||||
var tradeLog = new FakeCopyTradeLogRepository();
|
var tradeLog = new FakeCopyTradeLogRepository();
|
||||||
var threema = new ThreemaService(logger, new JobManager());
|
var notify = new FakeNotificationSink();
|
||||||
var ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo, new FakeOrderEventLog());
|
var ladder = new SellLadderService(copy, state, clob, logger, notify, posRepo, new FakeOrderEventLog());
|
||||||
var signalCh = Channel.CreateUnbounded<CopySignal>();
|
var signalCh = Channel.CreateUnbounded<CopySignal>();
|
||||||
var closedCh = Channel.CreateUnbounded<ClosedTrade>();
|
var closedCh = Channel.CreateUnbounded<ClosedTrade>();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user