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;
using Volcanic.AgenticCore.Telegram.Triggers;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// The bot's own slash commands. Anything else starting with / goes to the session (extension commands).
public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zones = null, JobScheduler? scheduler = null, TriggerServices? triggers = null)
{
public static readonly IReadOnlyList 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 " },
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,
TriggersSetup.MenuCommand,
new() { Command = "help", Description = "What this bot does" },
];
/// The admin's chat gets plus this (see ).
public static readonly BotCommand ImpersonateCommand = new() { Command = "impersonate", Description = "Act as another user: /impersonate , 0 stops" };
/// Split /name@bot args. Null when the text is not a command.
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);
}
/// Run a bot command. False when is not one of ours.
public async Task 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 "triggers":
await ReplyAsync(chat, triggers is null ? TriggersSetup.DisabledReply : "**Group triggers**\n" + triggers.Describe(chat.ChatId, chat.Session.TimeZone), 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 โ 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 + TriggersSetup.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 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 ResumeAsync(ChatContext chat, string id, CancellationToken cancellationToken)
{
if (id.Length == 0) return "Usage: /resume (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 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 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 (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 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(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()}**.";
}
}