forked from admin/VolcanicAgenticCore
feat(telegram): send Claude Code login links to the bot owner
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<T></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
|
||||
}
|
||||
}
|
||||
43
tests/Volcanic.AgenticCore.Telegram.Tests/OwnerLoginTests.cs
Normal file
43
tests/Volcanic.AgenticCore.Telegram.Tests/OwnerLoginTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user