Improve Telegram status and stop commands

This commit is contained in:
patriceckhart 2026-05-07 19:05:57 +02:00
parent caac4915ed
commit 63694afce8
8 changed files with 306 additions and 43 deletions

View file

@ -362,11 +362,15 @@ func botRun(rawTail []string, version string) error {
}
}
b := &telegram.Bot{
Client: telegram.NewClient(cfg.BotToken),
Agent: agent,
Config: cfg,
ZotHome: ZotHome(),
var b *telegram.Bot
b = &telegram.Bot{
Client: telegram.NewClient(cfg.BotToken),
Agent: agent,
Config: cfg,
ZotHome: ZotHome(),
Provider: resolved.Provider,
AuthMethod: resolved.AuthMethod,
CWD: args.CWD,
Save: func(c telegram.Config) error {
return telegram.SaveConfig(ZotHome(), c)
},
@ -381,6 +385,9 @@ func botRun(rawTail []string, version string) error {
}
agent.Client = next.NewClient()
agent.Model = next.Model
b.Provider = next.Provider
b.AuthMethod = next.AuthMethod
b.CWD = next.CWD
return nil
},
}

View file

@ -2131,6 +2131,7 @@ func (i *Interactive) CancelTurn() {
i.mu.Unlock()
if cancel != nil {
cancel()
i.confirmDialog.CancelAll("turn cancelled")
}
}
@ -3856,6 +3857,35 @@ func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock
func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
func (h *telegramHost) Status() string {
h.iv.mu.Lock()
providerName := h.iv.cfg.Provider
model := h.iv.cfg.Model
cwd := h.iv.cfg.CWD
usage := h.iv.cumUsage
subscription := h.iv.cfg.AuthMethod == "oauth"
ctxUsed := h.iv.lastCtxInput
busy := h.iv.busy
queued := len(h.iv.queued)
h.iv.mu.Unlock()
ctxMax := 0
if m, err := provider.FindModel(providerName, model); err == nil {
ctxMax = m.ContextWindow
}
return telegram.FormatStatus(telegram.StatusSnapshot{
Provider: providerName,
Model: model,
CWD: cwd,
Usage: usage,
Subscription: subscription,
ContextUsed: ctxUsed,
ContextMax: ctxMax,
Busy: busy,
Queued: queued,
})
}
func (h *telegramHost) Notify(level, message string) {
h.iv.mu.Lock()
switch level {

View file

@ -15,10 +15,13 @@ import (
// the agent. It is a long-running goroutine; Run blocks until ctx
// cancels.
type Bot struct {
Client *Client
Agent *core.Agent
Config Config
ZotHome string
Client *Client
Agent *core.Agent
Config Config
ZotHome string
Provider string
AuthMethod string
CWD string
// Save persists cfg to bot.json. Called whenever the bot pairs
// with a new allowed user or advances LastUpdateID.
Save func(Config) error
@ -28,10 +31,11 @@ type Bot struct {
// agent.ResolveCredentialFull which auto-refreshes expired tokens.
RefreshCreds func() error
mu sync.Mutex
busy bool
activeCtx context.CancelFunc
queue []queuedTurn
mu sync.Mutex
busy bool
activeCtx context.CancelFunc
queue []queuedTurn
lastCtxInput int
}
// queuedTurn is an inbound DM waiting to become a prompt.
@ -144,21 +148,17 @@ func (b *Bot) handleUpdate(ctx context.Context, u Update) error {
switch text {
case "/start", "/help":
_ = b.Client.SendMessage(ctx, msg.Chat.ID,
"send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop.",
"send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop, or plain stop.",
msg.MessageID)
return nil
case "/status":
return b.sendStatus(ctx, msg.Chat.ID, msg.MessageID)
case "/stop":
b.mu.Lock()
cancel := b.activeCtx
b.mu.Unlock()
if cancel != nil {
cancel()
_ = b.Client.SendMessage(ctx, msg.Chat.ID, "cancelled the current turn.", msg.MessageID)
} else {
_ = b.Client.SendMessage(ctx, msg.Chat.ID, "nothing running.", msg.MessageID)
}
b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID)
return nil
}
if isStopCommand(text) {
b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID)
return nil
}
@ -247,6 +247,12 @@ func (b *Bot) runTurn(ctx context.Context, t queuedTurn) {
switch e := ev.(type) {
case core.EvTextDelta:
replyBuilder.WriteString(e.Delta)
case core.EvUsage:
b.mu.Lock()
if e.Usage.InputTokens > 0 {
b.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
}
b.mu.Unlock()
case core.EvAssistantMessage:
var sb strings.Builder
for _, c := range e.Message.Content {
@ -308,26 +314,45 @@ func (b *Bot) startTyping(ctx context.Context, chatID int64) func() {
return cancel
}
func (b *Bot) cancelActiveTurn(ctx context.Context, chatID int64, replyTo int) {
b.mu.Lock()
cancel := b.activeCtx
b.mu.Unlock()
if cancel != nil {
cancel()
_ = b.Client.SendMessage(ctx, chatID, "cancelled the current turn.", replyTo)
} else {
_ = b.Client.SendMessage(ctx, chatID, "nothing running.", replyTo)
}
}
// sendStatus describes agent state to the Telegram user.
func (b *Bot) sendStatus(ctx context.Context, chatID int64, replyTo int) error {
b.mu.Lock()
busy := b.busy
queued := len(b.queue)
ctxUsed := b.lastCtxInput
providerName := b.Provider
authMethod := b.AuthMethod
cwd := b.CWD
b.mu.Unlock()
state := "idle"
if busy {
state = "working"
model := b.Agent.Model
ctxMax := 0
if m, err := provider.FindModel(providerName, model); err == nil {
ctxMax = m.ContextWindow
}
lines := []string{
fmt.Sprintf("state: %s", state),
fmt.Sprintf("queued: %d", queued),
fmt.Sprintf("model: %s", b.Agent.Model),
}
cost := b.Agent.Cost()
lines = append(lines, fmt.Sprintf("cost: $%.4f (%d in / %d out)",
cost.CostUSD, cost.InputTokens, cost.OutputTokens))
return b.Client.SendMessage(ctx, chatID, strings.Join(lines, "\n"), replyTo)
return b.Client.SendMessage(ctx, chatID, FormatStatus(StatusSnapshot{
Provider: providerName,
Model: model,
CWD: cwd,
Usage: b.Agent.Cost(),
Subscription: authMethod == "oauth",
ContextUsed: ctxUsed,
ContextMax: ctxMax,
Busy: busy,
Queued: queued,
}), replyTo)
}
// download fetches a file from Telegram and returns bytes + mime.

View file

@ -19,9 +19,13 @@ type Host interface {
SubmitOrQueue(prompt string, images []provider.ImageBlock)
// CancelTurn aborts the active turn (if any). Called when the
// paired Telegram user sends /stop.
// paired Telegram user sends /stop or plain "stop".
CancelTurn()
// Status returns the current model, usage, context, and cwd summary
// shown when the paired Telegram user sends /status.
Status() string
// Notify pushes a one-shot status line into the chat. Used to
// surface bridge events ("connected as @bot", "paired with
// user X", etc.) in the user's local transcript.
@ -272,9 +276,9 @@ func (b *Bridge) pollLoop(ctx context.Context) {
// handleUpdate applies pairing, gates on the allowed user, decodes
// the interesting bits (text, caption, image attachments), and
// forwards them to the Host. Built-in slash commands (/start,
// /help, /status, /stop) are handled inline without touching the
// agent.
// forwards them to the Host. Built-in commands (/start, /help,
// /status, /stop, and plain "stop") are handled inline without
// touching the agent.
func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
msg := u.Message
if msg == nil {
@ -329,13 +333,11 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
switch text {
case "/start", "/help":
_ = b.Client.SendMessage(ctx, msg.Chat.ID,
"mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop.",
"mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.",
msg.MessageID)
return
case "/status":
_ = b.Client.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("mirror active. paired user: %d.", paired),
msg.MessageID)
_ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID)
return
case "/stop":
b.Host.CancelTurn()
@ -343,6 +345,12 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
"cancelled the current turn.", msg.MessageID)
return
}
if isStopCommand(text) {
b.Host.CancelTurn()
_ = b.Client.SendMessage(ctx, msg.Chat.ID,
"cancelled the current turn.", msg.MessageID)
return
}
// Build the prompt: text + caption; download image attachments.
prompt := strings.TrimSpace(msg.Text)

View file

@ -0,0 +1,11 @@
package telegram
import "strings"
// isStopCommand reports whether text should abort the active turn.
// Telegram users often type plain "stop" rather than bot-style
// "/stop"; keep this intentionally narrow so normal prompts like
// "stop doing X" still go to the agent.
func isStopCommand(text string) bool {
return strings.EqualFold(strings.TrimSpace(text), "stop")
}

View file

@ -0,0 +1,23 @@
package telegram
import "testing"
func TestIsStopCommand(t *testing.T) {
tests := []struct {
text string
want bool
}{
{"stop", true},
{" STOP ", true},
{"Stop", true},
{"/stop", false}, // handled by the slash-command switch
{"stop please", false},
{"please stop", false},
{"", false},
}
for _, tt := range tests {
if got := isStopCommand(tt.text); got != tt.want {
t.Fatalf("isStopCommand(%q) = %v, want %v", tt.text, got, tt.want)
}
}
}

View file

@ -0,0 +1,120 @@
package telegram
import (
"fmt"
"os"
"strings"
"github.com/patriceckhart/zot/internal/provider"
)
// StatusSnapshot is the small cross-host state bundle rendered for
// Telegram /status replies.
type StatusSnapshot struct {
Provider string
Model string
CWD string
Usage provider.Usage
Subscription bool
ContextUsed int
ContextMax int
Busy bool
Queued int
}
// FormatStatus renders the same compact model/usage/cost/context
// information shown in the TUI status bar, plus the current directory.
func FormatStatus(s StatusSnapshot) string {
providerName := strings.TrimSpace(s.Provider)
model := strings.TrimSpace(s.Model)
if providerName == "" {
providerName = "unknown"
}
if model == "" {
model = "unknown"
}
var stats []string
if s.Usage.InputTokens > 0 {
stats = append(stats, fmt.Sprintf("↑%s", formatTokens(s.Usage.InputTokens)))
}
if s.Usage.OutputTokens > 0 {
stats = append(stats, fmt.Sprintf("↓%s", formatTokens(s.Usage.OutputTokens)))
}
if s.Usage.CacheReadTokens > 0 {
stats = append(stats, fmt.Sprintf("R%s", formatTokens(s.Usage.CacheReadTokens)))
}
if s.Usage.CacheWriteTokens > 0 {
stats = append(stats, fmt.Sprintf("W%s", formatTokens(s.Usage.CacheWriteTokens)))
}
if s.Usage.CostUSD > 0 || s.Subscription {
cost := fmt.Sprintf("$%.3f", s.Usage.CostUSD)
if s.Subscription {
cost += " (sub)"
}
stats = append(stats, cost)
}
if ctx := contextUsage(s.ContextUsed, s.ContextMax); ctx != "" {
stats = append(stats, ctx)
}
line := fmt.Sprintf("(%s) %s", providerName, model)
if len(stats) > 0 {
line += " " + strings.Join(stats, " ")
}
state := "idle"
if s.Busy {
state = "working"
}
lines := []string{line, "state: " + state}
if s.Queued > 0 {
lines = append(lines, fmt.Sprintf("queued: %d", s.Queued))
}
if cwd := shortenHome(strings.TrimSpace(s.CWD)); cwd != "" {
lines = append(lines, "cwd: "+cwd)
}
return strings.Join(lines, "\n")
}
func contextUsage(used, max int) string {
if max <= 0 {
if used <= 0 {
return ""
}
return formatTokens(used)
}
pct := float64(used) / float64(max) * 100
return fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max))
}
func formatTokens(n int) string {
switch {
case n < 0:
return "0"
case n < 1000:
return fmt.Sprintf("%d", n)
case n < 10000:
return fmt.Sprintf("%.1fk", float64(n)/1000)
case n < 1_000_000:
return fmt.Sprintf("%dk", (n+500)/1000)
case n < 10_000_000:
return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
default:
return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
}
}
func shortenHome(path string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return path
}
if path == home {
return "~"
}
if strings.HasPrefix(path, home+string(os.PathSeparator)) {
return "~" + strings.TrimPrefix(path, home)
}
return path
}

View file

@ -0,0 +1,39 @@
package telegram
import (
"strings"
"testing"
"github.com/patriceckhart/zot/internal/provider"
)
func TestFormatStatusIncludesModelUsageContextAndCWD(t *testing.T) {
got := FormatStatus(StatusSnapshot{
Provider: "openai",
Model: "gpt-5.5",
CWD: "/tmp/zot",
Usage: provider.Usage{InputTokens: 961_000, OutputTokens: 10_000, CacheReadTokens: 770_000, CostUSD: 2.749},
Subscription: true,
ContextUsed: 44_800,
ContextMax: 400_000,
Busy: true,
Queued: 2,
})
wants := []string{
"(openai) gpt-5.5",
"↑961k",
"↓10k",
"R770k",
"$2.749 (sub)",
"11.2%/400k",
"state: working",
"queued: 2",
"cwd: /tmp/zot",
}
for _, want := range wants {
if !strings.Contains(got, want) {
t.Fatalf("FormatStatus missing %q in:\n%s", want, got)
}
}
}