forked from admin/VolcanicAgenticCore
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.
235 lines
13 KiB
C#
235 lines
13 KiB
C#
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()}**.";
|
||
}
|
||
}
|