Kultur-Bug behoben + Threema entfernt (INotificationSink)

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 12:01:13 +02:00
co-authored by Claude Opus 5
parent ab0a494f29
commit fd6d62618f
56 changed files with 794 additions and 3734 deletions
-20
View File
@@ -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
View File
@@ -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>(),
+2 -5
View File
@@ -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.");
+543
View File
@@ -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 **4161 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 | **4161 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:** ~810 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 12 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:** ~23 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 | — | **23 PT** |
| **P1** | Core/Module von WinForms entkoppeln, `TargetFramework` auf `net8.0`, `System.Drawing` raus | — | **23 PT** |
| **P2** | Kultur & Zeit: `IAppClock`, Zeitzone bei Installation festlegen, Kultur pinnen, Analyzer | **1 PT** (Entscheidung 6 vereinfacht) | **23 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) | **23 PT** |
| **P4** | PDF-Export: PDFsharp Core + `IFontResolver` + eingebettete Schrift | — | **12 PT** |
| **P5** | Charts auf LiveCharts2 | **+1 PT** (Entscheidung 8) | **2 PT** |
| **P6** | UI-Shell: Launcher, Fenstermenü (deklarativ!), `ShellUiHost`, Shutdown-, Lizenz-Dialog, Widgets | — | **57 PT** |
| **P7** | Core-Views: Dashboard, Settings, Jobs, Terminal (inkl. **2 PropertyGrid-Ersätze** + `RichTextBox`) | **+3 PT** (Entscheidung 4) | **1115 PT** |
| **P8** | Modul-Fenster: CopyTrading (5), ResolutionFarming, Supervisor, Accounting (inkl. **4 PropertyGrid-Ersätze**, 20 DataGrids) | **+2 PT** (Entscheidung 4) | **1420 PT** |
| **P9** | Tests, `--smoke-ui` auf `Avalonia.Headless`, Linux-CI | — | **34 PT** |
| **P10** | Deployment: systemd, TZ, Logrotate, Feldtest im Zielland | **0,5 PT** (Lizenz-Neubindung entfällt) | **34 PT** |
| **P11** | **WinForms endgültig ausbauen** (nach L4): `Ui/`, Designer-Dateien, `UseWindowsForms`, `WinExe` | **neu** (Entscheidung 3) | **1 PT** |
| | **Summe (offen)** | | **4161 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
**P1P4 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 | 1115 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 | 35 PT | **Trading läuft headless auf Linux.** ⭐ Meilenstein |
| **L3** | P5 + P6 + P7 (Charts, Shell, Core-Views) | 1824 PT | Avalonia-Grundgerüst steht; Dashboard, Settings, Terminal nutzbar |
| **L4** | P8 + P9 (Modul-Fenster, Tests, CI) | 1724 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 4161 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);
}
}
@@ -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);
}
}
@@ -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;
}
}
}
@@ -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
{
}
}
@@ -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)
{
}
}
}
@@ -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
{
}
}
@@ -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)
{
}
}
}
@@ -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
{
}
}
@@ -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
{
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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;
}
}
}
@@ -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
}
}
}
@@ -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; }
}
}
}
@@ -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
-21
View File
@@ -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.
-19
View File
@@ -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 -37
View File
@@ -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
/// (023, lokale Zeit) enthält — und braucht den OpenRouter-Key (sonst still übersprungen). /// (023, 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 023). // (OPT-IN via POLYTRADER_SUPERVISOR_DAILY = Stunde 023).
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>();