From 4e41fc12d72bf2c0ea80c1638879d2b70d332e2c Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 2 Oct 2026 18:59:37 +0200 Subject: [PATCH] #794: Telegram-Client-Anmeldung ueber die WebUI Code und 2FA-Passwort wurden nur im Desktop-Dialog abgefragt; eine Instanz ohne Fenster liess die Anmeldung aus. Jetzt haelt die Instanz die Frage (TelegramLoginBroker), die Einstellungsseite beantwortet sie. Die Anmeldung laeuft im Hintergrund, damit die HTTP-Flaeche, ueber die der Code kommt, nicht auf ihn wartet. Eine offene Frage verfaellt nach 15 Minuten. Co-Authored-By: Claude Opus 5.5 --- src/ClawdDotNet.App/AppHost.cs | 63 ++++++++- .../Services/TelegramLoginBroker.cs | 130 ++++++++++++++++++ .../wwwroot/i18n/de.json | 10 ++ .../wwwroot/i18n/en.json | 10 ++ .../wwwroot/views/settings.js | 92 ++++++++++++- .../TelegramClientManager.cs | 2 + src/ClawdDotNet.Web/Contracts/Dtos.cs | 6 + src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs | 10 ++ .../Services/TelegramLoginBrokerTests.cs | 71 ++++++++++ 9 files changed, 385 insertions(+), 9 deletions(-) create mode 100644 src/ClawdDotNet.App/Services/TelegramLoginBroker.cs create mode 100644 tests/ClawdDotNet.App.Tests/Services/TelegramLoginBrokerTests.cs diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index d24e59b..97e7365 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -459,8 +459,10 @@ public sealed class AppHost : IAsyncDisposable // ─── 5. Werkzeuge ─── var tools = RegisterTools(); + // #794: Registered whenever it is configured. Before, an instance without a window + // skipped it ("no way to log in") - the login now comes through the web interface. TelegramClientManager? telegram = null; - if (instance.TelegramClient is not null && callbacks.TelegramLogin is not null) + if (instance.TelegramClient is not null) { telegram = new TelegramClientManager(instance, instancePath, loggerFactory.CreateLogger("ClawdDotNet.Tools.TelegramClient")); @@ -468,11 +470,6 @@ public sealed class AppHost : IAsyncDisposable tools.Register(new TelegramClientTool(telegram)); logger.LogInformation("TelegramClient-Tool registriert"); } - else if (instance.TelegramClient is not null) - { - logger.LogInformation( - "TelegramClient konfiguriert, aber kein Anmeldeweg vorhanden – übersprungen."); - } var host = BuildCore(settings, directories, instance, instancePath, logDirectory, loggerFactory, tools, telegram, logger); @@ -1031,9 +1028,61 @@ public sealed class AppHost : IAsyncDisposable return (scanner, staging, board, followUp); } + /// #794: The Telegram client login as the WebUI sees it and answers it. + public TelegramLoginBroker TelegramLogin { get; private set; } = new(configured: false); + + private ILogger? _telegramLogger; + private int _telegramConnecting; + + /// + /// #794: Starts (or restarts) the Telegram login in the background; questions go to + /// . Does nothing while a login is already running. + /// + public AdminResult StartTelegramLogin() + { + if (TelegramClient is null) + return AdminResult.Failure("No Telegram client is configured in this instance."); + if (Interlocked.Exchange(ref _telegramConnecting, 1) != 0) + return AdminResult.Failure("A Telegram login is already running."); + + TelegramLogin.MarkConnecting(); + _ = Task.Run(async () => + { + try + { + await TelegramClient.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + TelegramLogin.MarkConnected(TelegramClient.Self?.first_name ?? "?"); + } + catch (Exception ex) + { + // The message of the library, not the input: the code never ends up in it. + _telegramLogger?.LogError(ex, "Telegram: Login fehlgeschlagen"); + TelegramLogin.MarkFailed($"Login failed: {(ex is AggregateException { InnerException: { } inner } ? inner.Message : ex.Message)}"); + } + finally + { + Interlocked.Exchange(ref _telegramConnecting, 0); + } + }); + + return AdminResult.Success("Telegram login started."); + } + private async Task ConnectTelegramAsync(Callbacks callbacks, ILogger logger) { - if (TelegramClient is null || callbacks.TelegramLogin is null) return; + if (TelegramClient is null) return; + + // #794: Without a dialog (every instance without a window), the login runs through + // the web interface - in the background, so the HTTP interface it needs comes up. + if (callbacks.TelegramLogin is null) + { + _telegramLogger = logger; + TelegramLogin = new TelegramLoginBroker(configured: true); + TelegramClient.OnLoginCodeRequired += prompt => TelegramLogin.AskForCodeAsync(prompt); + TelegramClient.On2FAPasswordRequired += () => TelegramLogin.AskForPasswordAsync(); + StartTelegramLogin(); + return; + } TelegramClient.OnLoginCodeRequired += prompt => callbacks.TelegramLogin(prompt); diff --git a/src/ClawdDotNet.App/Services/TelegramLoginBroker.cs b/src/ClawdDotNet.App/Services/TelegramLoginBroker.cs new file mode 100644 index 0000000..6caca97 --- /dev/null +++ b/src/ClawdDotNet.App/Services/TelegramLoginBroker.cs @@ -0,0 +1,130 @@ +namespace ClawdDotNet.App.Services; + +/// #794: Where the Telegram client login stands. +public enum TelegramLoginState +{ + /// No Telegram client configured in this instance. + NotConfigured, + + /// Connecting; no question pending. + Connecting, + + /// Telegram sent a login code; it has to be entered. + NeedsCode, + + /// The account has two-step verification and no password is stored. + NeedsPassword, + + Connected, + + Failed +} + +/// #794: The login as the WebUI sees it — never the code or password itself. +public sealed record TelegramLoginStatus(TelegramLoginState State, string Message); + +/// +/// #794: The Telegram client login through the web interface. +/// +/// Until now the login code and the two-step password were asked for in a desktop dialog, +/// and an instance without a window skipped the login altogether — a new Telegram session +/// could only be created in the desktop window. Here the question becomes a pending state the +/// WebUI shows, and the answer comes back through an endpoint. +/// +/// Nothing waits on the person at startup. The login runs in the background after +/// the instance is up: waiting for a code during startup would hold back the HTTP interface — +/// the very way the code has to come in. +/// +/// A question does not wait forever. After the +/// login fails with a clear state; it can be started again. Otherwise a forgotten browser tab +/// would leave a thread blocked inside the Telegram library until the next restart. +/// +public sealed class TelegramLoginBroker +{ + public static readonly TimeSpan AnswerTimeout = TimeSpan.FromMinutes(15); + + private readonly object _lock = new(); + private TaskCompletionSource? _pending; + private TelegramLoginStatus _status; + + public TelegramLoginBroker(bool configured) + => _status = configured + ? new TelegramLoginStatus(TelegramLoginState.Connecting, "Connecting to Telegram…") + : new TelegramLoginStatus(TelegramLoginState.NotConfigured, "No Telegram client is configured in this instance."); + + public TelegramLoginStatus Status + { + get { lock (_lock) return _status; } + } + + /// Raised whenever the state changes, so the event stream can tell the WebUI. + public event Action? Changed; + + /// Asks for the login code — called by the Telegram client, waits for . + public Task AskForCodeAsync(string prompt) + => AskAsync(TelegramLoginState.NeedsCode, "Telegram sent a login code to the account's devices. Enter it here."); + + /// Asks for the two-step password when none is stored. + public Task AskForPasswordAsync() + => AskAsync(TelegramLoginState.NeedsPassword, "The account uses two-step verification. Enter its password."); + + private async Task AskAsync(TelegramLoginState state, string message) + { + TaskCompletionSource pending; + lock (_lock) + { + _pending?.TrySetCanceled(); + pending = _pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _status = new TelegramLoginStatus(state, message); + } + Changed?.Invoke(); + + var finished = await Task.WhenAny(pending.Task, Task.Delay(AnswerTimeout)).ConfigureAwait(false); + if (finished != pending.Task) + { + lock (_lock) + { + if (ReferenceEquals(_pending, pending)) _pending = null; + } + pending.TrySetCanceled(); + throw new TimeoutException("No answer within 15 minutes."); + } + + return await pending.Task.ConfigureAwait(false); + } + + /// The answer from the WebUI. Refused when no question is pending. + public AdminResult Answer(string value) + { + value = value.Trim(); + if (value.Length == 0) + return AdminResult.Failure("Enter the code or password."); + + TaskCompletionSource? pending; + lock (_lock) + { + pending = _pending; + _pending = null; + if (pending is null) + return AdminResult.Failure("Telegram is not waiting for an answer."); + _status = new TelegramLoginStatus(TelegramLoginState.Connecting, "Answer sent, logging in…"); + } + + pending.TrySetResult(value); + Changed?.Invoke(); + return AdminResult.Success("Sent to Telegram."); + } + + public void MarkConnecting() => Set(TelegramLoginState.Connecting, "Connecting to Telegram…"); + + public void MarkConnected(string who) => Set(TelegramLoginState.Connected, $"Logged in as {who}."); + + /// The reason is shown to the person; it must not carry the code or password. + public void MarkFailed(string reason) => Set(TelegramLoginState.Failed, reason); + + private void Set(TelegramLoginState state, string message) + { + lock (_lock) _status = new TelegramLoginStatus(state, message); + Changed?.Invoke(); + } +} diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index e392570..4204cf6 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -669,6 +669,16 @@ "logs.empty": "Noch keine Protokolleinträge vorhanden.", "settings.title": "Einstellungen & Systemarchitektur", + "settings.telegramCard": "Telegram-Client", + "settings.telegramState.Connecting": "Verbindet", + "settings.telegramState.NeedsCode": "Code nötig", + "settings.telegramState.NeedsPassword": "Passwort nötig", + "settings.telegramState.Connected": "Angemeldet", + "settings.telegramState.Failed": "Nicht angemeldet", + "settings.telegramCode": "Anmeldecode von Telegram", + "settings.telegramPassword": "Passwort der zweistufigen Bestätigung", + "settings.telegramSend": "Senden", + "settings.telegramRetry": "Erneut anmelden", "settings.license": "Lizenz", "settings.licenseState": "Zustand", "settings.licenseValid": "Gültig", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 1cf69c0..3db56ae 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -669,6 +669,16 @@ "logs.empty": "No log messages yet.", "settings.title": "Settings & System Architecture", + "settings.telegramCard": "Telegram client", + "settings.telegramState.Connecting": "Connecting", + "settings.telegramState.NeedsCode": "Code needed", + "settings.telegramState.NeedsPassword": "Password needed", + "settings.telegramState.Connected": "Logged in", + "settings.telegramState.Failed": "Not logged in", + "settings.telegramCode": "Login code from Telegram", + "settings.telegramPassword": "Two-step verification password", + "settings.telegramSend": "Send", + "settings.telegramRetry": "Log in again", "settings.license": "License", "settings.licenseState": "State", "settings.licenseValid": "Valid", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js index a2e9087..d619f31 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js @@ -214,10 +214,11 @@ async function instanceSection() { return el; } - const [settings, status, info] = await Promise.all([ + const [settings, status, info, telegram] = await Promise.all([ api("/settings").catch(() => null), api("/status").catch(() => null), - api("/info").catch(() => null) + api("/info").catch(() => null), + api("/telegram-client").catch(() => null) ]); // The instance file belongs to the instance process: it is only written while it runs. @@ -230,6 +231,7 @@ async function instanceSection() { const grid = erstelleElement("div", { klasse: "settings-grid" }); grid.append(instanceCard(settings), openRouterCard(settings, status), applicationCard(settings)); + if (telegram && telegram.state !== "NotConfigured") grid.append(telegramCard(telegram)); if (info) grid.append(subsystemsCard(info)); el.append(grid); @@ -332,3 +334,89 @@ function subsystemsCard(info) { [t("settings.dcSubToken"), badge(info.deploymentcenterActive)] ])]); } + +// ─── Telegram client login (#794) ─── +// +// The login code and the two-step password used to be asked in a desktop dialog. Now the +// instance holds the question and this card answers it. While a login is under way the card +// asks again every few seconds — Telegram's code arrives on the account's other devices, not +// here, so there is no event to wait for. While a question is open it does not ask: the +// answer itself moves the state on. + +const TELEGRAM_BADGE = { + Connected: "erfolg", Failed: "gefahr", NeedsCode: "warnung", NeedsPassword: "warnung", Connecting: "neutral" +}; + +function telegramCard(initial) { + const body = erstelleElement("div", { klasse: "formular-raster" }); + const card = erstelleKarte(t("settings.telegramCard"), [body]); + let timer = null; + + function render(state) { + body.replaceChildren( + erstelleElement("div", { klasse: "gruppe-horizontal" }, + erstellePlakette(t(`settings.telegramState.${state.state}`), TELEGRAM_BADGE[state.state] ?? "neutral")), + erstelleElement("p", { klasse: "gedaempft text-klein", text: state.message })); + + if (state.state === "NeedsCode" || state.state === "NeedsPassword") { + const input = erstelleElement("input", { + type: state.state === "NeedsPassword" ? "password" : "text", + autocomplete: "one-time-code", + inputmode: state.state === "NeedsCode" ? "numeric" : null + }); + const send = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer klein", text: t("settings.telegramSend") }); + const form = erstelleElement("form", { klasse: "formular-raster" }, + erstelleElement("label", { text: state.state === "NeedsCode" ? t("settings.telegramCode") : t("settings.telegramPassword") }), + input, erstelleElement("div", {}, send)); + form.addEventListener("submit", async (e) => { + e.preventDefault(); + send.disabled = true; + try { + await api("/telegram-client/answer", { method: "POST", body: { value: input.value } }); + input.value = ""; + await refreshTelegram(); + } catch (err) { + zeigeMeldung(err.message, "gefahr"); + send.disabled = false; + } + }); + body.append(form); + } + + if (state.state === "Failed") { + body.append(erstelleElement("div", {}, erstelleElement("button", { + type: "button", + klasse: "schaltflaeche klein", + text: t("settings.telegramRetry"), + onclick: async () => { + try { + await api("/telegram-client/connect", { method: "POST" }); + await refreshTelegram(); + } catch (err) { + zeigeMeldung(err.message, "gefahr"); + } + } + }))); + } + + clearTimeout(timer); + // Only while connecting: re-rendering during a question would wipe what is being typed. + if (state.state === "Connecting") { + timer = setTimeout(() => { + // Stop asking once the card is gone (another view, or the page was rebuilt). + if (card.isConnected) refreshTelegram(); + }, 3000); + } + } + + async function refreshTelegram() { + try { + render(await api("/telegram-client")); + } catch { + clearTimeout(timer); + } + } + + render(initial); + return card; +} diff --git a/src/ClawdDotNet.Tools.TelegramClient/TelegramClientManager.cs b/src/ClawdDotNet.Tools.TelegramClient/TelegramClientManager.cs index 3c1a120..a47c348 100644 --- a/src/ClawdDotNet.Tools.TelegramClient/TelegramClientManager.cs +++ b/src/ClawdDotNet.Tools.TelegramClient/TelegramClientManager.cs @@ -49,6 +49,8 @@ public sealed class TelegramClientManager : IAsyncDisposable var sessionDir = Path.GetDirectoryName(_sessionPath)!; Directory.CreateDirectory(sessionDir); + // #794: A login can be started again after it failed; the old client goes first. + _client?.Dispose(); _client = new Client(ConfigCallback); Helpers.Log = (lvl, msg) => diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs index 4873e18..6163291 100644 --- a/src/ClawdDotNet.Web/Contracts/Dtos.cs +++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs @@ -471,3 +471,9 @@ public sealed record SettingsUpdateDto( /// #764: Input of a tool settings form, by field name. An empty secret keeps the stored one. public sealed record ToolSettingsFormInputDto(Dictionary? Values); + +/// #794: The login code or two-step password for the Telegram client. +public sealed record TelegramAnswerDto(string? Value); + +/// #794: Where the Telegram client login stands — the state by name, for the WebUI. +public sealed record TelegramStatusDto(string State, string Message); diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs index 7dca5dc..8cfec1c 100644 --- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs @@ -73,6 +73,16 @@ internal static class ApiEndpoints { api.MapGet("/settings", (AppHost host) => Results.Ok(host.SettingsAdmin.Snapshot())); + // #794: The Telegram client login — the state, and the answer to its question. The + // code and the password go in, never out. + api.MapGet("/telegram-client", (AppHost host) => + host.TelegramLogin.Status is var status + ? Results.Ok(new TelegramStatusDto(status.State.ToString(), status.Message)) + : Results.NoContent()); + api.MapPost("/telegram-client/answer", (AppHost host, TelegramAnswerDto input) => + host.TelegramLogin.Answer(input.Value ?? "").ToResponse()); + api.MapPost("/telegram-client/connect", (AppHost host) => host.StartTelegramLogin().ToResponse()); + api.MapPut("/settings", (AppHost host, SettingsUpdateDto input) => host.SettingsAdmin.Update(new SettingsUpdate( input.InstanceName, input.LogDirectory, input.AutostartByManager, input.OpenRouterApiKey, diff --git a/tests/ClawdDotNet.App.Tests/Services/TelegramLoginBrokerTests.cs b/tests/ClawdDotNet.App.Tests/Services/TelegramLoginBrokerTests.cs new file mode 100644 index 0000000..e183e79 --- /dev/null +++ b/tests/ClawdDotNet.App.Tests/Services/TelegramLoginBrokerTests.cs @@ -0,0 +1,71 @@ +using ClawdDotNet.App.Services; +using Shouldly; + +namespace ClawdDotNet.App.Tests.Services; + +/// +/// #794: The Telegram client login through the WebUI — the question waits for the answer, +/// and an answer without a question goes nowhere. +/// +public sealed class TelegramLoginBrokerTests +{ + [Fact] + public async Task The_code_from_the_web_reaches_the_waiting_login() + { + var broker = new TelegramLoginBroker(configured: true); + + var waiting = broker.AskForCodeAsync("code?"); + broker.Status.State.ShouldBe(TelegramLoginState.NeedsCode); + + broker.Answer(" 12345 ").Ok.ShouldBeTrue(); + + (await waiting).ShouldBe("12345"); + broker.Status.State.ShouldBe(TelegramLoginState.Connecting); + } + + [Fact] + public void An_answer_without_a_question_is_refused() + { + var broker = new TelegramLoginBroker(configured: true); + + broker.Answer("12345").Ok.ShouldBeFalse(); + } + + [Fact] + public async Task An_answer_is_used_once() + { + var broker = new TelegramLoginBroker(configured: true); + var waiting = broker.AskForPasswordAsync(); + + broker.Answer("secret").Ok.ShouldBeTrue(); + broker.Answer("again").Ok.ShouldBeFalse(); + (await waiting).ShouldBe("secret"); + } + + [Fact] + public void The_status_never_carries_the_answer() + { + var broker = new TelegramLoginBroker(configured: true); + _ = broker.AskForPasswordAsync(); + + broker.Answer("hunter2"); + + broker.Status.Message.ShouldNotContain("hunter2"); + } + + [Fact] + public async Task A_new_question_cancels_an_unanswered_one() + { + var broker = new TelegramLoginBroker(configured: true); + var first = broker.AskForCodeAsync("code?"); + var second = broker.AskForCodeAsync("code again?"); + + await Should.ThrowAsync(first); + broker.Answer("777").Ok.ShouldBeTrue(); + (await second).ShouldBe("777"); + } + + [Fact] + public void Without_a_client_the_state_says_so() + => new TelegramLoginBroker(configured: false).Status.State.ShouldBe(TelegramLoginState.NotConfigured); +}