#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:
co-authored by
Claude Opus 5.5
parent
93395c9e01
commit
4e41fc12d7
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #794: The Telegram client login through the WebUI — the question waits for the answer,
|
||||
/// and an answer without a question goes nowhere.
|
||||
/// </summary>
|
||||
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<TaskCanceledException>(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);
|
||||
}
|
||||
Reference in New Issue
Block a user