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? 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 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}"); } } } }