#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();
}
}
@@ -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",
@@ -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",
@@ -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;
}
@@ -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) =>
+6
View File
@@ -471,3 +471,9 @@ public sealed record SettingsUpdateDto(
/// <summary>#764: Input of a tool settings form, by field name. An empty secret keeps the stored one.</summary>
public sealed record ToolSettingsFormInputDto(Dictionary<string, string?>? Values);
/// <summary>#794: The login code or two-step password for the Telegram client.</summary>
public sealed record TelegramAnswerDto(string? Value);
/// <summary>#794: Where the Telegram client login stands — the state by name, for the WebUI.</summary>
public sealed record TelegramStatusDto(string State, string Message);
@@ -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,