#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 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 18:59:37 +02:00
co-authored by Claude Opus 5.5
parent 93395c9e01
commit 4e41fc12d7
9 changed files with 385 additions and 9 deletions
+56 -7
View File
@@ -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);
}
/// <summary>#794: The Telegram client login as the WebUI sees it and answers it.</summary>
public TelegramLoginBroker TelegramLogin { get; private set; } = new(configured: false);
private ILogger? _telegramLogger;
private int _telegramConnecting;
/// <summary>
/// #794: Starts (or restarts) the Telegram login in the background; questions go to
/// <see cref="TelegramLogin"/>. Does nothing while a login is already running.
/// </summary>
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);
@@ -0,0 +1,130 @@
namespace ClawdDotNet.App.Services;
/// <summary>#794: Where the Telegram client login stands.</summary>
public enum TelegramLoginState
{
/// <summary>No Telegram client configured in this instance.</summary>
NotConfigured,
/// <summary>Connecting; no question pending.</summary>
Connecting,
/// <summary>Telegram sent a login code; it has to be entered.</summary>
NeedsCode,
/// <summary>The account has two-step verification and no password is stored.</summary>
NeedsPassword,
Connected,
Failed
}
/// <summary>#794: The login as the WebUI sees it — never the code or password itself.</summary>
public sealed record TelegramLoginStatus(TelegramLoginState State, string Message);
/// <summary>
/// #794: The Telegram client login through the web interface.
///
/// <para>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.</para>
///
/// <para><b>Nothing waits on the person at startup.</b> 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.</para>
///
/// <para><b>A question does not wait forever.</b> After <see cref="AnswerTimeout"/> 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.</para>
/// </summary>
public sealed class TelegramLoginBroker
{
public static readonly TimeSpan AnswerTimeout = TimeSpan.FromMinutes(15);
private readonly object _lock = new();
private TaskCompletionSource<string>? _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; }
}
/// <summary>Raised whenever the state changes, so the event stream can tell the WebUI.</summary>
public event Action? Changed;
/// <summary>Asks for the login code — called by the Telegram client, waits for <see cref="Answer"/>.</summary>
public Task<string> AskForCodeAsync(string prompt)
=> AskAsync(TelegramLoginState.NeedsCode, "Telegram sent a login code to the account's devices. Enter it here.");
/// <summary>Asks for the two-step password when none is stored.</summary>
public Task<string> AskForPasswordAsync()
=> AskAsync(TelegramLoginState.NeedsPassword, "The account uses two-step verification. Enter its password.");
private async Task<string> AskAsync(TelegramLoginState state, string message)
{
TaskCompletionSource<string> pending;
lock (_lock)
{
_pending?.TrySetCanceled();
pending = _pending = new TaskCompletionSource<string>(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);
}
/// <summary>The answer from the WebUI. Refused when no question is pending.</summary>
public AdminResult Answer(string value)
{
value = value.Trim();
if (value.Length == 0)
return AdminResult.Failure("Enter the code or password.");
TaskCompletionSource<string>? 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}.");
/// <summary>The reason is shown to the person; it must not carry the code or password.</summary>
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();
}
}