detached e0658cf

Merge pull request #47 from mi-skam/feat/tg-refactor-branch

refactor: extract generic BotAdapter interface from Telegram bot
commit
e0658cfd5abf9d5367b08d6f75181db40cdab3ac
author
Patric Eckhart · · 2026-06-29 12:28 UTC
committer
GitHub · · 2026-06-29 12:28 UTC
merge of
b325477 + 5e699ec · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +909 -517
M packages/agent/botcmd.go +25 -17
@@ -10,13 +10,14 @@ import (
1010 "os/exec"
1111 "os/signal"
1212 "path/filepath"
13- "strconv"
1413 "strings"
1514 "syscall"
1615 "time"
1716
17+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
1818 "github.com/patriceckhart/zot/packages/agent/modes/telegram"
1919 "github.com/patriceckhart/zot/packages/core"
20+ "github.com/patriceckhart/zot/packages/provider"
2021 )
2122
2223 // detachChild configures cmd to run in its own process group so tty
@@ -356,24 +357,36 @@ func botRun(rawTail []string, version string) error {
356357 s, _, serr := openOrCreateSessionForBot(args, resolved, agent, version)
357358 if serr == nil {
358359 sess = s
360+ agent.OnMessageAppended = func(msg provider.Message) {
361+ _ = sess.AppendMessage(msg)
362+ }
363+ agent.OnUsage = func(u provider.Usage) {
364+ _ = sess.AppendUsage(u, u)
365+ }
366+ agent.OnTranscriptCompacted = func(msgs []provider.Message) {
367+ _ = sess.AppendCompaction(msgs)
368+ }
359369 defer sess.Close()
360370 } else {
361371 fmt.Fprintln(os.Stderr, "session:", serr)
362372 }
363373 }
364374
365- var b *telegram.Bot
366- b = &telegram.Bot{
367- Client: telegram.NewClient(cfg.BotToken),
368- Agent: agent,
369- Config: cfg,
375+ // Construct the Telegram adapter and generic runner.
376+ adapter := telegram.NewAdapter(
377+ telegram.NewClient(cfg.BotToken),
378+ &cfg,
379+ func(c telegram.Config) error {
380+ return telegram.SaveConfig(ZotHome(), c)
381+ },
382+ )
383+ var runner *bot.Runner
384+ runner = bot.NewRunner(adapter, agent, bot.Config{
370385 ZotHome: ZotHome(),
371386 Provider: resolved.Provider,
387+ Model: resolved.Model,
372388 AuthMethod: resolved.AuthMethod,
373389 CWD: args.CWD,
374- Save: func(c telegram.Config) error {
375- return telegram.SaveConfig(ZotHome(), c)
376- },
377390 RefreshCreds: func() error {
378391 // Re-run the same resolver the tui uses so we pick up
379392 // refreshed oauth tokens, re-logins, and model switches.
@@ -385,12 +398,10 @@ func botRun(rawTail []string, version string) error {
385398 }
386399 agent.Client = next.NewClient()
387400 agent.Model = next.Model
388- b.Provider = next.Provider
389- b.AuthMethod = next.AuthMethod
390- b.CWD = next.CWD
401+ runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
391402 return nil
392403 },
393- }
404+ })
394405
395406 // Record our pid so `zot telegram-bot status` / `zot telegram-bot stop` can find us,
396407 // regardless of whether we were started directly or via `bot start`.
@@ -407,7 +418,7 @@ func botRun(rawTail []string, version string) error {
407418 cancel()
408419 }()
409420 defer cancel()
410- return b.Run(ctx)
421+ return runner.Run(ctx)
411422 }
412423
413424 // openOrCreateSessionForBot reuses the same logic as interactive mode
@@ -444,6 +455,3 @@ func maskToken(tok string) string {
444455 }
445456 return tok[:i+1] + body[:3] + "..." + body[len(body)-3:]
446457 }
447-
448-// _ compile-time hint so the strconv import stays if we later add numeric parsing.
449-var _ = strconv.Itoa
@@ -10,13 +10,14 @@ import (
10 "os/exec"10 "os/exec"
11 "os/signal"11 "os/signal"
12 "path/filepath"12 "path/filepath"
13- "strconv"
14 "strings"13 "strings"
15 "syscall"14 "syscall"
16 "time"15 "time"
17 16
17+ "github.com/patriceckhart/zot/packages/agent/modes/bot"
18 "github.com/patriceckhart/zot/packages/agent/modes/telegram"18 "github.com/patriceckhart/zot/packages/agent/modes/telegram"
19 "github.com/patriceckhart/zot/packages/core"19 "github.com/patriceckhart/zot/packages/core"
20+ "github.com/patriceckhart/zot/packages/provider"
20 )21 )
21 22
22 // detachChild configures cmd to run in its own process group so tty23 // detachChild configures cmd to run in its own process group so tty
@@ -356,24 +357,36 @@ func botRun(rawTail []string, version string) error {
356 s, _, serr := openOrCreateSessionForBot(args, resolved, agent, version)357 s, _, serr := openOrCreateSessionForBot(args, resolved, agent, version)
357 if serr == nil {358 if serr == nil {
358 sess = s359 sess = s
360+ agent.OnMessageAppended = func(msg provider.Message) {
361+ _ = sess.AppendMessage(msg)
362+ }
363+ agent.OnUsage = func(u provider.Usage) {
364+ _ = sess.AppendUsage(u, u)
365+ }
366+ agent.OnTranscriptCompacted = func(msgs []provider.Message) {
367+ _ = sess.AppendCompaction(msgs)
368+ }
359 defer sess.Close()369 defer sess.Close()
360 } else {370 } else {
361 fmt.Fprintln(os.Stderr, "session:", serr)371 fmt.Fprintln(os.Stderr, "session:", serr)
362 }372 }
363 }373 }
364 374
365- var b *telegram.Bot375+ // Construct the Telegram adapter and generic runner.
366- b = &telegram.Bot{376+ adapter := telegram.NewAdapter(
367- Client: telegram.NewClient(cfg.BotToken),377+ telegram.NewClient(cfg.BotToken),
368- Agent: agent,378+ &cfg,
369- Config: cfg,379+ func(c telegram.Config) error {
380+ return telegram.SaveConfig(ZotHome(), c)
381+ },
382+ )
383+ var runner *bot.Runner
384+ runner = bot.NewRunner(adapter, agent, bot.Config{
370 ZotHome: ZotHome(),385 ZotHome: ZotHome(),
371 Provider: resolved.Provider,386 Provider: resolved.Provider,
387+ Model: resolved.Model,
372 AuthMethod: resolved.AuthMethod,388 AuthMethod: resolved.AuthMethod,
373 CWD: args.CWD,389 CWD: args.CWD,
374- Save: func(c telegram.Config) error {
375- return telegram.SaveConfig(ZotHome(), c)
376- },
377 RefreshCreds: func() error {390 RefreshCreds: func() error {
378 // Re-run the same resolver the tui uses so we pick up391 // Re-run the same resolver the tui uses so we pick up
379 // refreshed oauth tokens, re-logins, and model switches.392 // refreshed oauth tokens, re-logins, and model switches.
@@ -385,12 +398,10 @@ func botRun(rawTail []string, version string) error {
385 }398 }
386 agent.Client = next.NewClient()399 agent.Client = next.NewClient()
387 agent.Model = next.Model400 agent.Model = next.Model
388- b.Provider = next.Provider401+ runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
389- b.AuthMethod = next.AuthMethod
390- b.CWD = next.CWD
391 return nil402 return nil
392 },403 },
393- }404+ })
394 405
395 // Record our pid so `zot telegram-bot status` / `zot telegram-bot stop` can find us,406 // 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`.407 // regardless of whether we were started directly or via `bot start`.
@@ -407,7 +418,7 @@ func botRun(rawTail []string, version string) error {
407 cancel()418 cancel()
408 }()419 }()
409 defer cancel()420 defer cancel()
410- return b.Run(ctx)421+ return runner.Run(ctx)
411 }422 }
412 423
413 // openOrCreateSessionForBot reuses the same logic as interactive mode424 // openOrCreateSessionForBot reuses the same logic as interactive mode
@@ -444,6 +455,3 @@ func maskToken(tok string) string {
444 }455 }
445 return tok[:i+1] + body[:3] + "..." + body[len(body)-3:]456 return tok[:i+1] + body[:3] + "..." + body[len(body)-3:]
446 }457 }
447-
448-// _ compile-time hint so the strconv import stays if we later add numeric parsing.
449-var _ = strconv.Itoa
A packages/agent/modes/bot/adapter.go +57 -0
@@ -0,0 +1,57 @@
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+// SendOptions carries protocol-agnostic delivery hints for outbound messages.
32+type SendOptions struct {
33+ // ReplyToMessageID is an optional adapter-owned message identifier to anchor replies.
34+ ReplyToMessageID string
35+}
36+
37+// BotAdapter is the transport layer a concrete protocol must implement.
38+// The Runner calls these methods; it never touches protocol types directly.
39+type BotAdapter interface {
40+ // Run drives inbound polling; calls handler for normal messages and
41+ // commandHandler for built-in commands. Blocks until ctx is done.
42+ Run(ctx context.Context,
43+ handler func(InboundMessage),
44+ commandHandler func(Command, InboundMessage),
45+ ) error
46+
47+ // Send delivers a reply. The adapter chunks to protocol limits.
48+ Send(ctx context.Context, channelID, text string, opts SendOptions) error
49+
50+ // IndicateWorking fires a "typing…" signal; returns a stop func.
51+ // Return a no-op if the protocol doesn't support it.
52+ IndicateWorking(ctx context.Context, channelID string) (stop func())
53+
54+ // StatusText appends protocol-specific info to /status replies
55+ // (e.g. "@botname"). Return "" if there is nothing to add.
56+ StatusText() string
57+}
@@ -0,0 +1,57 @@
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+// SendOptions carries protocol-agnostic delivery hints for outbound messages.
32+type SendOptions struct {
33+ // ReplyToMessageID is an optional adapter-owned message identifier to anchor replies.
34+ ReplyToMessageID string
35+}
36+
37+// BotAdapter is the transport layer a concrete protocol must implement.
38+// The Runner calls these methods; it never touches protocol types directly.
39+type BotAdapter interface {
40+ // Run drives inbound polling; calls handler for normal messages and
41+ // commandHandler for built-in commands. Blocks until ctx is done.
42+ Run(ctx context.Context,
43+ handler func(InboundMessage),
44+ commandHandler func(Command, InboundMessage),
45+ ) error
46+
47+ // Send delivers a reply. The adapter chunks to protocol limits.
48+ Send(ctx context.Context, channelID, text string, opts SendOptions) error
49+
50+ // IndicateWorking fires a "typing…" signal; returns a stop func.
51+ // Return a no-op if the protocol doesn't support it.
52+ IndicateWorking(ctx context.Context, channelID string) (stop func())
53+
54+ // StatusText appends protocol-specific info to /status replies
55+ // (e.g. "@botname"). Return "" if there is nothing to add.
56+ StatusText() string
57+}
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 +258 -0
@@ -0,0 +1,258 @@
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+ Model string
23+ AuthMethod string
24+ CWD string
25+ RefreshCreds func() error
26+}
27+
28+// queuedTurn is an inbound message waiting to become a prompt.
29+type queuedTurn struct {
30+ channelID string
31+ messageID string
32+ prompt string
33+ images []provider.ImageBlock
34+}
35+
36+// Runner is the protocol-agnostic bot engine. It owns the turn queue,
37+// dispatches prompts to the agent, and streams replies back through
38+// the BotAdapter.
39+type Runner struct {
40+ agent *core.Agent
41+ adapter BotAdapter
42+ cfg Config
43+
44+ mu sync.Mutex
45+ busy bool
46+ activeCtx context.CancelFunc
47+ queue []queuedTurn
48+ lastCtxInput int
49+ runCtx context.Context // set at Run entry; used by goroutines
50+}
51+
52+// NewRunner creates a Runner wired to the given adapter and agent.
53+func NewRunner(adapter BotAdapter, agent *core.Agent, cfg Config) *Runner {
54+ return &Runner{
55+ agent: agent,
56+ adapter: adapter,
57+ cfg: cfg,
58+ }
59+}
60+
61+// UpdateRuntimeConfig updates provider/model/auth/cwd at runtime (e.g. after
62+// credential refresh). This is thread-safe.
63+func (r *Runner) UpdateRuntimeConfig(provider, model, authMethod, cwd string) {
64+ r.mu.Lock()
65+ defer r.mu.Unlock()
66+ r.cfg.Provider = provider
67+ r.cfg.Model = model
68+ r.cfg.AuthMethod = authMethod
69+ r.cfg.CWD = cwd
70+}
71+
72+// Run starts the adapter's polling loop and blocks until ctx cancels.
73+func (r *Runner) Run(ctx context.Context) error {
74+ r.mu.Lock()
75+ r.runCtx = ctx
76+ r.mu.Unlock()
77+
78+ return r.adapter.Run(ctx, r.handleMessage, r.handleCommand)
79+}
80+
81+// handleMessage is called by the adapter for every normal inbound message.
82+func (r *Runner) handleMessage(msg InboundMessage) {
83+ if msg.Text == "" && len(msg.Images) == 0 {
84+ return
85+ }
86+
87+ r.mu.Lock()
88+ r.queue = append(r.queue, queuedTurn{
89+ channelID: msg.ChannelID,
90+ messageID: msg.MessageID,
91+ prompt: msg.Text,
92+ images: msg.Images,
93+ })
94+ idle := !r.busy
95+ if idle {
96+ r.busy = true
97+ }
98+ r.mu.Unlock()
99+
100+ if idle {
101+ go r.drainQueue()
102+ }
103+}
104+
105+// handleCommand is called by the adapter for built-in commands.
106+func (r *Runner) handleCommand(cmd Command, msg InboundMessage) {
107+ switch cmd {
108+ case CmdStart, CmdHelp:
109+ _ = r.adapter.Send(context.Background(), msg.ChannelID,
110+ "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.",
111+ SendOptions{ReplyToMessageID: msg.MessageID})
112+ case CmdStatus:
113+ r.sendStatus(msg.ChannelID, msg.MessageID)
114+ case CmdStop:
115+ r.cancelActiveTurn(msg.ChannelID, msg.MessageID)
116+ }
117+}
118+
119+// drainQueue runs queued turns one at a time until the queue is empty.
120+func (r *Runner) drainQueue() {
121+ r.mu.Lock()
122+ parent := r.runCtx
123+ r.mu.Unlock()
124+
125+ for {
126+ r.mu.Lock()
127+ if len(r.queue) == 0 {
128+ r.busy = false
129+ r.activeCtx = nil
130+ r.mu.Unlock()
131+ return
132+ }
133+ t := r.queue[0]
134+ r.queue = r.queue[1:]
135+ turnCtx, cancel := context.WithCancel(parent)
136+ r.activeCtx = cancel
137+ r.mu.Unlock()
138+
139+ if r.cfg.RefreshCreds != nil {
140+ if err := r.cfg.RefreshCreds(); err != nil {
141+ fmt.Fprintln(stderr(), "bot: refresh creds:", err)
142+ }
143+ }
144+ r.runTurn(turnCtx, t)
145+ cancel()
146+ }
147+}
148+
149+// runTurn sends the queued prompt to the agent and streams the reply.
150+func (r *Runner) runTurn(ctx context.Context, t queuedTurn) {
151+ stopWorking := r.adapter.IndicateWorking(ctx, t.channelID)
152+ defer stopWorking()
153+
154+ var replyBuilder strings.Builder
155+ var lastAssistantText string
156+ var turnErr error
157+
158+ sink := func(ev core.AgentEvent) {
159+ switch e := ev.(type) {
160+ case core.EvTextDelta:
161+ replyBuilder.WriteString(e.Delta)
162+ case core.EvUsage:
163+ r.mu.Lock()
164+ if e.Usage.InputTokens > 0 {
165+ r.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
166+ }
167+ r.mu.Unlock()
168+ case core.EvAssistantMessage:
169+ var sb strings.Builder
170+ for _, c := range e.Message.Content {
171+ if tb, ok := c.(provider.TextBlock); ok {
172+ if sb.Len() > 0 {
173+ sb.WriteString("\n")
174+ }
175+ sb.WriteString(tb.Text)
176+ }
177+ }
178+ if sb.Len() > 0 {
179+ lastAssistantText = sb.String()
180+ }
181+ replyBuilder.Reset()
182+ case core.EvTurnEnd:
183+ if e.Err != nil {
184+ turnErr = e.Err
185+ }
186+ }
187+ }
188+
189+ if err := r.agent.Prompt(ctx, t.prompt, t.images, sink); err != nil {
190+ turnErr = err
191+ }
192+
193+ reply := strings.TrimSpace(lastAssistantText)
194+ if reply == "" {
195+ reply = strings.TrimSpace(replyBuilder.String())
196+ }
197+ if turnErr != nil && ctx.Err() == nil {
198+ reply = "error: " + turnErr.Error()
199+ }
200+ if reply == "" {
201+ reply = "(no reply)"
202+ }
203+
204+ // Adapter.Send is responsible for chunking to protocol limits.
205+ if err := r.adapter.Send(context.Background(), t.channelID, reply, SendOptions{}); err != nil {
206+ fmt.Fprintln(stderr(), "bot: send reply:", err)
207+ }
208+}
209+
210+// cancelActiveTurn aborts the currently running turn, if any.
211+func (r *Runner) cancelActiveTurn(channelID, messageID string) {
212+ r.mu.Lock()
213+ cancel := r.activeCtx
214+ r.mu.Unlock()
215+ opts := SendOptions{ReplyToMessageID: messageID}
216+ if cancel != nil {
217+ cancel()
218+ _ = r.adapter.Send(context.Background(), channelID, "cancelled the current turn.", opts)
219+ } else {
220+ _ = r.adapter.Send(context.Background(), channelID, "nothing running.", opts)
221+ }
222+}
223+
224+// sendStatus describes agent state to the user.
225+func (r *Runner) sendStatus(channelID, messageID string) {
226+ r.mu.Lock()
227+ busy := r.busy
228+ queued := len(r.queue)
229+ ctxUsed := r.lastCtxInput
230+ providerName := r.cfg.Provider
231+ model := r.cfg.Model
232+ authMethod := r.cfg.AuthMethod
233+ cwd := r.cfg.CWD
234+ r.mu.Unlock()
235+
236+ ctxMax := 0
237+ if m, err := provider.FindModel(providerName, model); err == nil {
238+ ctxMax = m.ContextWindow
239+ }
240+
241+ status := FormatStatus(StatusSnapshot{
242+ Provider: providerName,
243+ Model: model,
244+ CWD: cwd,
245+ Usage: r.agent.Cost(),
246+ Subscription: authMethod == "oauth",
247+ ContextUsed: ctxUsed,
248+ ContextMax: ctxMax,
249+ Busy: busy,
250+ Queued: queued,
251+ })
252+
253+ if extra := r.adapter.StatusText(); extra != "" {
254+ status += "\n" + extra
255+ }
256+
257+ _ = r.adapter.Send(context.Background(), channelID, status, SendOptions{ReplyToMessageID: messageID})
258+}
@@ -0,0 +1,258 @@
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+ Model string
23+ AuthMethod string
24+ CWD string
25+ RefreshCreds func() error
26+}
27+
28+// queuedTurn is an inbound message waiting to become a prompt.
29+type queuedTurn struct {
30+ channelID string
31+ messageID string
32+ prompt string
33+ images []provider.ImageBlock
34+}
35+
36+// Runner is the protocol-agnostic bot engine. It owns the turn queue,
37+// dispatches prompts to the agent, and streams replies back through
38+// the BotAdapter.
39+type Runner struct {
40+ agent *core.Agent
41+ adapter BotAdapter
42+ cfg Config
43+
44+ mu sync.Mutex
45+ busy bool
46+ activeCtx context.CancelFunc
47+ queue []queuedTurn
48+ lastCtxInput int
49+ runCtx context.Context // set at Run entry; used by goroutines
50+}
51+
52+// NewRunner creates a Runner wired to the given adapter and agent.
53+func NewRunner(adapter BotAdapter, agent *core.Agent, cfg Config) *Runner {
54+ return &Runner{
55+ agent: agent,
56+ adapter: adapter,
57+ cfg: cfg,
58+ }
59+}
60+
61+// UpdateRuntimeConfig updates provider/model/auth/cwd at runtime (e.g. after
62+// credential refresh). This is thread-safe.
63+func (r *Runner) UpdateRuntimeConfig(provider, model, authMethod, cwd string) {
64+ r.mu.Lock()
65+ defer r.mu.Unlock()
66+ r.cfg.Provider = provider
67+ r.cfg.Model = model
68+ r.cfg.AuthMethod = authMethod
69+ r.cfg.CWD = cwd
70+}
71+
72+// Run starts the adapter's polling loop and blocks until ctx cancels.
73+func (r *Runner) Run(ctx context.Context) error {
74+ r.mu.Lock()
75+ r.runCtx = ctx
76+ r.mu.Unlock()
77+
78+ return r.adapter.Run(ctx, r.handleMessage, r.handleCommand)
79+}
80+
81+// handleMessage is called by the adapter for every normal inbound message.
82+func (r *Runner) handleMessage(msg InboundMessage) {
83+ if msg.Text == "" && len(msg.Images) == 0 {
84+ return
85+ }
86+
87+ r.mu.Lock()
88+ r.queue = append(r.queue, queuedTurn{
89+ channelID: msg.ChannelID,
90+ messageID: msg.MessageID,
91+ prompt: msg.Text,
92+ images: msg.Images,
93+ })
94+ idle := !r.busy
95+ if idle {
96+ r.busy = true
97+ }
98+ r.mu.Unlock()
99+
100+ if idle {
101+ go r.drainQueue()
102+ }
103+}
104+
105+// handleCommand is called by the adapter for built-in commands.
106+func (r *Runner) handleCommand(cmd Command, msg InboundMessage) {
107+ switch cmd {
108+ case CmdStart, CmdHelp:
109+ _ = r.adapter.Send(context.Background(), msg.ChannelID,
110+ "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.",
111+ SendOptions{ReplyToMessageID: msg.MessageID})
112+ case CmdStatus:
113+ r.sendStatus(msg.ChannelID, msg.MessageID)
114+ case CmdStop:
115+ r.cancelActiveTurn(msg.ChannelID, msg.MessageID)
116+ }
117+}
118+
119+// drainQueue runs queued turns one at a time until the queue is empty.
120+func (r *Runner) drainQueue() {
121+ r.mu.Lock()
122+ parent := r.runCtx
123+ r.mu.Unlock()
124+
125+ for {
126+ r.mu.Lock()
127+ if len(r.queue) == 0 {
128+ r.busy = false
129+ r.activeCtx = nil
130+ r.mu.Unlock()
131+ return
132+ }
133+ t := r.queue[0]
134+ r.queue = r.queue[1:]
135+ turnCtx, cancel := context.WithCancel(parent)
136+ r.activeCtx = cancel
137+ r.mu.Unlock()
138+
139+ if r.cfg.RefreshCreds != nil {
140+ if err := r.cfg.RefreshCreds(); err != nil {
141+ fmt.Fprintln(stderr(), "bot: refresh creds:", err)
142+ }
143+ }
144+ r.runTurn(turnCtx, t)
145+ cancel()
146+ }
147+}
148+
149+// runTurn sends the queued prompt to the agent and streams the reply.
150+func (r *Runner) runTurn(ctx context.Context, t queuedTurn) {
151+ stopWorking := r.adapter.IndicateWorking(ctx, t.channelID)
152+ defer stopWorking()
153+
154+ var replyBuilder strings.Builder
155+ var lastAssistantText string
156+ var turnErr error
157+
158+ sink := func(ev core.AgentEvent) {
159+ switch e := ev.(type) {
160+ case core.EvTextDelta:
161+ replyBuilder.WriteString(e.Delta)
162+ case core.EvUsage:
163+ r.mu.Lock()
164+ if e.Usage.InputTokens > 0 {
165+ r.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
166+ }
167+ r.mu.Unlock()
168+ case core.EvAssistantMessage:
169+ var sb strings.Builder
170+ for _, c := range e.Message.Content {
171+ if tb, ok := c.(provider.TextBlock); ok {
172+ if sb.Len() > 0 {
173+ sb.WriteString("\n")
174+ }
175+ sb.WriteString(tb.Text)
176+ }
177+ }
178+ if sb.Len() > 0 {
179+ lastAssistantText = sb.String()
180+ }
181+ replyBuilder.Reset()
182+ case core.EvTurnEnd:
183+ if e.Err != nil {
184+ turnErr = e.Err
185+ }
186+ }
187+ }
188+
189+ if err := r.agent.Prompt(ctx, t.prompt, t.images, sink); err != nil {
190+ turnErr = err
191+ }
192+
193+ reply := strings.TrimSpace(lastAssistantText)
194+ if reply == "" {
195+ reply = strings.TrimSpace(replyBuilder.String())
196+ }
197+ if turnErr != nil && ctx.Err() == nil {
198+ reply = "error: " + turnErr.Error()
199+ }
200+ if reply == "" {
201+ reply = "(no reply)"
202+ }
203+
204+ // Adapter.Send is responsible for chunking to protocol limits.
205+ if err := r.adapter.Send(context.Background(), t.channelID, reply, SendOptions{}); err != nil {
206+ fmt.Fprintln(stderr(), "bot: send reply:", err)
207+ }
208+}
209+
210+// cancelActiveTurn aborts the currently running turn, if any.
211+func (r *Runner) cancelActiveTurn(channelID, messageID string) {
212+ r.mu.Lock()
213+ cancel := r.activeCtx
214+ r.mu.Unlock()
215+ opts := SendOptions{ReplyToMessageID: messageID}
216+ if cancel != nil {
217+ cancel()
218+ _ = r.adapter.Send(context.Background(), channelID, "cancelled the current turn.", opts)
219+ } else {
220+ _ = r.adapter.Send(context.Background(), channelID, "nothing running.", opts)
221+ }
222+}
223+
224+// sendStatus describes agent state to the user.
225+func (r *Runner) sendStatus(channelID, messageID string) {
226+ r.mu.Lock()
227+ busy := r.busy
228+ queued := len(r.queue)
229+ ctxUsed := r.lastCtxInput
230+ providerName := r.cfg.Provider
231+ model := r.cfg.Model
232+ authMethod := r.cfg.AuthMethod
233+ cwd := r.cfg.CWD
234+ r.mu.Unlock()
235+
236+ ctxMax := 0
237+ if m, err := provider.FindModel(providerName, model); err == nil {
238+ ctxMax = m.ContextWindow
239+ }
240+
241+ status := FormatStatus(StatusSnapshot{
242+ Provider: providerName,
243+ Model: model,
244+ CWD: cwd,
245+ Usage: r.agent.Cost(),
246+ Subscription: authMethod == "oauth",
247+ ContextUsed: ctxUsed,
248+ ContextMax: ctxMax,
249+ Busy: busy,
250+ Queued: queued,
251+ })
252+
253+ if extra := r.adapter.StatusText(); extra != "" {
254+ status += "\n" + extra
255+ }
256+
257+ _ = r.adapter.Send(context.Background(), channelID, status, SendOptions{ReplyToMessageID: messageID})
258+}
A packages/agent/modes/bot/runner_test.go +105 -0
@@ -0,0 +1,105 @@
1+package bot
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/provider"
11+)
12+
13+type testAdapter struct{}
14+
15+func (testAdapter) Run(context.Context, func(InboundMessage), func(Command, InboundMessage)) error {
16+ return nil
17+}
18+func (testAdapter) Send(context.Context, string, string, SendOptions) error {
19+ return nil
20+}
21+func (testAdapter) IndicateWorking(context.Context, string) func() { return func() {} }
22+func (testAdapter) StatusText() string { return "" }
23+
24+type blockingClient struct {
25+ started chan struct{}
26+ release chan struct{}
27+
28+ mu sync.Mutex
29+ active int
30+ maxActive int
31+}
32+
33+func (c *blockingClient) Name() string { return "test" }
34+
35+func (c *blockingClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
36+ c.mu.Lock()
37+ c.active++
38+ if c.active > c.maxActive {
39+ c.maxActive = c.active
40+ }
41+ c.mu.Unlock()
42+
43+ select {
44+ case c.started <- struct{}{}:
45+ default:
46+ }
47+
48+ out := make(chan provider.Event, 1)
49+ go func() {
50+ defer close(out)
51+ defer func() {
52+ c.mu.Lock()
53+ c.active--
54+ c.mu.Unlock()
55+ }()
56+ select {
57+ case <-ctx.Done():
58+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
59+ case <-c.release:
60+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
61+ Role: provider.RoleAssistant,
62+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
63+ }}
64+ }
65+ }()
66+ return out, nil
67+}
68+
69+func (c *blockingClient) max() int {
70+ c.mu.Lock()
71+ defer c.mu.Unlock()
72+ return c.maxActive
73+}
74+
75+func TestHandleMessageClaimsDrainSlotBeforeSpawningDrainer(t *testing.T) {
76+ client := &blockingClient{started: make(chan struct{}, 2), release: make(chan struct{})}
77+ r := NewRunner(testAdapter{}, core.NewAgent(client, "test-model", "", nil), Config{})
78+ r.runCtx = context.Background()
79+
80+ var wg sync.WaitGroup
81+ wg.Add(2)
82+ go func() {
83+ defer wg.Done()
84+ r.handleMessage(InboundMessage{ChannelID: "c", Text: "one"})
85+ }()
86+ go func() {
87+ defer wg.Done()
88+ r.handleMessage(InboundMessage{ChannelID: "c", Text: "two"})
89+ }()
90+ wg.Wait()
91+
92+ select {
93+ case <-client.started:
94+ case <-time.After(2 * time.Second):
95+ t.Fatal("first turn did not start")
96+ }
97+
98+ // Give any accidentally spawned second drainer a chance to enter Stream.
99+ time.Sleep(100 * time.Millisecond)
100+ if got := client.max(); got != 1 {
101+ t.Fatalf("concurrent provider streams = %d, want 1", got)
102+ }
103+
104+ close(client.release)
105+}
@@ -0,0 +1,105 @@
1+package bot
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/provider"
11+)
12+
13+type testAdapter struct{}
14+
15+func (testAdapter) Run(context.Context, func(InboundMessage), func(Command, InboundMessage)) error {
16+ return nil
17+}
18+func (testAdapter) Send(context.Context, string, string, SendOptions) error {
19+ return nil
20+}
21+func (testAdapter) IndicateWorking(context.Context, string) func() { return func() {} }
22+func (testAdapter) StatusText() string { return "" }
23+
24+type blockingClient struct {
25+ started chan struct{}
26+ release chan struct{}
27+
28+ mu sync.Mutex
29+ active int
30+ maxActive int
31+}
32+
33+func (c *blockingClient) Name() string { return "test" }
34+
35+func (c *blockingClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
36+ c.mu.Lock()
37+ c.active++
38+ if c.active > c.maxActive {
39+ c.maxActive = c.active
40+ }
41+ c.mu.Unlock()
42+
43+ select {
44+ case c.started <- struct{}{}:
45+ default:
46+ }
47+
48+ out := make(chan provider.Event, 1)
49+ go func() {
50+ defer close(out)
51+ defer func() {
52+ c.mu.Lock()
53+ c.active--
54+ c.mu.Unlock()
55+ }()
56+ select {
57+ case <-ctx.Done():
58+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
59+ case <-c.release:
60+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
61+ Role: provider.RoleAssistant,
62+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
63+ }}
64+ }
65+ }()
66+ return out, nil
67+}
68+
69+func (c *blockingClient) max() int {
70+ c.mu.Lock()
71+ defer c.mu.Unlock()
72+ return c.maxActive
73+}
74+
75+func TestHandleMessageClaimsDrainSlotBeforeSpawningDrainer(t *testing.T) {
76+ client := &blockingClient{started: make(chan struct{}, 2), release: make(chan struct{})}
77+ r := NewRunner(testAdapter{}, core.NewAgent(client, "test-model", "", nil), Config{})
78+ r.runCtx = context.Background()
79+
80+ var wg sync.WaitGroup
81+ wg.Add(2)
82+ go func() {
83+ defer wg.Done()
84+ r.handleMessage(InboundMessage{ChannelID: "c", Text: "one"})
85+ }()
86+ go func() {
87+ defer wg.Done()
88+ r.handleMessage(InboundMessage{ChannelID: "c", Text: "two"})
89+ }()
90+ wg.Wait()
91+
92+ select {
93+ case <-client.started:
94+ case <-time.After(2 * time.Second):
95+ t.Fatal("first turn did not start")
96+ }
97+
98+ // Give any accidentally spawned second drainer a chance to enter Stream.
99+ time.Sleep(100 * time.Millisecond)
100+ if got := client.max(); got != 1 {
101+ t.Fatalf("concurrent provider streams = %d, want 1", got)
102+ }
103+
104+ close(client.release)
105+}
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 +258 -0
@@ -0,0 +1,258 @@
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, opts bot.SendOptions) 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+ replyTo := 0
200+ if opts.ReplyToMessageID != "" {
201+ if n, err := strconv.Atoi(opts.ReplyToMessageID); err == nil {
202+ replyTo = n
203+ }
204+ }
205+ for _, chunk := range chunkMessage(text, 4000) {
206+ if err := a.Client.SendMessage(ctx, chatID, chunk, replyTo); err != nil {
207+ return err
208+ }
209+ // Only the first chunk should be threaded under the original message.
210+ replyTo = 0
211+ }
212+ return nil
213+}
214+
215+// IndicateWorking keeps Telegram's "typing..." indicator alive until
216+// the returned stop function is called.
217+func (a *Adapter) IndicateWorking(ctx context.Context, channelID string) (stop func()) {
218+ chatID, err := strconv.ParseInt(channelID, 10, 64)
219+ if err != nil {
220+ return func() {}
221+ }
222+ tctx, cancel := context.WithCancel(ctx)
223+ go func() {
224+ for {
225+ _ = a.Client.SendChatAction(tctx, chatID, "typing")
226+ select {
227+ case <-tctx.Done():
228+ return
229+ case <-time.After(4 * time.Second):
230+ }
231+ }
232+ }()
233+ return cancel
234+}
235+
236+// StatusText returns the bot's @username for inclusion in /status.
237+func (a *Adapter) StatusText() string {
238+ if a.Cfg.BotUsername != "" {
239+ return "@" + a.Cfg.BotUsername
240+ }
241+ return ""
242+}
243+
244+// download fetches a file from Telegram and returns bytes + mime.
245+func (a *Adapter) download(ctx context.Context, fileID, mime string) ([]byte, string, error) {
246+ f, err := a.Client.GetFile(ctx, fileID)
247+ if err != nil {
248+ return nil, "", err
249+ }
250+ data, err := a.Client.DownloadFile(ctx, f.FilePath)
251+ if err != nil {
252+ return nil, "", err
253+ }
254+ if mime == "" {
255+ mime = guessImageMIME(f.FilePath)
256+ }
257+ return data, mime, nil
258+}
@@ -0,0 +1,258 @@
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, opts bot.SendOptions) 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+ replyTo := 0
200+ if opts.ReplyToMessageID != "" {
201+ if n, err := strconv.Atoi(opts.ReplyToMessageID); err == nil {
202+ replyTo = n
203+ }
204+ }
205+ for _, chunk := range chunkMessage(text, 4000) {
206+ if err := a.Client.SendMessage(ctx, chatID, chunk, replyTo); err != nil {
207+ return err
208+ }
209+ // Only the first chunk should be threaded under the original message.
210+ replyTo = 0
211+ }
212+ return nil
213+}
214+
215+// IndicateWorking keeps Telegram's "typing..." indicator alive until
216+// the returned stop function is called.
217+func (a *Adapter) IndicateWorking(ctx context.Context, channelID string) (stop func()) {
218+ chatID, err := strconv.ParseInt(channelID, 10, 64)
219+ if err != nil {
220+ return func() {}
221+ }
222+ tctx, cancel := context.WithCancel(ctx)
223+ go func() {
224+ for {
225+ _ = a.Client.SendChatAction(tctx, chatID, "typing")
226+ select {
227+ case <-tctx.Done():
228+ return
229+ case <-time.After(4 * time.Second):
230+ }
231+ }
232+ }()
233+ return cancel
234+}
235+
236+// StatusText returns the bot's @username for inclusion in /status.
237+func (a *Adapter) StatusText() string {
238+ if a.Cfg.BotUsername != "" {
239+ return "@" + a.Cfg.BotUsername
240+ }
241+ return ""
242+}
243+
244+// download fetches a file from Telegram and returns bytes + mime.
245+func (a *Adapter) download(ctx context.Context, fileID, mime string) ([]byte, string, error) {
246+ f, err := a.Client.GetFile(ctx, fileID)
247+ if err != nil {
248+ return nil, "", err
249+ }
250+ data, err := a.Client.DownloadFile(ctx, f.FilePath)
251+ if err != nil {
252+ return nil, "", err
253+ }
254+ if mime == "" {
255+ mime = guessImageMIME(f.FilePath)
256+ }
257+ return data, mime, nil
258+}
M packages/agent/modes/telegram/bot.go +36 -376
@@ -1,408 +1,68 @@
11 package telegram
22
33 import (
4- "context"
5- "fmt"
64 "strings"
7- "sync"
8- "time"
9-
10- "github.com/patriceckhart/zot/packages/core"
11- "github.com/patriceckhart/zot/packages/provider"
5+ "unicode/utf8"
126 )
137
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.
17-type Bot struct {
18- Client *Client
19- Agent *core.Agent
20- Config Config
21- ZotHome string
22- Provider string
23- AuthMethod string
24- 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.
32- RefreshCreds func() error
33-
34- mu sync.Mutex
35- busy bool
36- activeCtx context.CancelFunc
37- queue []queuedTurn
38- lastCtxInput int
39-}
40-
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.
50-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
372-}
373-
3748 // chunkMessage splits s into chunks no larger than limit runes, on line
3759 // boundaries when possible.
37610 func chunkMessage(s string, limit int) []string {
377- if len(s) <= limit {
11+ if limit <= 0 || utf8.RuneCountInString(s) <= limit {
37812 return []string{s}
37913 }
38014 var out []string
38115 lines := strings.Split(s, "\n")
38216 var cur strings.Builder
17+ curRunes := 0
38318 for _, l := range lines {
384- if cur.Len()+len(l)+1 > limit && cur.Len() > 0 {
19+ lineRunes := utf8.RuneCountInString(l)
20+ sepRunes := 0
21+ if curRunes > 0 {
22+ sepRunes = 1
23+ }
24+ if curRunes+sepRunes+lineRunes > limit && curRunes > 0 {
38525 out = append(out, cur.String())
38626 cur.Reset()
27+ curRunes = 0
28+ sepRunes = 0
38729 }
388- if len(l) > limit {
389- // Line itself too long; hard-split.
390- for len(l) > limit {
391- out = append(out, l[:limit])
392- l = l[limit:]
30+ if lineRunes > limit {
31+ // Line itself too long; hard-split on rune boundaries.
32+ for lineRunes > limit {
33+ i := byteIndexAfterRunes(l, limit)
34+ out = append(out, l[:i])
35+ l = l[i:]
36+ lineRunes = utf8.RuneCountInString(l)
39337 }
39438 }
395- if cur.Len() > 0 {
39+ if curRunes > 0 {
39640 cur.WriteString("\n")
41+ curRunes++
39742 }
39843 cur.WriteString(l)
44+ curRunes += utf8.RuneCountInString(l)
39945 }
400- if cur.Len() > 0 {
46+ if curRunes > 0 {
40147 out = append(out, cur.String())
40248 }
40349 return out
40450 }
40551
52+func byteIndexAfterRunes(s string, n int) int {
53+ if n <= 0 {
54+ return 0
55+ }
56+ count := 0
57+ for i := range s {
58+ if count == n {
59+ return i
60+ }
61+ count++
62+ }
63+ return len(s)
64+}
65+
40666 // isImageMIME returns true for MIME types the model can probably ingest
40767 // as a vision input.
40868 func isImageMIME(m string) bool {
@@ -1,408 +1,68 @@
1 package telegram1 package telegram
2 2
3 import (3 import (
4- "context"
5- "fmt"
6 "strings"4 "strings"
7- "sync"5+ "unicode/utf8"
8- "time"
9-
10- "github.com/patriceckhart/zot/packages/core"
11- "github.com/patriceckhart/zot/packages/provider"
12 )6 )
13 7
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.
17-type Bot struct {
18- Client *Client
19- Agent *core.Agent
20- Config Config
21- ZotHome string
22- Provider string
23- AuthMethod string
24- 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.
32- RefreshCreds func() error
33-
34- mu sync.Mutex
35- busy bool
36- activeCtx context.CancelFunc
37- queue []queuedTurn
38- lastCtxInput int
39-}
40-
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.
50-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
372-}
373-
374 // chunkMessage splits s into chunks no larger than limit runes, on line8 // chunkMessage splits s into chunks no larger than limit runes, on line
375 // boundaries when possible.9 // boundaries when possible.
376 func chunkMessage(s string, limit int) []string {10 func chunkMessage(s string, limit int) []string {
377- if len(s) <= limit {11+ if limit <= 0 || utf8.RuneCountInString(s) <= limit {
378 return []string{s}12 return []string{s}
379 }13 }
380 var out []string14 var out []string
381 lines := strings.Split(s, "\n")15 lines := strings.Split(s, "\n")
382 var cur strings.Builder16 var cur strings.Builder
17+ curRunes := 0
383 for _, l := range lines {18 for _, l := range lines {
384- if cur.Len()+len(l)+1 > limit && cur.Len() > 0 {19+ lineRunes := utf8.RuneCountInString(l)
20+ sepRunes := 0
21+ if curRunes > 0 {
22+ sepRunes = 1
23+ }
24+ if curRunes+sepRunes+lineRunes > limit && curRunes > 0 {
385 out = append(out, cur.String())25 out = append(out, cur.String())
386 cur.Reset()26 cur.Reset()
27+ curRunes = 0
28+ sepRunes = 0
387 }29 }
388- if len(l) > limit {30+ if lineRunes > limit {
389- // Line itself too long; hard-split.31+ // Line itself too long; hard-split on rune boundaries.
390- for len(l) > limit {32+ for lineRunes > limit {
391- out = append(out, l[:limit])33+ i := byteIndexAfterRunes(l, limit)
392- l = l[limit:]34+ out = append(out, l[:i])
35+ l = l[i:]
36+ lineRunes = utf8.RuneCountInString(l)
393 }37 }
394 }38 }
395- if cur.Len() > 0 {39+ if curRunes > 0 {
396 cur.WriteString("\n")40 cur.WriteString("\n")
41+ curRunes++
397 }42 }
398 cur.WriteString(l)43 cur.WriteString(l)
44+ curRunes += utf8.RuneCountInString(l)
399 }45 }
400- if cur.Len() > 0 {46+ if curRunes > 0 {
401 out = append(out, cur.String())47 out = append(out, cur.String())
402 }48 }
403 return out49 return out
404 }50 }
405 51
52+func byteIndexAfterRunes(s string, n int) int {
53+ if n <= 0 {
54+ return 0
55+ }
56+ count := 0
57+ for i := range s {
58+ if count == n {
59+ return i
60+ }
61+ count++
62+ }
63+ return len(s)
64+}
65+
406 // isImageMIME returns true for MIME types the model can probably ingest66 // isImageMIME returns true for MIME types the model can probably ingest
407 // as a vision input.67 // as a vision input.
408 func isImageMIME(m string) bool {68 func isImageMIME(m string) bool {
A packages/agent/modes/telegram/bot_test.go +31 -0
@@ -0,0 +1,31 @@
1+package telegram
2+
3+import (
4+ "strings"
5+ "testing"
6+ "unicode/utf8"
7+)
8+
9+func TestChunkMessageUsesRuneLimit(t *testing.T) {
10+ s := strings.Repeat("界", 4001)
11+ chunks := chunkMessage(s, 4000)
12+ if len(chunks) != 2 {
13+ t.Fatalf("len(chunks) = %d, want 2", len(chunks))
14+ }
15+ if got := utf8.RuneCountInString(chunks[0]); got != 4000 {
16+ t.Fatalf("first chunk runes = %d, want 4000", got)
17+ }
18+ if got := utf8.RuneCountInString(chunks[1]); got != 1 {
19+ t.Fatalf("second chunk runes = %d, want 1", got)
20+ }
21+}
22+
23+func TestChunkMessageDoesNotSplitMultiByteRune(t *testing.T) {
24+ chunks := chunkMessage("🙂🙂🙂", 2)
25+ if len(chunks) != 2 {
26+ t.Fatalf("len(chunks) = %d, want 2", len(chunks))
27+ }
28+ if chunks[0] != "🙂🙂" || chunks[1] != "🙂" {
29+ t.Fatalf("chunks = %#v", chunks)
30+ }
31+}
@@ -0,0 +1,31 @@
1+package telegram
2+
3+import (
4+ "strings"
5+ "testing"
6+ "unicode/utf8"
7+)
8+
9+func TestChunkMessageUsesRuneLimit(t *testing.T) {
10+ s := strings.Repeat("界", 4001)
11+ chunks := chunkMessage(s, 4000)
12+ if len(chunks) != 2 {
13+ t.Fatalf("len(chunks) = %d, want 2", len(chunks))
14+ }
15+ if got := utf8.RuneCountInString(chunks[0]); got != 4000 {
16+ t.Fatalf("first chunk runes = %d, want 4000", got)
17+ }
18+ if got := utf8.RuneCountInString(chunks[1]); got != 1 {
19+ t.Fatalf("second chunk runes = %d, want 1", got)
20+ }
21+}
22+
23+func TestChunkMessageDoesNotSplitMultiByteRune(t *testing.T) {
24+ chunks := chunkMessage("🙂🙂🙂", 2)
25+ if len(chunks) != 2 {
26+ t.Fatalf("len(chunks) = %d, want 2", len(chunks))
27+ }
28+ if chunks[0] != "🙂🙂" || chunks[1] != "🙂" {
29+ t.Fatalf("chunks = %#v", chunks)
30+ }
31+}
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