detached 741c643

refactor: extract generic BotAdapter interface from Telegram bot

Extract protocol-agnostic bot logic into a new packages/agent/modes/bot
package, enabling future messaging backends (Discord, Slack, etc.) to
reuse turn queueing, agent prompting, and command dispatch.

New package modes/bot:
- adapter.go: BotAdapter interface + InboundMessage/Command types
- runner.go: Generic Runner with turn queue, drainQueue, runTurn
- status.go: StatusSnapshot + FormatStatus (moved from telegram)
- commands.go: IsStopCommand (moved from telegram)

Telegram adapter (modes/telegram/adapter.go):
- Implements BotAdapter for Telegram protocol
- Owns polling, pairing, user filtering, image download
- ChannelID encoded as string (fmt.Sprintf(%d, chatID))

Refactored telegram/bot.go:
- Bot type now a thin wrapper (deprecated, kept for compatibility)
- Utility functions (chunkMessage, isImageMIME, guessImageMIME) retained

Updated botcmd.go:
- Constructs adapter + runner directly instead of Bot wrapper
- Uses runner.UpdateRuntimeConfig for credential refresh

Shims in telegram/status.go and telegram/commands.go re-export from
bot package for backward compatibility with interactive.go.

All tests pass. No import cycles: modes/bot does not import modes/telegram.
commit
741c643b1b85b9cec960f468cb547e3592b39d49
author
mi-skam · · 2026-06-26 05:39 UTC
committer
mi-skam · · 2026-06-26 05:56 UTC
parent
b325477
signature
SSH UnverifiedSHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0w The key is unknown
9 files · +732 -486
M packages/agent/botcmd.go +14 -13
@@ -15,6 +15,7 @@ import (
1515 "syscall"
1616 "time"
1717
18+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
1819 "github.com/patriceckhart/zot/packages/agent/modes/telegram"
1920 "github.com/patriceckhart/zot/packages/core"
2021 )
@@ -362,18 +363,20 @@ func botRun(rawTail []string, version string) error {
362363 }
363364 }
364365
365- var b *telegram.Bot
366- b = &telegram.Bot{
367- Client: telegram.NewClient(cfg.BotToken),
368- Agent: agent,
369- Config: cfg,
366+ // Construct the Telegram adapter and generic runner.
367+ adapter := telegram.NewAdapter(
368+ telegram.NewClient(cfg.BotToken),
369+ &cfg,
370+ func(c telegram.Config) error {
371+ return telegram.SaveConfig(ZotHome(), c)
372+ },
373+ )
374+ var runner *bot.Runner
375+ runner = bot.NewRunner(adapter, agent, bot.Config{
370376 ZotHome: ZotHome(),
371377 Provider: resolved.Provider,
372378 AuthMethod: resolved.AuthMethod,
373379 CWD: args.CWD,
374- Save: func(c telegram.Config) error {
375- return telegram.SaveConfig(ZotHome(), c)
376- },
377380 RefreshCreds: func() error {
378381 // Re-run the same resolver the tui uses so we pick up
379382 // refreshed oauth tokens, re-logins, and model switches.
@@ -385,12 +388,10 @@ func botRun(rawTail []string, version string) error {
385388 }
386389 agent.Client = next.NewClient()
387390 agent.Model = next.Model
388- b.Provider = next.Provider
389- b.AuthMethod = next.AuthMethod
390- b.CWD = next.CWD
391+ runner.UpdateRuntimeConfig(next.Provider, next.AuthMethod, next.CWD)
391392 return nil
392393 },
393- }
394+ })
394395
395396 // Record our pid so `zot telegram-bot status` / `zot telegram-bot stop` can find us,
396397 // regardless of whether we were started directly or via `bot start`.
@@ -407,7 +408,7 @@ func botRun(rawTail []string, version string) error {
407408 cancel()
408409 }()
409410 defer cancel()
410- return b.Run(ctx)
411+ return runner.Run(ctx)
411412 }
412413
413414 // openOrCreateSessionForBot reuses the same logic as interactive mode
@@ -15,6 +15,7 @@ import (
15 "syscall"15 "syscall"
16 "time"16 "time"
17 17
18+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
18 "github.com/patriceckhart/zot/packages/agent/modes/telegram"19 "github.com/patriceckhart/zot/packages/agent/modes/telegram"
19 "github.com/patriceckhart/zot/packages/core"20 "github.com/patriceckhart/zot/packages/core"
20 )21 )
@@ -362,18 +363,20 @@ func botRun(rawTail []string, version string) error {
362 }363 }
363 }364 }
364 365
365- var b *telegram.Bot366+ // Construct the Telegram adapter and generic runner.
366- b = &telegram.Bot{367+ adapter := telegram.NewAdapter(
367- Client: telegram.NewClient(cfg.BotToken),368+ telegram.NewClient(cfg.BotToken),
368- Agent: agent,369+ &cfg,
369- Config: cfg,370+ func(c telegram.Config) error {
371+ return telegram.SaveConfig(ZotHome(), c)
372+ },
373+ )
374+ var runner *bot.Runner
375+ runner = bot.NewRunner(adapter, agent, bot.Config{
370 ZotHome: ZotHome(),376 ZotHome: ZotHome(),
371 Provider: resolved.Provider,377 Provider: resolved.Provider,
372 AuthMethod: resolved.AuthMethod,378 AuthMethod: resolved.AuthMethod,
373 CWD: args.CWD,379 CWD: args.CWD,
374- Save: func(c telegram.Config) error {
375- return telegram.SaveConfig(ZotHome(), c)
376- },
377 RefreshCreds: func() error {380 RefreshCreds: func() error {
378 // Re-run the same resolver the tui uses so we pick up381 // Re-run the same resolver the tui uses so we pick up
379 // refreshed oauth tokens, re-logins, and model switches.382 // refreshed oauth tokens, re-logins, and model switches.
@@ -385,12 +388,10 @@ func botRun(rawTail []string, version string) error {
385 }388 }
386 agent.Client = next.NewClient()389 agent.Client = next.NewClient()
387 agent.Model = next.Model390 agent.Model = next.Model
388- b.Provider = next.Provider391+ runner.UpdateRuntimeConfig(next.Provider, next.AuthMethod, next.CWD)
389- b.AuthMethod = next.AuthMethod
390- b.CWD = next.CWD
391 return nil392 return nil
392 },393 },
393- }394+ })
394 395
395 // Record our pid so `zot telegram-bot status` / `zot telegram-bot stop` can find us,396 // Record our pid so `zot telegram-bot status` / `zot telegram-bot stop` can find us,
396 // regardless of whether we were started directly or via `bot start`.397 // regardless of whether we were started directly or via `bot start`.
@@ -407,7 +408,7 @@ func botRun(rawTail []string, version string) error {
407 cancel()408 cancel()
408 }()409 }()
409 defer cancel()410 defer cancel()
410- return b.Run(ctx)411+ return runner.Run(ctx)
411 }412 }
412 413
413 // openOrCreateSessionForBot reuses the same logic as interactive mode414 // openOrCreateSessionForBot reuses the same logic as interactive mode
A packages/agent/modes/bot/adapter.go +51 -0
@@ -0,0 +1,51 @@
1+// Package bot provides a protocol-agnostic runner for long-running bot
2+// modes. Concrete transports (Telegram, Discord, …) implement the
3+// BotAdapter interface; the Runner handles turn queueing, agent
4+// prompting, command dispatch, and credential refresh.
5+package bot
6+
7+import (
8+ "context"
9+
10+ "github.com/patriceckhart/zot/packages/provider"
11+)
12+
13+// InboundMessage is a protocol-normalised message from a user.
14+type InboundMessage struct {
15+ ChannelID string // opaque; adapter owns encoding (e.g. fmt.Sprintf("%d", chatID))
16+ MessageID string // optional reply anchor
17+ Text string
18+ Images []provider.ImageBlock
19+}
20+
21+// Command is a built-in bot command that bypasses the agent.
22+type Command int
23+
24+const (
25+ CmdStart Command = iota // first-time pairing / welcome
26+ CmdHelp // usage information
27+ CmdStatus // agent/provider state
28+ CmdStop // cancel the active turn
29+)
30+
31+// BotAdapter is the transport layer a concrete protocol must implement.
32+// The Runner calls these methods; it never touches protocol types directly.
33+type BotAdapter interface {
34+ // Run drives inbound polling; calls handler for normal messages and
35+ // commandHandler for built-in commands. Blocks until ctx is done.
36+ Run(ctx context.Context,
37+ handler func(InboundMessage),
38+ commandHandler func(Command, InboundMessage),
39+ ) error
40+
41+ // Send delivers a reply. The adapter chunks to protocol limits.
42+ Send(ctx context.Context, channelID, text string) error
43+
44+ // IndicateWorking fires a "typing…" signal; returns a stop func.
45+ // Return a no-op if the protocol doesn't support it.
46+ IndicateWorking(ctx context.Context, channelID string) (stop func())
47+
48+ // StatusText appends protocol-specific info to /status replies
49+ // (e.g. "@botname"). Return "" if there is nothing to add.
50+ StatusText() string
51+}
@@ -0,0 +1,51 @@
1+// Package bot provides a protocol-agnostic runner for long-running bot
2+// modes. Concrete transports (Telegram, Discord, …) implement the
3+// BotAdapter interface; the Runner handles turn queueing, agent
4+// prompting, command dispatch, and credential refresh.
5+package bot
6+
7+import (
8+ "context"
9+
10+ "github.com/patriceckhart/zot/packages/provider"
11+)
12+
13+// InboundMessage is a protocol-normalised message from a user.
14+type InboundMessage struct {
15+ ChannelID string // opaque; adapter owns encoding (e.g. fmt.Sprintf("%d", chatID))
16+ MessageID string // optional reply anchor
17+ Text string
18+ Images []provider.ImageBlock
19+}
20+
21+// Command is a built-in bot command that bypasses the agent.
22+type Command int
23+
24+const (
25+ CmdStart Command = iota // first-time pairing / welcome
26+ CmdHelp // usage information
27+ CmdStatus // agent/provider state
28+ CmdStop // cancel the active turn
29+)
30+
31+// BotAdapter is the transport layer a concrete protocol must implement.
32+// The Runner calls these methods; it never touches protocol types directly.
33+type BotAdapter interface {
34+ // Run drives inbound polling; calls handler for normal messages and
35+ // commandHandler for built-in commands. Blocks until ctx is done.
36+ Run(ctx context.Context,
37+ handler func(InboundMessage),
38+ commandHandler func(Command, InboundMessage),
39+ ) error
40+
41+ // Send delivers a reply. The adapter chunks to protocol limits.
42+ Send(ctx context.Context, channelID, text string) error
43+
44+ // IndicateWorking fires a "typing…" signal; returns a stop func.
45+ // Return a no-op if the protocol doesn't support it.
46+ IndicateWorking(ctx context.Context, channelID string) (stop func())
47+
48+ // StatusText appends protocol-specific info to /status replies
49+ // (e.g. "@botname"). Return "" if there is nothing to add.
50+ StatusText() string
51+}
A packages/agent/modes/bot/commands.go +11 -0
@@ -0,0 +1,11 @@
1+package bot
2+
3+import "strings"
4+
5+// IsStopCommand reports whether text should abort the active turn.
6+// Users often type plain "stop" rather than bot-style "/stop"; keep
7+// this intentionally narrow so normal prompts like "stop doing X"
8+// still go to the agent.
9+func IsStopCommand(text string) bool {
10+ return strings.EqualFold(strings.TrimSpace(text), "stop")
11+}
@@ -0,0 +1,11 @@
1+package bot
2+
3+import "strings"
4+
5+// IsStopCommand reports whether text should abort the active turn.
6+// Users often type plain "stop" rather than bot-style "/stop"; keep
7+// this intentionally narrow so normal prompts like "stop doing X"
8+// still go to the agent.
9+func IsStopCommand(text string) bool {
10+ return strings.EqualFold(strings.TrimSpace(text), "stop")
11+}
A packages/agent/modes/bot/runner.go +254 -0
@@ -0,0 +1,254 @@
1+package bot
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "os"
8+ "strings"
9+ "sync"
10+
11+ "github.com/patriceckhart/zot/packages/core"
12+ "github.com/patriceckhart/zot/packages/provider"
13+)
14+
15+// stderr is a tiny hook so tests can redirect bot logging.
16+var stderr = func() io.Writer { return os.Stderr }
17+
18+// Config holds runner-level settings that are protocol-independent.
19+type Config struct {
20+ ZotHome string
21+ Provider string
22+ AuthMethod string
23+ CWD string
24+ RefreshCreds func() error
25+}
26+
27+// queuedTurn is an inbound message waiting to become a prompt.
28+type queuedTurn struct {
29+ channelID string
30+ messageID string
31+ prompt string
32+ images []provider.ImageBlock
33+}
34+
35+// Runner is the protocol-agnostic bot engine. It owns the turn queue,
36+// dispatches prompts to the agent, and streams replies back through
37+// the BotAdapter.
38+type Runner struct {
39+ agent *core.Agent
40+ adapter BotAdapter
41+ cfg Config
42+
43+ mu sync.Mutex
44+ busy bool
45+ activeCtx context.CancelFunc
46+ queue []queuedTurn
47+ lastCtxInput int
48+ runCtx context.Context // set at Run entry; used by goroutines
49+}
50+
51+// NewRunner creates a Runner wired to the given adapter and agent.
52+func NewRunner(adapter BotAdapter, agent *core.Agent, cfg Config) *Runner {
53+ return &Runner{
54+ agent: agent,
55+ adapter: adapter,
56+ cfg: cfg,
57+ }
58+}
59+
60+// UpdateRuntimeConfig updates provider/auth/cwd at runtime (e.g. after
61+// credential refresh). This is thread-safe.
62+func (r *Runner) UpdateRuntimeConfig(provider, authMethod, cwd string) {
63+ r.mu.Lock()
64+ defer r.mu.Unlock()
65+ r.cfg.Provider = provider
66+ r.cfg.AuthMethod = authMethod
67+ r.cfg.CWD = cwd
68+}
69+
70+// Run starts the adapter's polling loop and blocks until ctx cancels.
71+func (r *Runner) Run(ctx context.Context) error {
72+ r.mu.Lock()
73+ r.runCtx = ctx
74+ r.mu.Unlock()
75+
76+ return r.adapter.Run(ctx, r.handleMessage, r.handleCommand)
77+}
78+
79+// handleMessage is called by the adapter for every normal inbound message.
80+func (r *Runner) handleMessage(msg InboundMessage) {
81+ if msg.Text == "" && len(msg.Images) == 0 {
82+ return
83+ }
84+
85+ r.mu.Lock()
86+ r.queue = append(r.queue, queuedTurn{
87+ channelID: msg.ChannelID,
88+ messageID: msg.MessageID,
89+ prompt: msg.Text,
90+ images: msg.Images,
91+ })
92+ idle := !r.busy
93+ r.mu.Unlock()
94+
95+ if idle {
96+ go r.drainQueue()
97+ }
98+}
99+
100+// handleCommand is called by the adapter for built-in commands.
101+func (r *Runner) handleCommand(cmd Command, msg InboundMessage) {
102+ switch cmd {
103+ case CmdStart, CmdHelp:
104+ _ = r.adapter.Send(context.Background(), msg.ChannelID,
105+ "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.")
106+ case CmdStatus:
107+ r.sendStatus(msg.ChannelID)
108+ case CmdStop:
109+ r.cancelActiveTurn(msg.ChannelID, msg.MessageID)
110+ }
111+}
112+
113+// drainQueue runs queued turns one at a time until the queue is empty.
114+func (r *Runner) drainQueue() {
115+ r.mu.Lock()
116+ parent := r.runCtx
117+ r.mu.Unlock()
118+
119+ for {
120+ r.mu.Lock()
121+ if len(r.queue) == 0 {
122+ r.busy = false
123+ r.activeCtx = nil
124+ r.mu.Unlock()
125+ return
126+ }
127+ t := r.queue[0]
128+ r.queue = r.queue[1:]
129+ r.busy = true
130+ turnCtx, cancel := context.WithCancel(parent)
131+ r.activeCtx = cancel
132+ r.mu.Unlock()
133+
134+ if r.cfg.RefreshCreds != nil {
135+ if err := r.cfg.RefreshCreds(); err != nil {
136+ fmt.Fprintln(stderr(), "bot: refresh creds:", err)
137+ }
138+ }
139+ r.runTurn(turnCtx, t)
140+ cancel()
141+ }
142+}
143+
144+// runTurn sends the queued prompt to the agent and streams the reply.
145+func (r *Runner) runTurn(ctx context.Context, t queuedTurn) {
146+ stopWorking := r.adapter.IndicateWorking(ctx, t.channelID)
147+ defer stopWorking()
148+
149+ var replyBuilder strings.Builder
150+ var lastAssistantText string
151+ var turnErr error
152+
153+ sink := func(ev core.AgentEvent) {
154+ switch e := ev.(type) {
155+ case core.EvTextDelta:
156+ replyBuilder.WriteString(e.Delta)
157+ case core.EvUsage:
158+ r.mu.Lock()
159+ if e.Usage.InputTokens > 0 {
160+ r.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
161+ }
162+ r.mu.Unlock()
163+ case core.EvAssistantMessage:
164+ var sb strings.Builder
165+ for _, c := range e.Message.Content {
166+ if tb, ok := c.(provider.TextBlock); ok {
167+ if sb.Len() > 0 {
168+ sb.WriteString("\n")
169+ }
170+ sb.WriteString(tb.Text)
171+ }
172+ }
173+ if sb.Len() > 0 {
174+ lastAssistantText = sb.String()
175+ }
176+ replyBuilder.Reset()
177+ case core.EvTurnEnd:
178+ if e.Err != nil {
179+ turnErr = e.Err
180+ }
181+ }
182+ }
183+
184+ if err := r.agent.Prompt(ctx, t.prompt, t.images, sink); err != nil {
185+ turnErr = err
186+ }
187+
188+ reply := strings.TrimSpace(lastAssistantText)
189+ if reply == "" {
190+ reply = strings.TrimSpace(replyBuilder.String())
191+ }
192+ if turnErr != nil && ctx.Err() == nil {
193+ reply = "error: " + turnErr.Error()
194+ }
195+ if reply == "" {
196+ reply = "(no reply)"
197+ }
198+
199+ // Adapter.Send is responsible for chunking to protocol limits.
200+ if err := r.adapter.Send(context.Background(), t.channelID, reply); err != nil {
201+ fmt.Fprintln(stderr(), "bot: send reply:", err)
202+ }
203+}
204+
205+// cancelActiveTurn aborts the currently running turn, if any.
206+func (r *Runner) cancelActiveTurn(channelID, messageID string) {
207+ r.mu.Lock()
208+ cancel := r.activeCtx
209+ r.mu.Unlock()
210+ if cancel != nil {
211+ cancel()
212+ _ = r.adapter.Send(context.Background(), channelID, "cancelled the current turn.")
213+ } else {
214+ _ = r.adapter.Send(context.Background(), channelID, "nothing running.")
215+ }
216+}
217+
218+// sendStatus describes agent state to the user.
219+func (r *Runner) sendStatus(channelID string) {
220+ r.mu.Lock()
221+ busy := r.busy
222+ queued := len(r.queue)
223+ ctxUsed := r.lastCtxInput
224+ providerName := r.cfg.Provider
225+ authMethod := r.cfg.AuthMethod
226+ cwd := r.cfg.CWD
227+ r.mu.Unlock()
228+
229+ model := r.agent.Model
230+ ctxMax := 0
231+ if m, err := provider.FindModel(providerName, model); err == nil {
232+ ctxMax = m.ContextWindow
233+ }
234+
235+ status := FormatStatus(StatusSnapshot{
236+ Provider: providerName,
237+ Model: model,
238+ CWD: cwd,
239+ Usage: r.agent.Cost(),
240+ Subscription: authMethod == "oauth",
241+ ContextUsed: ctxUsed,
242+ ContextMax: ctxMax,
243+ Busy: busy,
244+ Queued: queued,
245+ })
246+
247+ if extra := r.adapter.StatusText(); extra != "" {
248+ status += "\n" + extra
249+ }
250+
251+ _ = r.adapter.Send(context.Background(), channelID, status)
252+}
253+
254+
@@ -0,0 +1,254 @@
1+package bot
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "os"
8+ "strings"
9+ "sync"
10+
11+ "github.com/patriceckhart/zot/packages/core"
12+ "github.com/patriceckhart/zot/packages/provider"
13+)
14+
15+// stderr is a tiny hook so tests can redirect bot logging.
16+var stderr = func() io.Writer { return os.Stderr }
17+
18+// Config holds runner-level settings that are protocol-independent.
19+type Config struct {
20+ ZotHome string
21+ Provider string
22+ AuthMethod string
23+ CWD string
24+ RefreshCreds func() error
25+}
26+
27+// queuedTurn is an inbound message waiting to become a prompt.
28+type queuedTurn struct {
29+ channelID string
30+ messageID string
31+ prompt string
32+ images []provider.ImageBlock
33+}
34+
35+// Runner is the protocol-agnostic bot engine. It owns the turn queue,
36+// dispatches prompts to the agent, and streams replies back through
37+// the BotAdapter.
38+type Runner struct {
39+ agent *core.Agent
40+ adapter BotAdapter
41+ cfg Config
42+
43+ mu sync.Mutex
44+ busy bool
45+ activeCtx context.CancelFunc
46+ queue []queuedTurn
47+ lastCtxInput int
48+ runCtx context.Context // set at Run entry; used by goroutines
49+}
50+
51+// NewRunner creates a Runner wired to the given adapter and agent.
52+func NewRunner(adapter BotAdapter, agent *core.Agent, cfg Config) *Runner {
53+ return &Runner{
54+ agent: agent,
55+ adapter: adapter,
56+ cfg: cfg,
57+ }
58+}
59+
60+// UpdateRuntimeConfig updates provider/auth/cwd at runtime (e.g. after
61+// credential refresh). This is thread-safe.
62+func (r *Runner) UpdateRuntimeConfig(provider, authMethod, cwd string) {
63+ r.mu.Lock()
64+ defer r.mu.Unlock()
65+ r.cfg.Provider = provider
66+ r.cfg.AuthMethod = authMethod
67+ r.cfg.CWD = cwd
68+}
69+
70+// Run starts the adapter's polling loop and blocks until ctx cancels.
71+func (r *Runner) Run(ctx context.Context) error {
72+ r.mu.Lock()
73+ r.runCtx = ctx
74+ r.mu.Unlock()
75+
76+ return r.adapter.Run(ctx, r.handleMessage, r.handleCommand)
77+}
78+
79+// handleMessage is called by the adapter for every normal inbound message.
80+func (r *Runner) handleMessage(msg InboundMessage) {
81+ if msg.Text == "" && len(msg.Images) == 0 {
82+ return
83+ }
84+
85+ r.mu.Lock()
86+ r.queue = append(r.queue, queuedTurn{
87+ channelID: msg.ChannelID,
88+ messageID: msg.MessageID,
89+ prompt: msg.Text,
90+ images: msg.Images,
91+ })
92+ idle := !r.busy
93+ r.mu.Unlock()
94+
95+ if idle {
96+ go r.drainQueue()
97+ }
98+}
99+
100+// handleCommand is called by the adapter for built-in commands.
101+func (r *Runner) handleCommand(cmd Command, msg InboundMessage) {
102+ switch cmd {
103+ case CmdStart, CmdHelp:
104+ _ = r.adapter.Send(context.Background(), msg.ChannelID,
105+ "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.")
106+ case CmdStatus:
107+ r.sendStatus(msg.ChannelID)
108+ case CmdStop:
109+ r.cancelActiveTurn(msg.ChannelID, msg.MessageID)
110+ }
111+}
112+
113+// drainQueue runs queued turns one at a time until the queue is empty.
114+func (r *Runner) drainQueue() {
115+ r.mu.Lock()
116+ parent := r.runCtx
117+ r.mu.Unlock()
118+
119+ for {
120+ r.mu.Lock()
121+ if len(r.queue) == 0 {
122+ r.busy = false
123+ r.activeCtx = nil
124+ r.mu.Unlock()
125+ return
126+ }
127+ t := r.queue[0]
128+ r.queue = r.queue[1:]
129+ r.busy = true
130+ turnCtx, cancel := context.WithCancel(parent)
131+ r.activeCtx = cancel
132+ r.mu.Unlock()
133+
134+ if r.cfg.RefreshCreds != nil {
135+ if err := r.cfg.RefreshCreds(); err != nil {
136+ fmt.Fprintln(stderr(), "bot: refresh creds:", err)
137+ }
138+ }
139+ r.runTurn(turnCtx, t)
140+ cancel()
141+ }
142+}
143+
144+// runTurn sends the queued prompt to the agent and streams the reply.
145+func (r *Runner) runTurn(ctx context.Context, t queuedTurn) {
146+ stopWorking := r.adapter.IndicateWorking(ctx, t.channelID)
147+ defer stopWorking()
148+
149+ var replyBuilder strings.Builder
150+ var lastAssistantText string
151+ var turnErr error
152+
153+ sink := func(ev core.AgentEvent) {
154+ switch e := ev.(type) {
155+ case core.EvTextDelta:
156+ replyBuilder.WriteString(e.Delta)
157+ case core.EvUsage:
158+ r.mu.Lock()
159+ if e.Usage.InputTokens > 0 {
160+ r.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
161+ }
162+ r.mu.Unlock()
163+ case core.EvAssistantMessage:
164+ var sb strings.Builder
165+ for _, c := range e.Message.Content {
166+ if tb, ok := c.(provider.TextBlock); ok {
167+ if sb.Len() > 0 {
168+ sb.WriteString("\n")
169+ }
170+ sb.WriteString(tb.Text)
171+ }
172+ }
173+ if sb.Len() > 0 {
174+ lastAssistantText = sb.String()
175+ }
176+ replyBuilder.Reset()
177+ case core.EvTurnEnd:
178+ if e.Err != nil {
179+ turnErr = e.Err
180+ }
181+ }
182+ }
183+
184+ if err := r.agent.Prompt(ctx, t.prompt, t.images, sink); err != nil {
185+ turnErr = err
186+ }
187+
188+ reply := strings.TrimSpace(lastAssistantText)
189+ if reply == "" {
190+ reply = strings.TrimSpace(replyBuilder.String())
191+ }
192+ if turnErr != nil && ctx.Err() == nil {
193+ reply = "error: " + turnErr.Error()
194+ }
195+ if reply == "" {
196+ reply = "(no reply)"
197+ }
198+
199+ // Adapter.Send is responsible for chunking to protocol limits.
200+ if err := r.adapter.Send(context.Background(), t.channelID, reply); err != nil {
201+ fmt.Fprintln(stderr(), "bot: send reply:", err)
202+ }
203+}
204+
205+// cancelActiveTurn aborts the currently running turn, if any.
206+func (r *Runner) cancelActiveTurn(channelID, messageID string) {
207+ r.mu.Lock()
208+ cancel := r.activeCtx
209+ r.mu.Unlock()
210+ if cancel != nil {
211+ cancel()
212+ _ = r.adapter.Send(context.Background(), channelID, "cancelled the current turn.")
213+ } else {
214+ _ = r.adapter.Send(context.Background(), channelID, "nothing running.")
215+ }
216+}
217+
218+// sendStatus describes agent state to the user.
219+func (r *Runner) sendStatus(channelID string) {
220+ r.mu.Lock()
221+ busy := r.busy
222+ queued := len(r.queue)
223+ ctxUsed := r.lastCtxInput
224+ providerName := r.cfg.Provider
225+ authMethod := r.cfg.AuthMethod
226+ cwd := r.cfg.CWD
227+ r.mu.Unlock()
228+
229+ model := r.agent.Model
230+ ctxMax := 0
231+ if m, err := provider.FindModel(providerName, model); err == nil {
232+ ctxMax = m.ContextWindow
233+ }
234+
235+ status := FormatStatus(StatusSnapshot{
236+ Provider: providerName,
237+ Model: model,
238+ CWD: cwd,
239+ Usage: r.agent.Cost(),
240+ Subscription: authMethod == "oauth",
241+ ContextUsed: ctxUsed,
242+ ContextMax: ctxMax,
243+ Busy: busy,
244+ Queued: queued,
245+ })
246+
247+ if extra := r.adapter.StatusText(); extra != "" {
248+ status += "\n" + extra
249+ }
250+
251+ _ = r.adapter.Send(context.Background(), channelID, status)
252+}
253+
254+
A packages/agent/modes/bot/status.go +120 -0
@@ -0,0 +1,120 @@
1+package bot
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strings"
7+
8+ "github.com/patriceckhart/zot/packages/provider"
9+)
10+
11+// StatusSnapshot is the small cross-host state bundle rendered for
12+// /status replies.
13+type StatusSnapshot struct {
14+ Provider string
15+ Model string
16+ CWD string
17+ Usage provider.Usage
18+ Subscription bool
19+ ContextUsed int
20+ ContextMax int
21+ Busy bool
22+ Queued int
23+}
24+
25+// FormatStatus renders the same compact model/usage/cost/context
26+// information shown in the TUI status bar, plus the current directory.
27+func FormatStatus(s StatusSnapshot) string {
28+ providerName := strings.TrimSpace(s.Provider)
29+ model := strings.TrimSpace(s.Model)
30+ if providerName == "" {
31+ providerName = "unknown"
32+ }
33+ if model == "" {
34+ model = "unknown"
35+ }
36+
37+ var stats []string
38+ if s.Usage.InputTokens > 0 {
39+ stats = append(stats, fmt.Sprintf("↑%s", formatTokens(s.Usage.InputTokens)))
40+ }
41+ if s.Usage.OutputTokens > 0 {
42+ stats = append(stats, fmt.Sprintf("↓%s", formatTokens(s.Usage.OutputTokens)))
43+ }
44+ if s.Usage.CacheReadTokens > 0 {
45+ stats = append(stats, fmt.Sprintf("R%s", formatTokens(s.Usage.CacheReadTokens)))
46+ }
47+ if s.Usage.CacheWriteTokens > 0 {
48+ stats = append(stats, fmt.Sprintf("W%s", formatTokens(s.Usage.CacheWriteTokens)))
49+ }
50+ if s.Usage.CostUSD > 0 || s.Subscription {
51+ cost := fmt.Sprintf("$%.3f", s.Usage.CostUSD)
52+ if s.Subscription {
53+ cost += " (sub)"
54+ }
55+ stats = append(stats, cost)
56+ }
57+ if ctx := contextUsage(s.ContextUsed, s.ContextMax); ctx != "" {
58+ stats = append(stats, ctx)
59+ }
60+
61+ line := fmt.Sprintf("(%s) %s", providerName, model)
62+ if len(stats) > 0 {
63+ line += " " + strings.Join(stats, " ")
64+ }
65+
66+ state := "idle"
67+ if s.Busy {
68+ state = "working"
69+ }
70+ lines := []string{line, "state: " + state}
71+ if s.Queued > 0 {
72+ lines = append(lines, fmt.Sprintf("queued: %d", s.Queued))
73+ }
74+ if cwd := shortenHome(strings.TrimSpace(s.CWD)); cwd != "" {
75+ lines = append(lines, "cwd: "+cwd)
76+ }
77+ return strings.Join(lines, "\n")
78+}
79+
80+func contextUsage(used, max int) string {
81+ if max <= 0 {
82+ if used <= 0 {
83+ return ""
84+ }
85+ return formatTokens(used)
86+ }
87+ pct := float64(used) / float64(max) * 100
88+ return fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max))
89+}
90+
91+func formatTokens(n int) string {
92+ switch {
93+ case n < 0:
94+ return "0"
95+ case n < 1000:
96+ return fmt.Sprintf("%d", n)
97+ case n < 10000:
98+ return fmt.Sprintf("%.1fk", float64(n)/1000)
99+ case n < 1_000_000:
100+ return fmt.Sprintf("%dk", (n+500)/1000)
101+ case n < 10_000_000:
102+ return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
103+ default:
104+ return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
105+ }
106+}
107+
108+func shortenHome(path string) string {
109+ home, err := os.UserHomeDir()
110+ if err != nil || home == "" {
111+ return path
112+ }
113+ if path == home {
114+ return "~"
115+ }
116+ if strings.HasPrefix(path, home+string(os.PathSeparator)) {
117+ return "~" + strings.TrimPrefix(path, home)
118+ }
119+ return path
120+}
@@ -0,0 +1,120 @@
1+package bot
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strings"
7+
8+ "github.com/patriceckhart/zot/packages/provider"
9+)
10+
11+// StatusSnapshot is the small cross-host state bundle rendered for
12+// /status replies.
13+type StatusSnapshot struct {
14+ Provider string
15+ Model string
16+ CWD string
17+ Usage provider.Usage
18+ Subscription bool
19+ ContextUsed int
20+ ContextMax int
21+ Busy bool
22+ Queued int
23+}
24+
25+// FormatStatus renders the same compact model/usage/cost/context
26+// information shown in the TUI status bar, plus the current directory.
27+func FormatStatus(s StatusSnapshot) string {
28+ providerName := strings.TrimSpace(s.Provider)
29+ model := strings.TrimSpace(s.Model)
30+ if providerName == "" {
31+ providerName = "unknown"
32+ }
33+ if model == "" {
34+ model = "unknown"
35+ }
36+
37+ var stats []string
38+ if s.Usage.InputTokens > 0 {
39+ stats = append(stats, fmt.Sprintf("↑%s", formatTokens(s.Usage.InputTokens)))
40+ }
41+ if s.Usage.OutputTokens > 0 {
42+ stats = append(stats, fmt.Sprintf("↓%s", formatTokens(s.Usage.OutputTokens)))
43+ }
44+ if s.Usage.CacheReadTokens > 0 {
45+ stats = append(stats, fmt.Sprintf("R%s", formatTokens(s.Usage.CacheReadTokens)))
46+ }
47+ if s.Usage.CacheWriteTokens > 0 {
48+ stats = append(stats, fmt.Sprintf("W%s", formatTokens(s.Usage.CacheWriteTokens)))
49+ }
50+ if s.Usage.CostUSD > 0 || s.Subscription {
51+ cost := fmt.Sprintf("$%.3f", s.Usage.CostUSD)
52+ if s.Subscription {
53+ cost += " (sub)"
54+ }
55+ stats = append(stats, cost)
56+ }
57+ if ctx := contextUsage(s.ContextUsed, s.ContextMax); ctx != "" {
58+ stats = append(stats, ctx)
59+ }
60+
61+ line := fmt.Sprintf("(%s) %s", providerName, model)
62+ if len(stats) > 0 {
63+ line += " " + strings.Join(stats, " ")
64+ }
65+
66+ state := "idle"
67+ if s.Busy {
68+ state = "working"
69+ }
70+ lines := []string{line, "state: " + state}
71+ if s.Queued > 0 {
72+ lines = append(lines, fmt.Sprintf("queued: %d", s.Queued))
73+ }
74+ if cwd := shortenHome(strings.TrimSpace(s.CWD)); cwd != "" {
75+ lines = append(lines, "cwd: "+cwd)
76+ }
77+ return strings.Join(lines, "\n")
78+}
79+
80+func contextUsage(used, max int) string {
81+ if max <= 0 {
82+ if used <= 0 {
83+ return ""
84+ }
85+ return formatTokens(used)
86+ }
87+ pct := float64(used) / float64(max) * 100
88+ return fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max))
89+}
90+
91+func formatTokens(n int) string {
92+ switch {
93+ case n < 0:
94+ return "0"
95+ case n < 1000:
96+ return fmt.Sprintf("%d", n)
97+ case n < 10000:
98+ return fmt.Sprintf("%.1fk", float64(n)/1000)
99+ case n < 1_000_000:
100+ return fmt.Sprintf("%dk", (n+500)/1000)
101+ case n < 10_000_000:
102+ return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
103+ default:
104+ return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
105+ }
106+}
107+
108+func shortenHome(path string) string {
109+ home, err := os.UserHomeDir()
110+ if err != nil || home == "" {
111+ return path
112+ }
113+ if path == home {
114+ return "~"
115+ }
116+ if strings.HasPrefix(path, home+string(os.PathSeparator)) {
117+ return "~" + strings.TrimPrefix(path, home)
118+ }
119+ return path
120+}
A packages/agent/modes/telegram/adapter.go +250 -0
@@ -0,0 +1,250 @@
1+package telegram
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+// Adapter implements bot.BotAdapter for Telegram.
15+type Adapter struct {
16+ Client *Client
17+ Cfg *Config // pointer so Run can mutate and persist
18+ Save func(Config) error
19+}
20+
21+// NewAdapter creates a Telegram adapter.
22+func NewAdapter(client *Client, cfg *Config, save func(Config) error) *Adapter {
23+ return &Adapter{Client: client, Cfg: cfg, Save: save}
24+}
25+
26+// Run drives the Telegram long-polling loop. It performs initial
27+// GetMe, handles pairing, and dispatches inbound messages to the
28+// generic handler / commandHandler callbacks.
29+func (a *Adapter) Run(ctx context.Context,
30+ handler func(bot.InboundMessage),
31+ commandHandler func(bot.Command, bot.InboundMessage),
32+) error {
33+ if a.Cfg.BotToken == "" {
34+ return fmt.Errorf("no bot token configured; run `zot bot setup` first")
35+ }
36+ me, err := a.Client.GetMe(ctx)
37+ if err != nil {
38+ return fmt.Errorf("getMe: %w", err)
39+ }
40+ // Keep the stored username/id in sync with the actual bot.
41+ if a.Cfg.BotID != me.ID || a.Cfg.BotUsername != me.Username {
42+ a.Cfg.BotID = me.ID
43+ a.Cfg.BotUsername = me.Username
44+ _ = a.Save(*a.Cfg)
45+ }
46+
47+ fmt.Printf("telegram bridge online as @%s (id=%d)\n", me.Username, me.ID)
48+ if a.Cfg.AllowedUserID == 0 {
49+ fmt.Println("no user paired yet — send /start to the bot from Telegram to claim it")
50+ } else {
51+ fmt.Printf("paired with telegram user id %d\n", a.Cfg.AllowedUserID)
52+ }
53+
54+ return a.pollLoop(ctx, handler, commandHandler)
55+}
56+
57+// pollLoop long-polls Telegram for updates and dispatches them.
58+func (a *Adapter) pollLoop(ctx context.Context,
59+ handler func(bot.InboundMessage),
60+ commandHandler func(bot.Command, bot.InboundMessage),
61+) error {
62+ backoff := time.Second
63+ for {
64+ if err := ctx.Err(); err != nil {
65+ return err
66+ }
67+ updates, err := a.Client.GetUpdates(ctx, a.Cfg.LastUpdateID+1, 30)
68+ if err != nil {
69+ if ctx.Err() != nil {
70+ return ctx.Err()
71+ }
72+ fmt.Fprintln(stderr(), "telegram: getUpdates error:", err)
73+ select {
74+ case <-ctx.Done():
75+ return ctx.Err()
76+ case <-time.After(backoff):
77+ }
78+ if backoff < 30*time.Second {
79+ backoff *= 2
80+ }
81+ continue
82+ }
83+ backoff = time.Second
84+ for _, u := range updates {
85+ a.handleUpdate(ctx, u, handler, commandHandler)
86+ a.Cfg.LastUpdateID = u.UpdateID
87+ _ = a.Save(*a.Cfg)
88+ }
89+ }
90+}
91+
92+// handleUpdate processes a single Telegram update. Telegram-specific
93+// concerns (pairing, user filtering, image download) live here; the
94+// generic callbacks are called for normal messages and commands.
95+func (a *Adapter) handleUpdate(ctx context.Context, u Update,
96+ handler func(bot.InboundMessage),
97+ commandHandler func(bot.Command, bot.InboundMessage),
98+) {
99+ msg := u.Message
100+ if msg == nil {
101+ msg = u.Edited
102+ }
103+ if msg == nil || msg.From == nil || msg.From.IsBot {
104+ return
105+ }
106+ if msg.Chat.Type != "private" {
107+ return
108+ }
109+
110+ chanID := fmt.Sprintf("%d", msg.Chat.ID)
111+ msgID := fmt.Sprintf("%d", msg.MessageID)
112+
113+ // Pairing: first user who sends /start claims the bridge.
114+ text := strings.TrimSpace(msg.Text)
115+ if a.Cfg.AllowedUserID == 0 {
116+ if strings.HasPrefix(text, "/start") {
117+ a.Cfg.AllowedUserID = msg.From.ID
118+ _ = a.Save(*a.Cfg)
119+ _ = a.Client.SendMessage(ctx, msg.Chat.ID,
120+ fmt.Sprintf("paired with @%s. send any message and i'll forward it to zot.", msg.From.Username),
121+ msg.MessageID)
122+ return
123+ }
124+ _ = a.Client.SendMessage(ctx, msg.Chat.ID,
125+ "this bot isn't paired yet. send /start to claim it.",
126+ msg.MessageID)
127+ return
128+ }
129+
130+ // Enforce allowed user.
131+ if msg.From.ID != a.Cfg.AllowedUserID {
132+ _ = a.Client.SendMessage(ctx, msg.Chat.ID,
133+ "this bot is paired with a different user.",
134+ msg.MessageID)
135+ return
136+ }
137+
138+ inbound := bot.InboundMessage{
139+ ChannelID: chanID,
140+ MessageID: msgID,
141+ }
142+
143+ // Built-in commands that bypass the agent.
144+ switch text {
145+ case "/start":
146+ commandHandler(bot.CmdStart, inbound)
147+ return
148+ case "/help":
149+ commandHandler(bot.CmdHelp, inbound)
150+ return
151+ case "/status":
152+ commandHandler(bot.CmdStatus, inbound)
153+ return
154+ case "/stop":
155+ commandHandler(bot.CmdStop, inbound)
156+ return
157+ }
158+ if bot.IsStopCommand(text) {
159+ commandHandler(bot.CmdStop, inbound)
160+ return
161+ }
162+
163+ // Build the prompt: combine text + caption; download image attachments.
164+ prompt := strings.TrimSpace(msg.Text)
165+ if msg.Caption != "" {
166+ if prompt != "" {
167+ prompt += "\n"
168+ }
169+ prompt += msg.Caption
170+ }
171+
172+ var images []provider.ImageBlock
173+ if len(msg.Photo) > 0 {
174+ largest := msg.Photo[len(msg.Photo)-1]
175+ if data, mime, err := a.download(ctx, largest.FileID, ""); err == nil {
176+ images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
177+ } else {
178+ fmt.Fprintln(stderr(), "telegram: download photo:", err)
179+ }
180+ }
181+ if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
182+ if data, mime, err := a.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
183+ images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
184+ }
185+ }
186+
187+ inbound.Text = prompt
188+ inbound.Images = images
189+ handler(inbound)
190+}
191+
192+// Send delivers a reply to a Telegram chat. channelID is parsed back
193+// to int64. Messages are chunked to 4000 runes (Telegram limit 4096).
194+func (a *Adapter) Send(ctx context.Context, channelID, text string) error {
195+ chatID, err := strconv.ParseInt(channelID, 10, 64)
196+ if err != nil {
197+ return fmt.Errorf("invalid channelID %q: %w", channelID, err)
198+ }
199+ for _, chunk := range chunkMessage(text, 4000) {
200+ if err := a.Client.SendMessage(ctx, chatID, chunk, 0); err != nil {
201+ return err
202+ }
203+ }
204+ return nil
205+}
206+
207+// IndicateWorking keeps Telegram's "typing..." indicator alive until
208+// the returned stop function is called.
209+func (a *Adapter) IndicateWorking(ctx context.Context, channelID string) (stop func()) {
210+ chatID, err := strconv.ParseInt(channelID, 10, 64)
211+ if err != nil {
212+ return func() {}
213+ }
214+ tctx, cancel := context.WithCancel(ctx)
215+ go func() {
216+ for {
217+ _ = a.Client.SendChatAction(tctx, chatID, "typing")
218+ select {
219+ case <-tctx.Done():
220+ return
221+ case <-time.After(4 * time.Second):
222+ }
223+ }
224+ }()
225+ return cancel
226+}
227+
228+// StatusText returns the bot's @username for inclusion in /status.
229+func (a *Adapter) StatusText() string {
230+ if a.Cfg.BotUsername != "" {
231+ return "@" + a.Cfg.BotUsername
232+ }
233+ return ""
234+}
235+
236+// download fetches a file from Telegram and returns bytes + mime.
237+func (a *Adapter) download(ctx context.Context, fileID, mime string) ([]byte, string, error) {
238+ f, err := a.Client.GetFile(ctx, fileID)
239+ if err != nil {
240+ return nil, "", err
241+ }
242+ data, err := a.Client.DownloadFile(ctx, f.FilePath)
243+ if err != nil {
244+ return nil, "", err
245+ }
246+ if mime == "" {
247+ mime = guessImageMIME(f.FilePath)
248+ }
249+ return data, mime, nil
250+}
@@ -0,0 +1,250 @@
1+package telegram
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+// Adapter implements bot.BotAdapter for Telegram.
15+type Adapter struct {
16+ Client *Client
17+ Cfg *Config // pointer so Run can mutate and persist
18+ Save func(Config) error
19+}
20+
21+// NewAdapter creates a Telegram adapter.
22+func NewAdapter(client *Client, cfg *Config, save func(Config) error) *Adapter {
23+ return &Adapter{Client: client, Cfg: cfg, Save: save}
24+}
25+
26+// Run drives the Telegram long-polling loop. It performs initial
27+// GetMe, handles pairing, and dispatches inbound messages to the
28+// generic handler / commandHandler callbacks.
29+func (a *Adapter) Run(ctx context.Context,
30+ handler func(bot.InboundMessage),
31+ commandHandler func(bot.Command, bot.InboundMessage),
32+) error {
33+ if a.Cfg.BotToken == "" {
34+ return fmt.Errorf("no bot token configured; run `zot bot setup` first")
35+ }
36+ me, err := a.Client.GetMe(ctx)
37+ if err != nil {
38+ return fmt.Errorf("getMe: %w", err)
39+ }
40+ // Keep the stored username/id in sync with the actual bot.
41+ if a.Cfg.BotID != me.ID || a.Cfg.BotUsername != me.Username {
42+ a.Cfg.BotID = me.ID
43+ a.Cfg.BotUsername = me.Username
44+ _ = a.Save(*a.Cfg)
45+ }
46+
47+ fmt.Printf("telegram bridge online as @%s (id=%d)\n", me.Username, me.ID)
48+ if a.Cfg.AllowedUserID == 0 {
49+ fmt.Println("no user paired yet — send /start to the bot from Telegram to claim it")
50+ } else {
51+ fmt.Printf("paired with telegram user id %d\n", a.Cfg.AllowedUserID)
52+ }
53+
54+ return a.pollLoop(ctx, handler, commandHandler)
55+}
56+
57+// pollLoop long-polls Telegram for updates and dispatches them.
58+func (a *Adapter) pollLoop(ctx context.Context,
59+ handler func(bot.InboundMessage),
60+ commandHandler func(bot.Command, bot.InboundMessage),
61+) error {
62+ backoff := time.Second
63+ for {
64+ if err := ctx.Err(); err != nil {
65+ return err
66+ }
67+ updates, err := a.Client.GetUpdates(ctx, a.Cfg.LastUpdateID+1, 30)
68+ if err != nil {
69+ if ctx.Err() != nil {
70+ return ctx.Err()
71+ }
72+ fmt.Fprintln(stderr(), "telegram: getUpdates error:", err)
73+ select {
74+ case <-ctx.Done():
75+ return ctx.Err()
76+ case <-time.After(backoff):
77+ }
78+ if backoff < 30*time.Second {
79+ backoff *= 2
80+ }
81+ continue
82+ }
83+ backoff = time.Second
84+ for _, u := range updates {
85+ a.handleUpdate(ctx, u, handler, commandHandler)
86+ a.Cfg.LastUpdateID = u.UpdateID
87+ _ = a.Save(*a.Cfg)
88+ }
89+ }
90+}
91+
92+// handleUpdate processes a single Telegram update. Telegram-specific
93+// concerns (pairing, user filtering, image download) live here; the
94+// generic callbacks are called for normal messages and commands.
95+func (a *Adapter) handleUpdate(ctx context.Context, u Update,
96+ handler func(bot.InboundMessage),
97+ commandHandler func(bot.Command, bot.InboundMessage),
98+) {
99+ msg := u.Message
100+ if msg == nil {
101+ msg = u.Edited
102+ }
103+ if msg == nil || msg.From == nil || msg.From.IsBot {
104+ return
105+ }
106+ if msg.Chat.Type != "private" {
107+ return
108+ }
109+
110+ chanID := fmt.Sprintf("%d", msg.Chat.ID)
111+ msgID := fmt.Sprintf("%d", msg.MessageID)
112+
113+ // Pairing: first user who sends /start claims the bridge.
114+ text := strings.TrimSpace(msg.Text)
115+ if a.Cfg.AllowedUserID == 0 {
116+ if strings.HasPrefix(text, "/start") {
117+ a.Cfg.AllowedUserID = msg.From.ID
118+ _ = a.Save(*a.Cfg)
119+ _ = a.Client.SendMessage(ctx, msg.Chat.ID,
120+ fmt.Sprintf("paired with @%s. send any message and i'll forward it to zot.", msg.From.Username),
121+ msg.MessageID)
122+ return
123+ }
124+ _ = a.Client.SendMessage(ctx, msg.Chat.ID,
125+ "this bot isn't paired yet. send /start to claim it.",
126+ msg.MessageID)
127+ return
128+ }
129+
130+ // Enforce allowed user.
131+ if msg.From.ID != a.Cfg.AllowedUserID {
132+ _ = a.Client.SendMessage(ctx, msg.Chat.ID,
133+ "this bot is paired with a different user.",
134+ msg.MessageID)
135+ return
136+ }
137+
138+ inbound := bot.InboundMessage{
139+ ChannelID: chanID,
140+ MessageID: msgID,
141+ }
142+
143+ // Built-in commands that bypass the agent.
144+ switch text {
145+ case "/start":
146+ commandHandler(bot.CmdStart, inbound)
147+ return
148+ case "/help":
149+ commandHandler(bot.CmdHelp, inbound)
150+ return
151+ case "/status":
152+ commandHandler(bot.CmdStatus, inbound)
153+ return
154+ case "/stop":
155+ commandHandler(bot.CmdStop, inbound)
156+ return
157+ }
158+ if bot.IsStopCommand(text) {
159+ commandHandler(bot.CmdStop, inbound)
160+ return
161+ }
162+
163+ // Build the prompt: combine text + caption; download image attachments.
164+ prompt := strings.TrimSpace(msg.Text)
165+ if msg.Caption != "" {
166+ if prompt != "" {
167+ prompt += "\n"
168+ }
169+ prompt += msg.Caption
170+ }
171+
172+ var images []provider.ImageBlock
173+ if len(msg.Photo) > 0 {
174+ largest := msg.Photo[len(msg.Photo)-1]
175+ if data, mime, err := a.download(ctx, largest.FileID, ""); err == nil {
176+ images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
177+ } else {
178+ fmt.Fprintln(stderr(), "telegram: download photo:", err)
179+ }
180+ }
181+ if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
182+ if data, mime, err := a.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
183+ images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
184+ }
185+ }
186+
187+ inbound.Text = prompt
188+ inbound.Images = images
189+ handler(inbound)
190+}
191+
192+// Send delivers a reply to a Telegram chat. channelID is parsed back
193+// to int64. Messages are chunked to 4000 runes (Telegram limit 4096).
194+func (a *Adapter) Send(ctx context.Context, channelID, text string) error {
195+ chatID, err := strconv.ParseInt(channelID, 10, 64)
196+ if err != nil {
197+ return fmt.Errorf("invalid channelID %q: %w", channelID, err)
198+ }
199+ for _, chunk := range chunkMessage(text, 4000) {
200+ if err := a.Client.SendMessage(ctx, chatID, chunk, 0); err != nil {
201+ return err
202+ }
203+ }
204+ return nil
205+}
206+
207+// IndicateWorking keeps Telegram's "typing..." indicator alive until
208+// the returned stop function is called.
209+func (a *Adapter) IndicateWorking(ctx context.Context, channelID string) (stop func()) {
210+ chatID, err := strconv.ParseInt(channelID, 10, 64)
211+ if err != nil {
212+ return func() {}
213+ }
214+ tctx, cancel := context.WithCancel(ctx)
215+ go func() {
216+ for {
217+ _ = a.Client.SendChatAction(tctx, chatID, "typing")
218+ select {
219+ case <-tctx.Done():
220+ return
221+ case <-time.After(4 * time.Second):
222+ }
223+ }
224+ }()
225+ return cancel
226+}
227+
228+// StatusText returns the bot's @username for inclusion in /status.
229+func (a *Adapter) StatusText() string {
230+ if a.Cfg.BotUsername != "" {
231+ return "@" + a.Cfg.BotUsername
232+ }
233+ return ""
234+}
235+
236+// download fetches a file from Telegram and returns bytes + mime.
237+func (a *Adapter) download(ctx context.Context, fileID, mime string) ([]byte, string, error) {
238+ f, err := a.Client.GetFile(ctx, fileID)
239+ if err != nil {
240+ return nil, "", err
241+ }
242+ data, err := a.Client.DownloadFile(ctx, f.FilePath)
243+ if err != nil {
244+ return nil, "", err
245+ }
246+ if mime == "" {
247+ mime = guessImageMIME(f.FilePath)
248+ }
249+ return data, mime, nil
250+}
M packages/agent/modes/telegram/bot.go +24 -349
@@ -2,18 +2,17 @@ package telegram
22
33 import (
44 "context"
5- "fmt"
65 "strings"
7- "sync"
8- "time"
96
7+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
108 "github.com/patriceckhart/zot/packages/core"
11- "github.com/patriceckhart/zot/packages/provider"
129 )
1310
14-// Bot owns the Telegram polling loop and dispatches inbound DMs to
15-// the agent. It is a long-running goroutine; Run blocks until ctx
16-// cancels.
11+// Bot is a thin wrapper around bot.Runner + Adapter. It exists to
12+// keep the exported API stable for botcmd.go.
13+//
14+// Deprecated: Use telegram.NewAdapter + bot.NewRunner directly.
15+// Bot is kept for backward compatibility but is no longer used internally.
1716 type Bot struct {
1817 Client *Client
1918 Agent *core.Agent
@@ -22,353 +21,29 @@ type Bot struct {
2221 Provider string
2322 AuthMethod string
2423 CWD string
25- // Save persists cfg to bot.json. Called whenever the bot pairs
26- // with a new allowed user or advances LastUpdateID.
27- Save func(Config) error
28- // RefreshCreds is called before every turn to pick up newly
29- // refreshed OAuth tokens. Optional; when nil, the bot uses the
30- // credential it was built with. Implementations typically call
31- // agent.ResolveCredentialFull which auto-refreshes expired tokens.
24+ Save func(Config) error
3225 RefreshCreds func() error
3326
34- mu sync.Mutex
35- busy bool
36- activeCtx context.CancelFunc
37- queue []queuedTurn
38- lastCtxInput int
27+ runner *bot.Runner
28+ adapter *Adapter
3929 }
4030
41-// queuedTurn is an inbound DM waiting to become a prompt.
42-type queuedTurn struct {
43- chatID int64
44- messageID int
45- prompt string
46- images []provider.ImageBlock
47-}
48-
49-// Run drives the bot. Returns when ctx is cancelled or GetMe fails.
31+// Run starts the bot. It constructs the adapter and runner on first
32+// call, then delegates to runner.Run.
5033 func (b *Bot) Run(ctx context.Context) error {
51- if b.Config.BotToken == "" {
52- return fmt.Errorf("no bot token configured; run `zot bot setup` first")
53- }
54- me, err := b.Client.GetMe(ctx)
55- if err != nil {
56- return fmt.Errorf("getMe: %w", err)
57- }
58- // Keep the stored username/id in sync with the actual bot.
59- if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username {
60- b.Config.BotID = me.ID
61- b.Config.BotUsername = me.Username
62- _ = b.Save(b.Config)
63- }
64-
65- fmt.Printf("telegram bridge online as @%s (id=%d)\n", me.Username, me.ID)
66- if b.Config.AllowedUserID == 0 {
67- fmt.Println("no user paired yet — send /start to the bot from Telegram to claim it")
68- } else {
69- fmt.Printf("paired with telegram user id %d\n", b.Config.AllowedUserID)
70- }
71-
72- return b.pollLoop(ctx)
73-}
74-
75-// pollLoop long-polls Telegram for updates and dispatches them.
76-func (b *Bot) pollLoop(ctx context.Context) error {
77- backoff := time.Second
78- for {
79- if err := ctx.Err(); err != nil {
80- return err
81- }
82- updates, err := b.Client.GetUpdates(ctx, b.Config.LastUpdateID+1, 30)
83- if err != nil {
84- if ctx.Err() != nil {
85- return ctx.Err()
86- }
87- fmt.Fprintln(stderr(), "telegram: getUpdates error:", err)
88- select {
89- case <-ctx.Done():
90- return ctx.Err()
91- case <-time.After(backoff):
92- }
93- if backoff < 30*time.Second {
94- backoff *= 2
95- }
96- continue
97- }
98- backoff = time.Second
99- for _, u := range updates {
100- if err := b.handleUpdate(ctx, u); err != nil {
101- fmt.Fprintln(stderr(), "telegram: handleUpdate:", err)
102- }
103- b.Config.LastUpdateID = u.UpdateID
104- _ = b.Save(b.Config)
105- }
106- }
107-}
108-
109-// handleUpdate processes a single Telegram update.
110-func (b *Bot) handleUpdate(ctx context.Context, u Update) error {
111- msg := u.Message
112- if msg == nil {
113- msg = u.Edited
114- }
115- if msg == nil || msg.From == nil || msg.From.IsBot {
116- return nil
117- }
118- if msg.Chat.Type != "private" {
119- return nil
120- }
121-
122- // Pairing: first user who sends /start claims the bridge.
123- text := strings.TrimSpace(msg.Text)
124- if b.Config.AllowedUserID == 0 {
125- if strings.HasPrefix(text, "/start") {
126- b.Config.AllowedUserID = msg.From.ID
127- _ = b.Save(b.Config)
128- _ = b.Client.SendMessage(ctx, msg.Chat.ID,
129- fmt.Sprintf("paired with @%s. send any message and i'll forward it to zot.", msg.From.Username),
130- msg.MessageID)
131- return nil
132- }
133- _ = b.Client.SendMessage(ctx, msg.Chat.ID,
134- "this bot isn't paired yet. send /start to claim it.",
135- msg.MessageID)
136- return nil
137- }
138-
139- // Enforce allowed user.
140- if msg.From.ID != b.Config.AllowedUserID {
141- _ = b.Client.SendMessage(ctx, msg.Chat.ID,
142- "this bot is paired with a different user.",
143- msg.MessageID)
144- return nil
145- }
146-
147- // Built-in commands that bypass the agent.
148- switch text {
149- case "/start", "/help":
150- _ = b.Client.SendMessage(ctx, msg.Chat.ID,
151- "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.",
152- msg.MessageID)
153- return nil
154- case "/status":
155- return b.sendStatus(ctx, msg.Chat.ID, msg.MessageID)
156- case "/stop":
157- b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID)
158- return nil
159- }
160- if isStopCommand(text) {
161- b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID)
162- return nil
163- }
164-
165- // Build the prompt: combine text + caption; download image attachments.
166- prompt := strings.TrimSpace(msg.Text)
167- if msg.Caption != "" {
168- if prompt != "" {
169- prompt += "\n"
170- }
171- prompt += msg.Caption
172- }
173-
174- var images []provider.ImageBlock
175- if len(msg.Photo) > 0 {
176- // Photos arrive in multiple sizes; take the largest (last in the slice).
177- largest := msg.Photo[len(msg.Photo)-1]
178- if data, mime, err := b.download(ctx, largest.FileID, ""); err == nil {
179- images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
180- } else {
181- fmt.Fprintln(stderr(), "telegram: download photo:", err)
182- }
183- }
184- if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
185- if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
186- images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
187- }
188- }
189-
190- if prompt == "" && len(images) == 0 {
191- return nil
192- }
193-
194- b.mu.Lock()
195- b.queue = append(b.queue, queuedTurn{
196- chatID: msg.Chat.ID,
197- messageID: msg.MessageID,
198- prompt: prompt,
199- images: images,
200- })
201- idle := !b.busy
202- b.mu.Unlock()
203-
204- if idle {
205- go b.drainQueue(ctx)
206- }
207- return nil
208-}
209-
210-// drainQueue runs queued turns one at a time until the queue is empty.
211-func (b *Bot) drainQueue(parent context.Context) {
212- for {
213- b.mu.Lock()
214- if len(b.queue) == 0 {
215- b.busy = false
216- b.activeCtx = nil
217- b.mu.Unlock()
218- return
219- }
220- t := b.queue[0]
221- b.queue = b.queue[1:]
222- b.busy = true
223- turnCtx, cancel := context.WithCancel(parent)
224- b.activeCtx = cancel
225- b.mu.Unlock()
226-
227- if b.RefreshCreds != nil {
228- if err := b.RefreshCreds(); err != nil {
229- fmt.Fprintln(stderr(), "telegram: refresh creds:", err)
230- }
231- }
232- b.runTurn(turnCtx, t)
233- cancel()
234- }
235-}
236-
237-// runTurn sends the queued prompt to the agent and streams the reply.
238-func (b *Bot) runTurn(ctx context.Context, t queuedTurn) {
239- stopTyping := b.startTyping(ctx, t.chatID)
240- defer stopTyping()
241-
242- var replyBuilder strings.Builder
243- var lastAssistantText string
244- var turnErr error
245-
246- sink := func(ev core.AgentEvent) {
247- switch e := ev.(type) {
248- case core.EvTextDelta:
249- replyBuilder.WriteString(e.Delta)
250- case core.EvUsage:
251- b.mu.Lock()
252- if e.Usage.InputTokens > 0 {
253- b.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
254- }
255- b.mu.Unlock()
256- case core.EvAssistantMessage:
257- var sb strings.Builder
258- for _, c := range e.Message.Content {
259- if tb, ok := c.(provider.TextBlock); ok {
260- if sb.Len() > 0 {
261- sb.WriteString("\n")
262- }
263- sb.WriteString(tb.Text)
264- }
265- }
266- if sb.Len() > 0 {
267- lastAssistantText = sb.String()
268- }
269- replyBuilder.Reset()
270- case core.EvTurnEnd:
271- if e.Err != nil {
272- turnErr = e.Err
273- }
274- }
275- }
276-
277- if err := b.Agent.Prompt(ctx, t.prompt, t.images, sink); err != nil {
278- turnErr = err
279- }
280-
281- reply := strings.TrimSpace(lastAssistantText)
282- if reply == "" {
283- reply = strings.TrimSpace(replyBuilder.String())
284- }
285- if turnErr != nil && ctx.Err() == nil {
286- reply = "error: " + turnErr.Error()
287- }
288- if reply == "" {
289- reply = "(no reply)"
290- }
291- // Telegram caps messages at 4096 chars. Chunk to be safe.
292- for _, chunk := range chunkMessage(reply, 4000) {
293- if err := b.Client.SendMessage(context.Background(), t.chatID, chunk, 0); err != nil {
294- fmt.Fprintln(stderr(), "telegram: sendMessage:", err)
295- break
296- }
297- }
298-}
299-
300-// startTyping keeps Telegram's "typing..." indicator alive until the
301-// returned stop function is called.
302-func (b *Bot) startTyping(ctx context.Context, chatID int64) func() {
303- tctx, cancel := context.WithCancel(ctx)
304- go func() {
305- for {
306- _ = b.Client.SendChatAction(tctx, chatID, "typing")
307- select {
308- case <-tctx.Done():
309- return
310- case <-time.After(4 * time.Second):
311- }
312- }
313- }()
314- return cancel
315-}
316-
317-func (b *Bot) cancelActiveTurn(ctx context.Context, chatID int64, replyTo int) {
318- b.mu.Lock()
319- cancel := b.activeCtx
320- b.mu.Unlock()
321- if cancel != nil {
322- cancel()
323- _ = b.Client.SendMessage(ctx, chatID, "cancelled the current turn.", replyTo)
324- } else {
325- _ = b.Client.SendMessage(ctx, chatID, "nothing running.", replyTo)
326- }
327-}
328-
329-// sendStatus describes agent state to the Telegram user.
330-func (b *Bot) sendStatus(ctx context.Context, chatID int64, replyTo int) error {
331- b.mu.Lock()
332- busy := b.busy
333- queued := len(b.queue)
334- ctxUsed := b.lastCtxInput
335- providerName := b.Provider
336- authMethod := b.AuthMethod
337- cwd := b.CWD
338- b.mu.Unlock()
339-
340- model := b.Agent.Model
341- ctxMax := 0
342- if m, err := provider.FindModel(providerName, model); err == nil {
343- ctxMax = m.ContextWindow
344- }
345- return b.Client.SendMessage(ctx, chatID, FormatStatus(StatusSnapshot{
346- Provider: providerName,
347- Model: model,
348- CWD: cwd,
349- Usage: b.Agent.Cost(),
350- Subscription: authMethod == "oauth",
351- ContextUsed: ctxUsed,
352- ContextMax: ctxMax,
353- Busy: busy,
354- Queued: queued,
355- }), replyTo)
356-}
357-
358-// download fetches a file from Telegram and returns bytes + mime.
359-func (b *Bot) download(ctx context.Context, fileID, mime string) ([]byte, string, error) {
360- f, err := b.Client.GetFile(ctx, fileID)
361- if err != nil {
362- return nil, "", err
363- }
364- data, err := b.Client.DownloadFile(ctx, f.FilePath)
365- if err != nil {
366- return nil, "", err
367- }
368- if mime == "" {
369- mime = guessImageMIME(f.FilePath)
370- }
371- return data, mime, nil
34+ if b.adapter == nil {
35+ b.adapter = NewAdapter(b.Client, &b.Config, b.Save)
36+ }
37+ if b.runner == nil {
38+ b.runner = bot.NewRunner(b.adapter, b.Agent, bot.Config{
39+ ZotHome: b.ZotHome,
40+ Provider: b.Provider,
41+ AuthMethod: b.AuthMethod,
42+ CWD: b.CWD,
43+ RefreshCreds: b.RefreshCreds,
44+ })
45+ }
46+ return b.runner.Run(ctx)
37247 }
37348
37449 // chunkMessage splits s into chunks no larger than limit runes, on line
@@ -2,18 +2,17 @@ package telegram
2 2
3 import (3 import (
4 "context"4 "context"
5- "fmt"
6 "strings"5 "strings"
7- "sync"
8- "time"
9 6
7+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
10 "github.com/patriceckhart/zot/packages/core"8 "github.com/patriceckhart/zot/packages/core"
11- "github.com/patriceckhart/zot/packages/provider"
12 )9 )
13 10
14-// Bot owns the Telegram polling loop and dispatches inbound DMs to11+// Bot is a thin wrapper around bot.Runner + Adapter. It exists to
15-// the agent. It is a long-running goroutine; Run blocks until ctx12+// keep the exported API stable for botcmd.go.
16-// cancels.13+//
14+// Deprecated: Use telegram.NewAdapter + bot.NewRunner directly.
15+// Bot is kept for backward compatibility but is no longer used internally.
17 type Bot struct {16 type Bot struct {
18 Client *Client17 Client *Client
19 Agent *core.Agent18 Agent *core.Agent
@@ -22,353 +21,29 @@ type Bot struct {
22 Provider string21 Provider string
23 AuthMethod string22 AuthMethod string
24 CWD string23 CWD string
25- // Save persists cfg to bot.json. Called whenever the bot pairs24+ Save func(Config) error
26- // with a new allowed user or advances LastUpdateID.
27- Save func(Config) error
28- // RefreshCreds is called before every turn to pick up newly
29- // refreshed OAuth tokens. Optional; when nil, the bot uses the
30- // credential it was built with. Implementations typically call
31- // agent.ResolveCredentialFull which auto-refreshes expired tokens.
32 RefreshCreds func() error25 RefreshCreds func() error
33 26
34- mu sync.Mutex27+ runner *bot.Runner
35- busy bool28+ adapter *Adapter
36- activeCtx context.CancelFunc
37- queue []queuedTurn
38- lastCtxInput int
39 }29 }
40 30
41-// queuedTurn is an inbound DM waiting to become a prompt.31+// Run starts the bot. It constructs the adapter and runner on first
42-type queuedTurn struct {32+// call, then delegates to runner.Run.
43- chatID int64
44- messageID int
45- prompt string
46- images []provider.ImageBlock
47-}
48-
49-// Run drives the bot. Returns when ctx is cancelled or GetMe fails.
50 func (b *Bot) Run(ctx context.Context) error {33 func (b *Bot) Run(ctx context.Context) error {
51- if b.Config.BotToken == "" {34+ if b.adapter == nil {
52- return fmt.Errorf("no bot token configured; run `zot bot setup` first")35+ b.adapter = NewAdapter(b.Client, &b.Config, b.Save)
53- }36+ }
54- me, err := b.Client.GetMe(ctx)37+ if b.runner == nil {
55- if err != nil {38+ b.runner = bot.NewRunner(b.adapter, b.Agent, bot.Config{
56- return fmt.Errorf("getMe: %w", err)39+ ZotHome: b.ZotHome,
57- }40+ Provider: b.Provider,
58- // Keep the stored username/id in sync with the actual bot.41+ AuthMethod: b.AuthMethod,
59- if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username {42+ CWD: b.CWD,
60- b.Config.BotID = me.ID43+ RefreshCreds: b.RefreshCreds,
61- b.Config.BotUsername = me.Username44+ })
62- _ = b.Save(b.Config)45+ }
63- }46+ return b.runner.Run(ctx)
64-
65- fmt.Printf("telegram bridge online as @%s (id=%d)\n", me.Username, me.ID)
66- if b.Config.AllowedUserID == 0 {
67- fmt.Println("no user paired yet — send /start to the bot from Telegram to claim it")
68- } else {
69- fmt.Printf("paired with telegram user id %d\n", b.Config.AllowedUserID)
70- }
71-
72- return b.pollLoop(ctx)
73-}
74-
75-// pollLoop long-polls Telegram for updates and dispatches them.
76-func (b *Bot) pollLoop(ctx context.Context) error {
77- backoff := time.Second
78- for {
79- if err := ctx.Err(); err != nil {
80- return err
81- }
82- updates, err := b.Client.GetUpdates(ctx, b.Config.LastUpdateID+1, 30)
83- if err != nil {
84- if ctx.Err() != nil {
85- return ctx.Err()
86- }
87- fmt.Fprintln(stderr(), "telegram: getUpdates error:", err)
88- select {
89- case <-ctx.Done():
90- return ctx.Err()
91- case <-time.After(backoff):
92- }
93- if backoff < 30*time.Second {
94- backoff *= 2
95- }
96- continue
97- }
98- backoff = time.Second
99- for _, u := range updates {
100- if err := b.handleUpdate(ctx, u); err != nil {
101- fmt.Fprintln(stderr(), "telegram: handleUpdate:", err)
102- }
103- b.Config.LastUpdateID = u.UpdateID
104- _ = b.Save(b.Config)
105- }
106- }
107-}
108-
109-// handleUpdate processes a single Telegram update.
110-func (b *Bot) handleUpdate(ctx context.Context, u Update) error {
111- msg := u.Message
112- if msg == nil {
113- msg = u.Edited
114- }
115- if msg == nil || msg.From == nil || msg.From.IsBot {
116- return nil
117- }
118- if msg.Chat.Type != "private" {
119- return nil
120- }
121-
122- // Pairing: first user who sends /start claims the bridge.
123- text := strings.TrimSpace(msg.Text)
124- if b.Config.AllowedUserID == 0 {
125- if strings.HasPrefix(text, "/start") {
126- b.Config.AllowedUserID = msg.From.ID
127- _ = b.Save(b.Config)
128- _ = b.Client.SendMessage(ctx, msg.Chat.ID,
129- fmt.Sprintf("paired with @%s. send any message and i'll forward it to zot.", msg.From.Username),
130- msg.MessageID)
131- return nil
132- }
133- _ = b.Client.SendMessage(ctx, msg.Chat.ID,
134- "this bot isn't paired yet. send /start to claim it.",
135- msg.MessageID)
136- return nil
137- }
138-
139- // Enforce allowed user.
140- if msg.From.ID != b.Config.AllowedUserID {
141- _ = b.Client.SendMessage(ctx, msg.Chat.ID,
142- "this bot is paired with a different user.",
143- msg.MessageID)
144- return nil
145- }
146-
147- // Built-in commands that bypass the agent.
148- switch text {
149- case "/start", "/help":
150- _ = b.Client.SendMessage(ctx, msg.Chat.ID,
151- "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.",
152- msg.MessageID)
153- return nil
154- case "/status":
155- return b.sendStatus(ctx, msg.Chat.ID, msg.MessageID)
156- case "/stop":
157- b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID)
158- return nil
159- }
160- if isStopCommand(text) {
161- b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID)
162- return nil
163- }
164-
165- // Build the prompt: combine text + caption; download image attachments.
166- prompt := strings.TrimSpace(msg.Text)
167- if msg.Caption != "" {
168- if prompt != "" {
169- prompt += "\n"
170- }
171- prompt += msg.Caption
172- }
173-
174- var images []provider.ImageBlock
175- if len(msg.Photo) > 0 {
176- // Photos arrive in multiple sizes; take the largest (last in the slice).
177- largest := msg.Photo[len(msg.Photo)-1]
178- if data, mime, err := b.download(ctx, largest.FileID, ""); err == nil {
179- images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
180- } else {
181- fmt.Fprintln(stderr(), "telegram: download photo:", err)
182- }
183- }
184- if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
185- if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
186- images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
187- }
188- }
189-
190- if prompt == "" && len(images) == 0 {
191- return nil
192- }
193-
194- b.mu.Lock()
195- b.queue = append(b.queue, queuedTurn{
196- chatID: msg.Chat.ID,
197- messageID: msg.MessageID,
198- prompt: prompt,
199- images: images,
200- })
201- idle := !b.busy
202- b.mu.Unlock()
203-
204- if idle {
205- go b.drainQueue(ctx)
206- }
207- return nil
208-}
209-
210-// drainQueue runs queued turns one at a time until the queue is empty.
211-func (b *Bot) drainQueue(parent context.Context) {
212- for {
213- b.mu.Lock()
214- if len(b.queue) == 0 {
215- b.busy = false
216- b.activeCtx = nil
217- b.mu.Unlock()
218- return
219- }
220- t := b.queue[0]
221- b.queue = b.queue[1:]
222- b.busy = true
223- turnCtx, cancel := context.WithCancel(parent)
224- b.activeCtx = cancel
225- b.mu.Unlock()
226-
227- if b.RefreshCreds != nil {
228- if err := b.RefreshCreds(); err != nil {
229- fmt.Fprintln(stderr(), "telegram: refresh creds:", err)
230- }
231- }
232- b.runTurn(turnCtx, t)
233- cancel()
234- }
235-}
236-
237-// runTurn sends the queued prompt to the agent and streams the reply.
238-func (b *Bot) runTurn(ctx context.Context, t queuedTurn) {
239- stopTyping := b.startTyping(ctx, t.chatID)
240- defer stopTyping()
241-
242- var replyBuilder strings.Builder
243- var lastAssistantText string
244- var turnErr error
245-
246- sink := func(ev core.AgentEvent) {
247- switch e := ev.(type) {
248- case core.EvTextDelta:
249- replyBuilder.WriteString(e.Delta)
250- case core.EvUsage:
251- b.mu.Lock()
252- if e.Usage.InputTokens > 0 {
253- b.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
254- }
255- b.mu.Unlock()
256- case core.EvAssistantMessage:
257- var sb strings.Builder
258- for _, c := range e.Message.Content {
259- if tb, ok := c.(provider.TextBlock); ok {
260- if sb.Len() > 0 {
261- sb.WriteString("\n")
262- }
263- sb.WriteString(tb.Text)
264- }
265- }
266- if sb.Len() > 0 {
267- lastAssistantText = sb.String()
268- }
269- replyBuilder.Reset()
270- case core.EvTurnEnd:
271- if e.Err != nil {
272- turnErr = e.Err
273- }
274- }
275- }
276-
277- if err := b.Agent.Prompt(ctx, t.prompt, t.images, sink); err != nil {
278- turnErr = err
279- }
280-
281- reply := strings.TrimSpace(lastAssistantText)
282- if reply == "" {
283- reply = strings.TrimSpace(replyBuilder.String())
284- }
285- if turnErr != nil && ctx.Err() == nil {
286- reply = "error: " + turnErr.Error()
287- }
288- if reply == "" {
289- reply = "(no reply)"
290- }
291- // Telegram caps messages at 4096 chars. Chunk to be safe.
292- for _, chunk := range chunkMessage(reply, 4000) {
293- if err := b.Client.SendMessage(context.Background(), t.chatID, chunk, 0); err != nil {
294- fmt.Fprintln(stderr(), "telegram: sendMessage:", err)
295- break
296- }
297- }
298-}
299-
300-// startTyping keeps Telegram's "typing..." indicator alive until the
301-// returned stop function is called.
302-func (b *Bot) startTyping(ctx context.Context, chatID int64) func() {
303- tctx, cancel := context.WithCancel(ctx)
304- go func() {
305- for {
306- _ = b.Client.SendChatAction(tctx, chatID, "typing")
307- select {
308- case <-tctx.Done():
309- return
310- case <-time.After(4 * time.Second):
311- }
312- }
313- }()
314- return cancel
315-}
316-
317-func (b *Bot) cancelActiveTurn(ctx context.Context, chatID int64, replyTo int) {
318- b.mu.Lock()
319- cancel := b.activeCtx
320- b.mu.Unlock()
321- if cancel != nil {
322- cancel()
323- _ = b.Client.SendMessage(ctx, chatID, "cancelled the current turn.", replyTo)
324- } else {
325- _ = b.Client.SendMessage(ctx, chatID, "nothing running.", replyTo)
326- }
327-}
328-
329-// sendStatus describes agent state to the Telegram user.
330-func (b *Bot) sendStatus(ctx context.Context, chatID int64, replyTo int) error {
331- b.mu.Lock()
332- busy := b.busy
333- queued := len(b.queue)
334- ctxUsed := b.lastCtxInput
335- providerName := b.Provider
336- authMethod := b.AuthMethod
337- cwd := b.CWD
338- b.mu.Unlock()
339-
340- model := b.Agent.Model
341- ctxMax := 0
342- if m, err := provider.FindModel(providerName, model); err == nil {
343- ctxMax = m.ContextWindow
344- }
345- return b.Client.SendMessage(ctx, chatID, FormatStatus(StatusSnapshot{
346- Provider: providerName,
347- Model: model,
348- CWD: cwd,
349- Usage: b.Agent.Cost(),
350- Subscription: authMethod == "oauth",
351- ContextUsed: ctxUsed,
352- ContextMax: ctxMax,
353- Busy: busy,
354- Queued: queued,
355- }), replyTo)
356-}
357-
358-// download fetches a file from Telegram and returns bytes + mime.
359-func (b *Bot) download(ctx context.Context, fileID, mime string) ([]byte, string, error) {
360- f, err := b.Client.GetFile(ctx, fileID)
361- if err != nil {
362- return nil, "", err
363- }
364- data, err := b.Client.DownloadFile(ctx, f.FilePath)
365- if err != nil {
366- return nil, "", err
367- }
368- if mime == "" {
369- mime = guessImageMIME(f.FilePath)
370- }
371- return data, mime, nil
372 }47 }
373 48
374 // chunkMessage splits s into chunks no larger than limit runes, on line49 // chunkMessage splits s into chunks no larger than limit runes, on line
M packages/agent/modes/telegram/commands.go +3 -8
@@ -1,11 +1,6 @@
11 package telegram
22
3-import "strings"
3+import "github.com/patriceckhart/zot/packages/agent/modes/bot"
44
5-// isStopCommand reports whether text should abort the active turn.
6-// Telegram users often type plain "stop" rather than bot-style
7-// "/stop"; keep this intentionally narrow so normal prompts like
8-// "stop doing X" still go to the agent.
9-func isStopCommand(text string) bool {
10- return strings.EqualFold(strings.TrimSpace(text), "stop")
11-}
5+// isStopCommand is a shim to bot.IsStopCommand for backward compatibility.
6+var isStopCommand = bot.IsStopCommand
@@ -1,11 +1,6 @@
1 package telegram1 package telegram
2 2
3-import "strings"3+import "github.com/patriceckhart/zot/packages/agent/modes/bot"
4 4
5-// isStopCommand reports whether text should abort the active turn.5+// isStopCommand is a shim to bot.IsStopCommand for backward compatibility.
6-// Telegram users often type plain "stop" rather than bot-style6+var isStopCommand = bot.IsStopCommand
7-// "/stop"; keep this intentionally narrow so normal prompts like
8-// "stop doing X" still go to the agent.
9-func isStopCommand(text string) bool {
10- return strings.EqualFold(strings.TrimSpace(text), "stop")
11-}
M packages/agent/modes/telegram/status.go +5 -116
@@ -1,120 +1,9 @@
11 package telegram
22
3-import (
4- "fmt"
5- "os"
6- "strings"
3+import "github.com/patriceckhart/zot/packages/agent/modes/bot"
74
8- "github.com/patriceckhart/zot/packages/provider"
9-)
5+// StatusSnapshot is an alias for bot.StatusSnapshot for backward compatibility.
6+type StatusSnapshot = bot.StatusSnapshot
107
11-// StatusSnapshot is the small cross-host state bundle rendered for
12-// Telegram /status replies.
13-type StatusSnapshot struct {
14- Provider string
15- Model string
16- CWD string
17- Usage provider.Usage
18- Subscription bool
19- ContextUsed int
20- ContextMax int
21- Busy bool
22- Queued int
23-}
24-
25-// FormatStatus renders the same compact model/usage/cost/context
26-// information shown in the TUI status bar, plus the current directory.
27-func FormatStatus(s StatusSnapshot) string {
28- providerName := strings.TrimSpace(s.Provider)
29- model := strings.TrimSpace(s.Model)
30- if providerName == "" {
31- providerName = "unknown"
32- }
33- if model == "" {
34- model = "unknown"
35- }
36-
37- var stats []string
38- if s.Usage.InputTokens > 0 {
39- stats = append(stats, fmt.Sprintf("↑%s", formatTokens(s.Usage.InputTokens)))
40- }
41- if s.Usage.OutputTokens > 0 {
42- stats = append(stats, fmt.Sprintf("↓%s", formatTokens(s.Usage.OutputTokens)))
43- }
44- if s.Usage.CacheReadTokens > 0 {
45- stats = append(stats, fmt.Sprintf("R%s", formatTokens(s.Usage.CacheReadTokens)))
46- }
47- if s.Usage.CacheWriteTokens > 0 {
48- stats = append(stats, fmt.Sprintf("W%s", formatTokens(s.Usage.CacheWriteTokens)))
49- }
50- if s.Usage.CostUSD > 0 || s.Subscription {
51- cost := fmt.Sprintf("$%.3f", s.Usage.CostUSD)
52- if s.Subscription {
53- cost += " (sub)"
54- }
55- stats = append(stats, cost)
56- }
57- if ctx := contextUsage(s.ContextUsed, s.ContextMax); ctx != "" {
58- stats = append(stats, ctx)
59- }
60-
61- line := fmt.Sprintf("(%s) %s", providerName, model)
62- if len(stats) > 0 {
63- line += " " + strings.Join(stats, " ")
64- }
65-
66- state := "idle"
67- if s.Busy {
68- state = "working"
69- }
70- lines := []string{line, "state: " + state}
71- if s.Queued > 0 {
72- lines = append(lines, fmt.Sprintf("queued: %d", s.Queued))
73- }
74- if cwd := shortenHome(strings.TrimSpace(s.CWD)); cwd != "" {
75- lines = append(lines, "cwd: "+cwd)
76- }
77- return strings.Join(lines, "\n")
78-}
79-
80-func contextUsage(used, max int) string {
81- if max <= 0 {
82- if used <= 0 {
83- return ""
84- }
85- return formatTokens(used)
86- }
87- pct := float64(used) / float64(max) * 100
88- return fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max))
89-}
90-
91-func formatTokens(n int) string {
92- switch {
93- case n < 0:
94- return "0"
95- case n < 1000:
96- return fmt.Sprintf("%d", n)
97- case n < 10000:
98- return fmt.Sprintf("%.1fk", float64(n)/1000)
99- case n < 1_000_000:
100- return fmt.Sprintf("%dk", (n+500)/1000)
101- case n < 10_000_000:
102- return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
103- default:
104- return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
105- }
106-}
107-
108-func shortenHome(path string) string {
109- home, err := os.UserHomeDir()
110- if err != nil || home == "" {
111- return path
112- }
113- if path == home {
114- return "~"
115- }
116- if strings.HasPrefix(path, home+string(os.PathSeparator)) {
117- return "~" + strings.TrimPrefix(path, home)
118- }
119- return path
120-}
8+// FormatStatus is an alias for bot.FormatStatus for backward compatibility.
9+var FormatStatus = bot.FormatStatus
@@ -1,120 +1,9 @@
1 package telegram1 package telegram
2 2
3-import (3+import "github.com/patriceckhart/zot/packages/agent/modes/bot"
4- "fmt"
5- "os"
6- "strings"
7 4
8- "github.com/patriceckhart/zot/packages/provider"5+// StatusSnapshot is an alias for bot.StatusSnapshot for backward compatibility.
9-)6+type StatusSnapshot = bot.StatusSnapshot
10 7
11-// StatusSnapshot is the small cross-host state bundle rendered for8+// FormatStatus is an alias for bot.FormatStatus for backward compatibility.
12-// Telegram /status replies.9+var FormatStatus = bot.FormatStatus
13-type StatusSnapshot struct {
14- Provider string
15- Model string
16- CWD string
17- Usage provider.Usage
18- Subscription bool
19- ContextUsed int
20- ContextMax int
21- Busy bool
22- Queued int
23-}
24-
25-// FormatStatus renders the same compact model/usage/cost/context
26-// information shown in the TUI status bar, plus the current directory.
27-func FormatStatus(s StatusSnapshot) string {
28- providerName := strings.TrimSpace(s.Provider)
29- model := strings.TrimSpace(s.Model)
30- if providerName == "" {
31- providerName = "unknown"
32- }
33- if model == "" {
34- model = "unknown"
35- }
36-
37- var stats []string
38- if s.Usage.InputTokens > 0 {
39- stats = append(stats, fmt.Sprintf("↑%s", formatTokens(s.Usage.InputTokens)))
40- }
41- if s.Usage.OutputTokens > 0 {
42- stats = append(stats, fmt.Sprintf("↓%s", formatTokens(s.Usage.OutputTokens)))
43- }
44- if s.Usage.CacheReadTokens > 0 {
45- stats = append(stats, fmt.Sprintf("R%s", formatTokens(s.Usage.CacheReadTokens)))
46- }
47- if s.Usage.CacheWriteTokens > 0 {
48- stats = append(stats, fmt.Sprintf("W%s", formatTokens(s.Usage.CacheWriteTokens)))
49- }
50- if s.Usage.CostUSD > 0 || s.Subscription {
51- cost := fmt.Sprintf("$%.3f", s.Usage.CostUSD)
52- if s.Subscription {
53- cost += " (sub)"
54- }
55- stats = append(stats, cost)
56- }
57- if ctx := contextUsage(s.ContextUsed, s.ContextMax); ctx != "" {
58- stats = append(stats, ctx)
59- }
60-
61- line := fmt.Sprintf("(%s) %s", providerName, model)
62- if len(stats) > 0 {
63- line += " " + strings.Join(stats, " ")
64- }
65-
66- state := "idle"
67- if s.Busy {
68- state = "working"
69- }
70- lines := []string{line, "state: " + state}
71- if s.Queued > 0 {
72- lines = append(lines, fmt.Sprintf("queued: %d", s.Queued))
73- }
74- if cwd := shortenHome(strings.TrimSpace(s.CWD)); cwd != "" {
75- lines = append(lines, "cwd: "+cwd)
76- }
77- return strings.Join(lines, "\n")
78-}
79-
80-func contextUsage(used, max int) string {
81- if max <= 0 {
82- if used <= 0 {
83- return ""
84- }
85- return formatTokens(used)
86- }
87- pct := float64(used) / float64(max) * 100
88- return fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max))
89-}
90-
91-func formatTokens(n int) string {
92- switch {
93- case n < 0:
94- return "0"
95- case n < 1000:
96- return fmt.Sprintf("%d", n)
97- case n < 10000:
98- return fmt.Sprintf("%.1fk", float64(n)/1000)
99- case n < 1_000_000:
100- return fmt.Sprintf("%dk", (n+500)/1000)
101- case n < 10_000_000:
102- return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
103- default:
104- return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
105- }
106-}
107-
108-func shortenHome(path string) string {
109- home, err := os.UserHomeDir()
110- if err != nil || home == "" {
111- return path
112- }
113- if path == home {
114- return "~"
115- }
116- if strings.HasPrefix(path, home+string(os.PathSeparator)) {
117- return "~" + strings.TrimPrefix(path, home)
118- }
119- return path
120-}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close