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);
+}