using System.Globalization; using System.Text; using Microsoft.Extensions.Options; using Telegram.Bot.Types; using Volcanic.AgenticCore.A2A.Server; using Volcanic.AgenticCore.A2A.Server.Callers; using Volcanic.AgenticCore.A2A.Server.Tasks; using Volcanic.AgenticCore.Abstractions.Providers; using Volcanic.AgenticCore.Abstractions.Skills; using Volcanic.AgenticCore.Extensions.A2A; using Volcanic.AgenticCore.Extensions.Scheduler; using Volcanic.AgenticCore.Features.SystemPrompt; 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, IOptions? a2a = null, TimeProvider? time = null, A2AClientSettings? a2aClient = null, A2AWatchRegistry? a2aWatches = 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" }, new() { Command = "skills", Description = "Skills in this chat's workspace" }, .. SchedulerSetup.Menu, TriggersSetup.MenuCommand, new() { Command = "a2a", Description = "A2A callers, remote agents and tasks of this chat" }, new() { Command = "help", Description = "What this bot does" }, ]; /// Each 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 "skills": await ReplyAsync(chat, await SkillsAsync(chat, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); return true; case "jobs": await ReplyAsync(chat, await JobsAsync(chat, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); return true; case "a2a": await ReplyAsync(chat, A2AStatus(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 /skills – skills in my workspace """ + SchedulerSetup.HelpLines + "\n/a2a – A2A callers, remote agents and recent tasks of this chat" + TriggersSetup.HelpLines + GoogleCalendarSetup.HelpLines(chat.Session.Commands) + (chat.Session.Commands.Any(c => c.Name == "mcp_add") ? RemoteMcpSetup.HelpLines : ""); 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; } /// /// Every skill directory (SKILL.md, INSTRUCTIONS.md or jobs/, in any combination), with what it holds (hooks and /// protected paths included); a broken hook field or job file is listed with its problem (job files: scheduler on). /// private async Task SkillsAsync(ChatContext chat, CancellationToken cancellationToken) { var cwd = chat.Session.Cwd; var where = $"`{Path.GetRelativePath(cwd, SkillCatalog.Directory(cwd))}//`"; var dirs = SkillFiles.Directories(cwd).Where(SkillFiles.IsSkill).ToList(); if (dirs.Count == 0) return $"No skills yet. Ask me to write one, or put its SKILL.md, INSTRUCTIONS.md or jobs/ in {where}."; var skills = SkillCatalog.Load(cwd).ToDictionary(s => Path.GetDirectoryName(s.Path)!, StringComparer.Ordinal); var instructions = SkillCatalog.LoadInstructions(cwd).ToDictionary(i => Path.GetDirectoryName(i.Path)!, StringComparer.Ordinal); var target = TelegramJobDelivery.TargetOf(chat.ChatId); var jobs = scheduler is { IsStarted: true } ? await scheduler.SyncSkillJobsAsync(target, cancellationToken).ConfigureAwait(false) : []; var lines = new List(); foreach (var dir in dirs) { var name = Path.GetFileName(dir); var jobCount = SkillFiles.JobPaths(dir).Count; var jobsText = jobCount == 1 ? "1 job" : $"{jobCount.ToString(CultureInfo.InvariantCulture)} jobs"; var hasInstructions = instructions.TryGetValue(dir, out var i); var instructionsText = i is { Truncated: true } ? "INSTRUCTIONS.md, truncated in the prompt" : "INSTRUCTIONS.md"; var hooks = SkillHookFiles.Read(cwd, dir); var hooksText = hooks?.Summary() ?? ""; if (skills.TryGetValue(dir, out var skill)) { var extras = new List(); if (hasInstructions) extras.Add(instructionsText); if (jobCount > 0) extras.Add(jobsText); if (hooksText.Length > 0) extras.Add(hooksText); lines.Add($"• **{skill.Name}** – {skill.Description}" + (extras.Count > 0 ? $" (+ {string.Join(", ", extras)})" : "")); } else { var parts = new List(); if (hasInstructions) parts.Add(instructionsText); if (jobCount > 0) parts.Add(jobsText); if (hooksText.Length > 0) parts.Add(hooksText); if (parts.Count == 0) parts.Add("SKILL.md without a description (not listed to me)"); lines.Add($"• **{name}** – {string.Join(", ", parts)}"); } foreach (var error in hooks?.Errors ?? []) lines.Add($" ⚠️ {Path.GetRelativePath(cwd, hooks!.SkillFile)}: {error}"); foreach (var broken in jobs.Where(j => j.Status is SkillJobStatus.Invalid or SkillJobStatus.OverLimit && j.Id.StartsWith(name + "/", StringComparison.Ordinal))) lines.Add($" ⚠️ {broken.File}: {broken.Problem}"); } return $"**Skills** ({where})\n" + string.Join('\n', lines); } private async Task JobsAsync(ChatContext chat, CancellationToken cancellationToken) { if (scheduler is not { IsStarted: true }) return SchedulerSetup.DisabledReply; var target = TelegramJobDelivery.TargetOf(chat.ChatId); var skillJobs = await scheduler.SyncSkillJobsAsync(target, cancellationToken).ConfigureAwait(false); var jobs = scheduler.List(target); if (jobs.Count == 0 && skillJobs.Count == 0) return "No scheduled jobs."; var zone = scheduler.TimeZoneFor(target); var now = scheduler.Now; var text = $"**Scheduled jobs** ({JobFormat.ZoneName(zone)}, now {JobFormat.DayTime(now, zone)})\n" + (jobs.Count == 0 ? "none" : string.Join('\n', jobs.Select(j => JobFormat.Line(j, zone, now)))); if (skillJobs.Count > 0) text += "\n\n**Skill jobs** (`.volcanic/skills//jobs/`, edit the files to change them)\n" + string.Join('\n', skillJobs.Select(j => JobFormat.SkillLine(j, zone, now))); return text; } private string A2AStatus(ChatContext chat) { var serverOn = a2a?.Value is { Enabled: true }; var clientOn = a2aClient is { Enabled: true }; if (!serverOn && !clientOn) return "A2A is off. Set `A2A__Enabled=true` (and `A2A__PublicUrl`) to let other agents talk to this assistant, " + "`A2AClient__Enabled=true` to let it talk to other agents."; var workspace = chat.Session.Cwd; var lines = new List(); if (serverOn) { var callers = CallersFile.Read(workspace, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); lines.Add("**A2A callers** (.volcanic/a2a/callers.json)"); lines.AddRange(callers.Count == 0 ? ["none"] : callers.Select(c => $"• {c.Name} ({c.Mode}) key {Mask(c.Key)}")); var tasks = A2ATaskFiles.Recent(workspace, 10); lines.Add("**Recent A2A tasks**"); lines.AddRange(tasks.Count == 0 ? ["none"] : tasks.Select(t => $"• {t.Caller}: {t.State}{Age(t.Updated)} ({ShortId(t.TaskId)})")); } else lines.Add("A2A server: off (`A2A__Enabled`)."); if (clientOn) { var agents = AgentsFile.Read(workspace); lines.Add("**Remote agents** (.volcanic/a2a/agents.json)"); if (agents.Error is not null) lines.Add(agents.Error); lines.AddRange(agents.Agents.Count == 0 ? ["none"] : agents.Agents.Select(a => $"• {a.Name}: {DisplayUrl(a.Url)}{(a.Key is null ? "" : " key " + Mask(a.Key))}")); lines.AddRange(agents.Problems.Select(p => $"• skipped: {p}")); var watches = a2aWatches?.List(TelegramJobDelivery.TargetOf(chat.ChatId)) ?? []; lines.Add("**Watches** (remote tasks still running)"); lines.AddRange(watches.Count == 0 ? ["none"] : watches.Select(w => $"• {w.Agent}: task {ShortId(w.TaskId)} by {w.Source}{Age(w.StartedAt)}")); } else lines.Add("A2A client: off (`A2AClient__Enabled`)."); return string.Join('\n', lines); } /// The last four characters of a key of 12 or more, else nothing: "…abcd" or "…". private static string Mask(string key) => key.Length >= 12 ? "…" + key[^4..] : "…"; /// Scheme, host, port and path only: a query, fragment or user info may carry credentials. private static string DisplayUrl(string url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped) : "(invalid url)"; private static string ShortId(string id) => id[..Math.Min(8, id.Length)]; /// ", 5m ago" from a task's last status time; empty without one. private string Age(DateTimeOffset? updated) { if (updated is not { } at) return ""; var age = (time ?? TimeProvider.System).GetUtcNow() - at; return age.TotalMinutes < 1 ? ", just now" : age.TotalHours < 1 ? $", {(int)age.TotalMinutes}m ago" : age.TotalDays < 1 ? $", {(int)age.TotalHours}h ago" : $", {(int)age.TotalDays}d ago"; } 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); if (scheduler.FindSkillJob(target, arg) is { } skillJob) return JobFormat.SkillCancelHint(skillJob); 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()}**."; } }