Files
Playa/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs
EugeneTes 92a0061fdd feat(telegram): ADMIN_USER_ID may /impersonate another user
While on, the admin's updates are routed as the target user's (allow-list,
chat, workspace, session, settings, jobs) and the target chat's output goes
to the admin's chat. The target's own updates are dropped with a notice that
an admin is working with their account. In memory; /impersonate 0 stops.
2026-10-05 07:24:07 +00:00

235 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using System.Text;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Scheduling;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>The bot's own slash commands. Anything else starting with <c>/</c> goes to the session (extension commands).</summary>
public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zones = null, JobScheduler? scheduler = null)
{
public static readonly IReadOnlyList<BotCommand> Menu =
[
new() { Command = "new", Description = "Start a new conversation" },
new() { Command = "stop", Description = "Stop the current run" },
new() { Command = "status", Description = "Model, session and context usage" },
new() { Command = "sessions", Description = "Recent conversations" },
new() { Command = "resume", Description = "Resume a conversation: /resume <id>" },
new() { Command = "compact", Description = "Summarise the conversation to free context" },
new() { Command = "thinking", Description = "Show or set the thinking level" },
new() { Command = "timezone", Description = "Show or set this chat's time zone" },
.. SchedulerSetup.Menu,
new() { Command = "help", Description = "What this bot does" },
];
/// <summary>The admin's chat gets <see cref="Menu"/> plus this (see <see cref="Impersonation"/>).</summary>
public static readonly BotCommand ImpersonateCommand = new() { Command = "impersonate", Description = "Act as another user: /impersonate <user_id>, 0 stops" };
/// <summary>Split <c>/name@bot args</c>. Null when the text is not a command.</summary>
public static (string Name, string Args)? Parse(string text)
{
if (!text.StartsWith('/') || text.Length < 2) return null;
var space = text.IndexOfAny([' ', '\n']);
var head = space < 0 ? text[1..] : text[1..space];
var args = space < 0 ? "" : text[(space + 1)..].Trim();
var at = head.IndexOf('@');
if (at >= 0) head = head[..at];
return (head.ToLowerInvariant(), args);
}
/// <summary>Run a bot command. False when <paramref name="text"/> is not one of ours.</summary>
public async Task<bool> TryHandleAsync(ChatContext chat, string text, CancellationToken cancellationToken)
{
if (Parse(text) is not { } command) return false;
var session = chat.Session;
switch (command.Name)
{
case "start":
case "help":
await ReplyAsync(chat, Help(chat), cancellationToken).ConfigureAwait(false);
return true;
case "stop":
chat.Inbox.Discard(); // what is still waiting in the window would start the run the user just stopped
if (!session.IsStreaming && !session.IsCompacting)
{
await ReplyAsync(chat, "Nothing is running.", cancellationToken).ConfigureAwait(false);
return true;
}
await session.AbortAsync().ConfigureAwait(false);
return true; // the renderer reports "Stopped."
case "new":
chat.Inbox.Discard(); // drop what the user typed before /new, before a buffered message can flush
if (session.IsStreaming) await session.AbortAsync().ConfigureAwait(false);
var created = await session.NewSessionAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
await ReplyAsync(chat, created.Cancelled ? "An extension cancelled the new session." : "๐Ÿ†• New conversation.", cancellationToken).ConfigureAwait(false);
return true;
case "status":
await ReplyAsync(chat, Status(chat), cancellationToken).ConfigureAwait(false);
return true;
case "sessions":
await ReplyAsync(chat, await SessionsAsync(chat, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "resume":
await ReplyAsync(chat, await ResumeAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "compact":
if (session.IsStreaming)
{
await ReplyAsync(chat, "Wait until the current run finishes, or /stop it.", cancellationToken).ConfigureAwait(false);
return true;
}
_ = Task.Run(async () =>
{
try
{
await session.CompactAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
chat.Report($"Compaction failed: {ex.Message}");
}
}, CancellationToken.None);
return true; // the renderer shows compaction progress
case "thinking":
await ReplyAsync(chat, await ThinkingAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "timezone":
await ReplyAsync(chat, await TimeZoneAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "jobs":
await ReplyAsync(chat, Jobs(chat), cancellationToken).ConfigureAwait(false);
return true;
case "cancel":
await ReplyAsync(chat, await CancelJobAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
default:
return false;
}
}
private Task ReplyAsync(ChatContext chat, string text, CancellationToken cancellationToken) =>
sender.SendHtmlAsync(chat.DeliveryChatId, TelegramFormatter.ToHtml(text), text, cancellationToken: cancellationToken);
private static string Help(ChatContext chat) =>
$"""
I am a coding agent running **{chat.Session.Model?.Name ?? "an unconfigured model"}**. Write to me as you would to a colleague; I can read and change files and run shell commands in my workspace `{chat.Session.Cwd}`.
While I am working, a new message steers the current run.
Reply to one of my messages, or quote part of it, to point at what you mean.
/new โ€“ start a new conversation
/stop โ€“ stop the current run
/status โ€“ model, session and context usage
/sessions, /resume <id> โ€“ earlier conversations
/compact โ€“ summarise the conversation to free context
/thinking [level] โ€“ show or set the thinking level
/timezone [IANA name|reset] โ€“ this chat's time zone
""" + SchedulerSetup.HelpLines + GoogleCalendarSetup.HelpLines(chat.Session.Commands);
private string Status(ChatContext chat)
{
var session = chat.Session;
var sb = new StringBuilder();
sb.AppendLine(CultureInfo.InvariantCulture, $"**Model:** {session.Model?.Key ?? "none"}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Thinking:** {session.ThinkingLevel.ToString().ToLowerInvariant()}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Session:** `{session.SessionId}`{(session.SessionName is { } name ? $" ({name})" : "")}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Messages:** {session.Messages.Count}");
if (session.GetContextUsage() is { } usage)
sb.AppendLine(CultureInfo.InvariantCulture, $"**Context:** {(usage.Tokens is { } t ? t.ToString("N0", CultureInfo.InvariantCulture) : "?")} / {usage.ContextWindow.ToString("N0", CultureInfo.InvariantCulture)} tokens{(usage.Percent is { } p ? $" ({p:0}%)" : "")}");
if (scheduler is { IsStarted: true })
sb.AppendLine(CultureInfo.InvariantCulture, $"**Jobs:** {scheduler.List(TelegramJobDelivery.TargetOf(chat.ChatId)).Count}");
var zone = zones?.Describe(chat.ChatId) ?? TimeZoneIds.Name(session.TimeZone); // no service (tests): no clock read
sb.AppendLine(CultureInfo.InvariantCulture, $"**Time zone:** {zone}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Workspace:** `{session.Cwd}`");
sb.Append(session.IsStreaming ? "**State:** running" : session.IsCompacting ? "**State:** compacting" : "**State:** idle");
return sb.ToString();
}
private static async Task<string> SessionsAsync(ChatContext chat, CancellationToken cancellationToken)
{
var sessions = await chat.Session.ListSessionsAsync(cancellationToken).ConfigureAwait(false);
if (sessions.Count == 0) return "No conversations yet.";
var sb = new StringBuilder("**Recent conversations** (newest first):\n");
foreach (var s in sessions.Take(10))
{
var title = s.Name ?? s.FirstUserText ?? "(empty)";
if (title.Length > 60) title = title[..60] + "โ€ฆ";
var current = s.SessionId == chat.Session.SessionId ? " โ† current" : "";
sb.Append(CultureInfo.InvariantCulture, $"\n`/resume {s.SessionId}`{current}\n{s.Modified:yyyy-MM-dd HH:mm} ยท {s.MessageCount} messages ยท {title}\n");
}
return sb.ToString();
}
private static async Task<string> ResumeAsync(ChatContext chat, string id, CancellationToken cancellationToken)
{
if (id.Length == 0) return "Usage: /resume <id> (see /sessions).";
var sessions = await chat.Session.ListSessionsAsync(cancellationToken).ConfigureAwait(false);
var matches = sessions.Where(s => s.SessionId.StartsWith(id, StringComparison.OrdinalIgnoreCase)).ToList();
if (matches.Count != 1) return matches.Count == 0 ? $"No conversation `{id}`." : $"`{id}` matches several conversations; use more characters.";
chat.Inbox.Discard(); // what was typed before /resume belongs to neither conversation; drop it before it can flush
if (chat.Session.IsStreaming) await chat.Session.AbortAsync().ConfigureAwait(false);
var result = await chat.Session.SwitchSessionAsync(matches[0].SessionId, cancellationToken).ConfigureAwait(false);
return result.Cancelled ? "An extension cancelled the switch." : $"Resumed `{matches[0].SessionId}` ({matches[0].MessageCount} messages).";
}
private async Task<string> TimeZoneAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
if (zones is null) return "Time zones aren't configured on this bot.";
if (arg.Length == 0) return zones.Show(chat.ChatId);
if (arg.Equals("reset", StringComparison.OrdinalIgnoreCase)) return await zones.SetAsync(chat.ChatId, null, cancellationToken).ConfigureAwait(false);
return ChatTimeZones.TryParse(arg, out var zone, out var error)
? await zones.SetAsync(chat.ChatId, zone, cancellationToken).ConfigureAwait(false)
: error;
}
private string Jobs(ChatContext chat)
{
if (scheduler is not { IsStarted: true }) return SchedulerSetup.DisabledReply;
var target = TelegramJobDelivery.TargetOf(chat.ChatId);
var jobs = scheduler.List(target);
if (jobs.Count == 0) return "No scheduled jobs.";
var zone = scheduler.TimeZoneFor(target);
var now = scheduler.Now;
return $"**Scheduled jobs** ({JobFormat.ZoneName(zone)}, now {JobFormat.DayTime(now, zone)})\n" +
string.Join('\n', jobs.Select(j => JobFormat.Line(j, zone, now)));
}
private async Task<string> CancelJobAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
if (scheduler is not { IsStarted: true }) return SchedulerSetup.DisabledReply;
var id = arg.Trim().ToLowerInvariant();
if (id.Length == 0) return "Usage: /cancel <id> (see /jobs).";
var target = TelegramJobDelivery.TargetOf(chat.ChatId);
return await scheduler.CancelJobAsync(target, id, cancellationToken).ConfigureAwait(false) is { } job
? $"Cancelled {job.Id} ({job.Name})."
: $"No job {id} in this chat.";
}
private static async Task<string> ThinkingAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
var session = chat.Session;
var available = string.Join(", ", session.AvailableThinkingLevels.Select(l => l.ToString().ToLowerInvariant()));
if (arg.Length == 0) return $"Thinking: **{session.ThinkingLevel.ToString().ToLowerInvariant()}**. Available: {available}.";
if (!Enum.TryParse<ThinkingLevel>(arg, ignoreCase: true, out var level) || !session.AvailableThinkingLevels.Contains(level))
return $"Unknown level `{arg}`. Available: {available}.";
await session.SetThinkingLevelAsync(level, cancellationToken).ConfigureAwait(false);
return $"Thinking: **{session.ThinkingLevel.ToString().ToLowerInvariant()}**.";
}
}