feat(telegram): send Claude Code login links to the bot owner

This commit is contained in:
EugeneTes
2026-09-25 17:45:11 +00:00
parent 1aa9197d9c
commit df0a518b97
7 changed files with 192 additions and 3 deletions

View File

@@ -68,7 +68,8 @@ await session.PromptAsync("Explain this repository");
- [Providers and models](docs/providers.md): model keys, authentication, the built-in provider extensions.
- [Telegram bot](docs/telegram-bot.md): setup, commands, security, connection recovery.
- [Building a UI (Telegram guide)](docs/ui-integration.md): how to put a chat front end on the core.
- [Claude Code provider](docs/claude-code-provider.md): requirements, how the bridge works, limits, the doctor command.
- [Claude Code provider](docs/claude-code-provider.md): requirements, how the bridge works, limits, the doctor
command, automatic re-login when the subscription session expires.
- Design spec and plan: `docs/superpowers/specs/`, `docs/superpowers/plans/`.
## Layout of the agent directory

View File

@@ -24,7 +24,8 @@ o.DefaultModel = "claude-code/sonnet"; // or opus, haiku, fable, or the pin
```
Run `/claude-code-doctor` in a session to check the installation, auth, required CLI flags and the bridge
handshake. It costs no quota.
handshake. It costs no quota. If it reports "not logged in", run `/claude-code-login` (see below) or `claude
auth login` on the host.
## How it works
@@ -54,6 +55,36 @@ conversation is replayed each time.
`apiKeySource == "none"` (proof of subscription auth) and a connected `volcanic` MCP server. Anything else
fails closed.
## Session expiry and re-login
A `claude` subscription session can expire mid-use (a 401, or the CLI's own "Please run /login" / "Invalid API
key" wording). The provider detects this from the stream and, given somewhere to send the owner a link, logs
back in without failing the request outright:
1. The failing request pauses instead of erroring, as long as nothing has reached the caller yet (no partial
reply, no tool call).
2. If a `ClaudeCodeProviderOptions.LoginUi` is configured, its `IUserInterface` gets a notification with the
`claude auth login --claudeai` link, then an input prompt ("Paste the Claude login code") for the code shown
on the page.
3. `ClaudeLoginCoordinator` keeps this to one flow at a time: concurrent expired requests all wait on the same
login instead of racing the CLI.
4. A wrong code gets a fresh link, up to 3 attempts; the owner has 10 minutes to answer each prompt
(`ClaudeLoginOptions.CodeTimeout`), and resolving where the owner is has 30 seconds
(`ClaudeLoginOptions.OwnerTimeout`) before they count as unreachable.
5. On success the paused request replays exactly once; a failure there (including expiring again immediately)
is published as-is, so there is no retry loop.
6. **`LoginUi` is null** (the default, e.g. headless hosts): no login flow runs. The request fails with
`[auth] Claude Code session expired; run "claude auth login" on the host (or /claude-code-login from a chat
UI)`.
Run `/claude-code-login` in any session with a UI to sign in (or switch accounts) through that session's own
dialog, with no owner redirect — useful even before the session has expired. Without a UI it explains that it
needs one instead of failing silently.
`ClaudeCodeProviderOptions` carries the pieces a host wires up: `LoginUi` (where the link goes),
`Coordinator` (defaults to the process-wide `ClaudeLoginCoordinator.Shared`), and `LoginOptions` (the timeouts
and attempt budget above). The Telegram host points `LoginUi` at the first `AllowedUserIds` entry's private chat.
## Limitations (inherited from the design)
- Full-history replay costs more context than the Messages API and cannot replay thinking signatures.

View File

@@ -62,9 +62,16 @@ A message sent while the agent works steers the current run. Photos (with or wit
| `/thinking [level]` | Show or set the thinking level (Claude Code `--effort`) |
| `/help` | Short help |
Any other `/command` is passed to the session, so extension commands such as `/claude-code-doctor` work.
Any other `/command` is passed to the session, so extension commands such as `/claude-code-doctor` and
`/claude-code-login` work.
When an extension asks something, the question appears with buttons (or as a reply prompt for free text).
If the Claude subscription session behind the bot expires, the **first id in `AllowedUserIds`** (the bot owner)
gets the re-login link and code prompt in their own private chat, regardless of which chat's request triggered
it. That user must already have started a chat with the bot — Telegram bots cannot message a user first — or
the login goes unanswered and the triggering request fails. See
[claude-code-provider.md](claude-code-provider.md#session-expiry-and-re-login).
## Security
The agent has the built-in tools, including `bash`, and runs them as the bot's OS user. Only allow-listed user ids

View File

@@ -0,0 +1,17 @@
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// The Claude Code login channel: the private chat of the first allowed user (the bot owner). In a private chat the chat
/// id is the user id, so the chat is created on demand even if the owner has not written yet (sending then fails
/// unless the owner has started the bot, which surfaces as an unanswered login).
/// </summary>
public sealed class OwnerLoginChannel(ChatRegistry chats, TelegramOptions options)
{
public async Task<IUserInterface?> ResolveAsync(CancellationToken cancellationToken)
{
if (options.AllowedUserIds.Count == 0) return null;
return (await chats.GetAsync(options.AllowedUserIds[0], cancellationToken).ConfigureAwait(false)).Ui;
}
}

View File

@@ -26,6 +26,14 @@ if (errors.Count > 0)
}
builder.Services.AddSingleton(options);
builder.Services.AddSingleton<OwnerLoginChannel>();
// The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves
// OwnerLoginChannel lazily (at call time, not here) to avoid a construction cycle: ChatRegistry depends on the
// session factory, which depends on the extension, which would otherwise depend back on ChatRegistry.
builder.Services.AddSingleton(sp => new ClaudeCodeProviderOptions
{
LoginUi = ct => sp.GetRequiredService<OwnerLoginChannel>().ResolveAsync(ct),
});
builder.Services.AddAgenticCore(o =>
{
o.DefaultModel = options.Model;

View File

@@ -0,0 +1,82 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
/// <summary>
/// The Telegram host (and any other host that wants a per-session <see cref="ClaudeCodeProviderOptions.LoginUi"/>)
/// relies on <c>ActivatorUtilities.CreateInstance</c> resolving <see cref="ClaudeCodeProviderExtension"/>'s optional
/// <see cref="ClaudeCodeProviderOptions"/> constructor parameter from a singleton registered in the service
/// provider - the exact construction path <c>ExtensionRegistration.FromType&lt;T&gt;</c> uses per session (see
/// <c>Features/Extensions/ExtensionRegistration.cs</c>). This pins that assumption down so a future change to the
/// constructor's shape, or to ActivatorUtilities' own resolution rules, fails here instead of silently dropping
/// <c>LoginUi</c> in production.
/// </summary>
public class ProviderExtensionActivatorInjectionTests
{
/// <summary>Records provider and command registrations; only what activation touches.</summary>
private sealed class MinimalExtensionApi : IExtensionApi
{
public List<ProviderRegistration> Providers { get; } = [];
public Dictionary<string, CommandDefinition> Commands { get; } = new();
public string ExtensionName => "claude-code-provider";
public ILogger Logger => NullLogger.Instance;
public IEventBus Events => throw new NotSupportedException();
public IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent => new Subscription();
public void RegisterTool(ITool tool) => throw new NotSupportedException();
public void RegisterCommand(string name, CommandDefinition command) => Commands[name] = command;
public void RegisterProvider(ProviderRegistration provider) => Providers.Add(provider);
public void UnregisterProvider(string providerId) => throw new NotSupportedException();
public void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true) { }
public void SendUserMessage(string text, IReadOnlyList<ImageContent>? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException();
public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException();
public Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ThinkingLevel GetThinkingLevel() => throw new NotSupportedException();
public void SetThinkingLevel(ThinkingLevel level) => throw new NotSupportedException();
public IReadOnlyList<string> GetActiveTools() => throw new NotSupportedException();
public void SetActiveTools(IEnumerable<string> names) => throw new NotSupportedException();
public IReadOnlyList<ToolInfo> GetAllTools() => throw new NotSupportedException();
public IReadOnlyList<CommandInfo> GetCommands() => throw new NotSupportedException();
public string? GetSessionName() => throw new NotSupportedException();
public void SetSessionName(string? name) => throw new NotSupportedException();
public Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
private sealed class Subscription : IDisposable { public void Dispose() { } }
}
[Fact]
public async Task ActivatorUtilities_resolves_the_extensions_optional_options_parameter_from_di()
{
var services = new ServiceCollection();
services.AddSingleton(new ClaudeCodeProviderOptions { ClaudePath = "sentinel-claude-path", Registry = new SessionRegistry(), GetEnv = _ => null });
await using var provider = services.BuildServiceProvider();
// The exact construction path ExtensionRegistration.FromType<T> uses per session.
var extension = (ClaudeCodeProviderExtension)ActivatorUtilities.CreateInstance(provider, typeof(ClaudeCodeProviderExtension));
await extension.ActivateAsync(new MinimalExtensionApi(), CancellationToken.None);
Assert.Equal("sentinel-claude-path", extension.Api!.Options.ClaudePath);
}
[Fact]
public async Task Without_a_di_registration_the_constructors_default_parameter_is_used()
{
var services = new ServiceCollection();
await using var provider = services.BuildServiceProvider();
var extension = (ClaudeCodeProviderExtension)ActivatorUtilities.CreateInstance(provider, typeof(ClaudeCodeProviderExtension));
await extension.ActivateAsync(new MinimalExtensionApi(), CancellationToken.None);
Assert.Null(extension.Api!.Options.LoginUi); // options: null -> new ClaudeCodeProviderOptions(), no DI surprise
}
}

View File

@@ -0,0 +1,43 @@
using Volcanic.AgenticCore.Extensions.ClaudeCode;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Tests.Support;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class OwnerLoginTests
{
[Fact]
public async Task Login_link_and_code_prompt_go_to_the_owner_chat_and_the_reply_answers_it()
{
await using var host = new ChatHost();
var channel = new OwnerLoginChannel(host.Chats, host.Options);
var ui = await channel.ResolveAsync(CancellationToken.None);
Assert.NotNull(ui);
var ask = new UiLoginPrompt(ui!).AskForCodeAsync("https://claude.com/cai/oauth/authorize?state=t", 1, CancellationToken.None);
var chat = await host.ChatAsync();
await Wait.UntilAsync(() => chat.Ui.IsWaitingForInput, "the login code question");
// A plain-text reply from the owner (not a command, no /-prefix) answers the pending InputAsync directly;
// it never becomes a prompt to the model.
await host.SendAsync("pasted-code");
Assert.Equal("pasted-code", await ask.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Empty(host.Faux.Requests);
await host.SettleAsync(); // the link notification goes through the renderer's async queue
Assert.Contains(host.Bot.Sent, s => s.ChatId.Identifier == Updates.AllowedUser
&& s.Text.Contains("oauth/authorize?state=t", StringComparison.Ordinal));
Assert.Contains(host.Bot.Sent, s => s.ChatId.Identifier == Updates.AllowedUser && s.Text == UiLoginPrompt.CodeQuestion);
}
[Fact]
public async Task No_allowed_users_means_no_owner_to_resolve()
{
await using var host = new ChatHost();
host.Options.AllowedUserIds.Clear();
var channel = new OwnerLoginChannel(host.Chats, host.Options);
Assert.Null(await channel.ResolveAsync(CancellationToken.None));
}
}