1package telegram
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8 "time"
9
10 "github.com/bnema/zut/packages/agent/modes/bot"
11 "github.com/bnema/zut/packages/provider"
12)
13
14// Host is the small interface the Bridge calls back into the TUI
15// through. Decouples bridge plumbing from the Interactive type.
16type Host interface {
17 // SubmitOrQueue feeds a user prompt into the running agent.
18 // Runs now if the agent is idle, queues behind any in-flight
19 // turn otherwise.
20 SubmitOrQueue(prompt string, images []provider.ImageBlock)
21
22 // CancelTurn aborts the active turn (if any). Called when the
23 // paired Telegram user sends /stop or plain "stop".
24 CancelTurn()
25
26 // Status returns the current model, usage, context, and cwd summary
27 // shown when the paired Telegram user sends /status.
28 Status() string
29
30 // Notify pushes a one-shot status line into the chat. Used to
31 // surface bridge events ("connected as @bot", "paired with
32 // user X", etc.) in the user's local transcript.
33 Notify(level, message string)
34}
35
36// Bridge polls Telegram for updates and forwards them into the
37// Host's running agent, then mirrors the agent's final assistant
38// text back to the paired Telegram user. One bridge per Interactive
39// instance; created on /telegram connect, stopped on /telegram
40// disconnect or zut exit.
41type Bridge struct {
42 Client *Client
43 Config Config
44 Save func(Config) error
45 Host Host
46
47 mu sync.Mutex
48 running bool
49 cancel context.CancelFunc
50 me *User
51 chatID int64 // populated after first DM from the paired user
52 workingCancel context.CancelFunc
53
54 // nextReplyFromTelegram is set when the next assistant reply
55 // should be sent bare (no "zut: " prefix) because the turn was
56 // initiated by a Telegram DM. The flag clears as soon as the
57 // reply is flushed. TUI-originated turns leave the flag false
58 // so the reply is tagged "zut: " for clarity on the two-sided
59 // transcript.
60 nextReplyFromTelegram bool
61}
62
63// State is the snapshot /telegram status reports.
64type State struct {
65 Running bool
66 Username string // bot username, e.g. "zutbot"
67 PairedID int64 // 0 when no user has claimed the bot yet
68}
69
70// Active reports whether the bridge is currently polling.
71func (b *Bridge) Active() bool {
72 if b == nil {
73 return false
74 }
75 b.mu.Lock()
76 defer b.mu.Unlock()
77 return b.running
78}
79
80// State returns a snapshot of the bridge for /telegram status.
81func (b *Bridge) State() State {
82 if b == nil {
83 return State{}
84 }
85 b.mu.Lock()
86 defer b.mu.Unlock()
87 s := State{Running: b.running, PairedID: b.Config.AllowedUserID}
88 if b.me != nil {
89 s.Username = b.me.Username
90 }
91 return s
92}
93
94// Start kicks off the polling loop. Idempotent: calling twice
95// returns nil the second time and leaves the existing loop alone.
96// Verifies the bot token with GetMe before starting the loop so
97// obvious configuration errors surface immediately.
98func (b *Bridge) Start(parent context.Context) error {
99 b.mu.Lock()
100 if b.running {
101 b.mu.Unlock()
102 return nil
103 }
104 if b.Config.BotToken == "" {
105 b.mu.Unlock()
106 return fmt.Errorf("no bot token configured; run `zut telegram-bot setup` first")
107 }
108 b.mu.Unlock()
109
110 ctx, cancel := context.WithCancel(parent)
111 // Quick handshake before committing the loop.
112 me, err := b.Client.GetMe(ctx)
113 if err != nil {
114 cancel()
115 return fmt.Errorf("getMe: %w", err)
116 }
117 if err := b.Client.DeleteWebhook(ctx, false); err != nil {
118 cancel()
119 return fmt.Errorf("remove telegram webhook before polling: %w", err)
120 }
121
122 b.mu.Lock()
123 b.running = true
124 b.cancel = cancel
125 b.me = me
126 // Telegram private-chat ids are the same as the user id, so if
127 // we've already paired in a previous session we can send to the
128 // user immediately without waiting for them to DM first.
129 if b.Config.AllowedUserID != 0 && b.chatID == 0 {
130 b.chatID = b.Config.AllowedUserID
131 }
132 if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username {
133 b.Config.BotID = me.ID
134 b.Config.BotUsername = me.Username
135 _ = b.Save(b.Config)
136 }
137 b.mu.Unlock()
138
139 go b.pollLoop(ctx)
140 return nil
141}
142
143// Stop halts the polling loop. Safe to call when not running.
144func (b *Bridge) Stop() {
145 b.mu.Lock()
146 cancel := b.cancel
147 workingCancel := b.workingCancel
148 b.running = false
149 b.cancel = nil
150 b.workingCancel = nil
151 b.mu.Unlock()
152 if workingCancel != nil {
153 workingCancel()
154 }
155 if cancel != nil {
156 cancel()
157 }
158}
159
160// OnAssistantText should be called by the TUI with the assistant's
161// final visible text for each turn. The bridge forwards it to the
162// paired chat in message-sized chunks. Prefix depends on which
163// side initiated the turn: TUI-originated turns get "zut: " so the
164// two-sided transcript reads naturally ("you: ..." / "zut: ..."),
165// while Telegram-originated turns send bare text (the user's own
166// bubble is already on-screen, a "zut: " prefix would just add
167// visual noise to a plain back-and-forth).
168func (b *Bridge) OnAssistantText(text string) {
169 b.mu.Lock()
170 // prefix := "zut: "
171 prefix := ""
172 workingCancel := b.workingCancel
173 b.workingCancel = nil
174 if b.nextReplyFromTelegram {
175 prefix = ""
176 b.nextReplyFromTelegram = false
177 }
178 b.mu.Unlock()
179 if workingCancel != nil {
180 workingCancel()
181 }
182 b.sendToPaired(text, prefix)
183}
184
185// OnUserTyped mirrors a message the user typed in the zut TUI into
186// the paired Telegram chat, tagged "you:" so the Telegram thread
187// stays a complete record of the conversation (both TUI-originated
188// and Telegram-originated turns). Messages sent from Telegram
189// itself aren't mirrored back (they already appear as the user's
190// own bubble), only TUI-originated prompts flow through here.
191func (b *Bridge) OnUserTyped(text string) {
192 b.sendToPaired(text, "you: ")
193}
194
195// indicateWorking keeps Telegram's typing indicator visible while a turn
196// submitted from Telegram is waiting or running. Telegram clears chat actions
197// after a few seconds, so refresh it until the reply is ready or the bridge
198// stops.
199func (b *Bridge) indicateWorking(ctx context.Context, chatID int64) {
200 workingCtx, cancel := context.WithCancel(ctx)
201
202 b.mu.Lock()
203 previous := b.workingCancel
204 b.workingCancel = cancel
205 b.mu.Unlock()
206 if previous != nil {
207 previous()
208 }
209
210 go func() {
211 ticker := time.NewTicker(4 * time.Second)
212 defer ticker.Stop()
213 for {
214 _ = b.Client.SendChatAction(workingCtx, chatID, "typing")
215 select {
216 case <-workingCtx.Done():
217 return
218 case <-ticker.C:
219 }
220 }
221 }()
222}
223
224// stopWorking clears the local typing-indicator refresh loop. Telegram removes
225// the visible indicator itself when the next message arrives or it expires.
226func (b *Bridge) stopWorking() {
227 b.mu.Lock()
228 cancel := b.workingCancel
229 b.workingCancel = nil
230 b.mu.Unlock()
231 if cancel != nil {
232 cancel()
233 }
234}
235
236// sendToPaired writes text (with an optional prefix, chunked to
237// Telegram's 4096-char cap) to the paired chat. No-op when the
238// bridge is stopped or before the paired chat id is known.
239func (b *Bridge) sendToPaired(text, prefix string) {
240 b.mu.Lock()
241 chatID := b.chatID
242 running := b.running
243 b.mu.Unlock()
244 if !running || chatID == 0 {
245 return
246 }
247 text = strings.TrimSpace(text)
248 if text == "" {
249 return
250 }
251 if prefix != "" {
252 text = prefix + text
253 }
254 for _, chunk := range bot.ChunkMessage(text, 4000) {
255 if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {
256 fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)
257 return
258 }
259 }
260}
261
262// SendImage uploads path to the paired Telegram chat as an inline
263// photo. Returns an error if the bridge is not running, no user has
264// paired yet, or the upload itself fails. Used by the
265// telegram_send_image tool so a Telegram-originated turn can yield
266// a real image instead of a textual description.
267func (b *Bridge) SendImage(ctx context.Context, path, caption string) error {
268 b.mu.Lock()
269 chatID := b.chatID
270 running := b.running
271 b.mu.Unlock()
272 if !running {
273 return fmt.Errorf("telegram bridge is not running")
274 }
275 if chatID == 0 {
276 return fmt.Errorf("telegram bridge has no paired chat yet")
277 }
278 return b.Client.SendPhoto(ctx, chatID, path, caption)
279}
280
281// SendDocument uploads path to the paired Telegram chat as a raw
282// document attachment (no compression). Counterpart of SendImage for
283// the telegram_send_file tool.
284func (b *Bridge) SendDocument(ctx context.Context, path, caption string) error {
285 b.mu.Lock()
286 chatID := b.chatID
287 running := b.running
288 b.mu.Unlock()
289 if !running {
290 return fmt.Errorf("telegram bridge is not running")
291 }
292 if chatID == 0 {
293 return fmt.Errorf("telegram bridge has no paired chat yet")
294 }
295 return b.Client.SendDocument(ctx, chatID, path, caption)
296}
297
298// pollLoop long-polls Telegram and dispatches each update. Runs
299// until ctx cancels.
300func (b *Bridge) pollLoop(ctx context.Context) {
301 backoff := time.Second
302 for {
303 if err := ctx.Err(); err != nil {
304 return
305 }
306 updates, err := b.Client.GetUpdates(ctx, b.Config.LastUpdateID+1, 30)
307 if err != nil {
308 if ctx.Err() != nil {
309 return
310 }
311 b.Host.Notify("warn", fmt.Sprintf("telegram: getUpdates: %v", err))
312 select {
313 case <-ctx.Done():
314 return
315 case <-time.After(backoff):
316 }
317 if backoff < 30*time.Second {
318 backoff *= 2
319 }
320 continue
321 }
322 backoff = time.Second
323 for _, u := range updates {
324 b.handleUpdate(ctx, u)
325 b.mu.Lock()
326 b.Config.LastUpdateID = u.UpdateID
327 _ = b.Save(b.Config)
328 b.mu.Unlock()
329 }
330 }
331}
332
333// handleUpdate applies pairing, gates on the allowed user, decodes
334// the interesting bits (text, caption, image attachments), and
335// forwards them to the Host. Built-in commands (/start, /help,
336// /status, /stop, and plain "stop") are handled inline without
337// touching the agent.
338func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
339 msg := u.Message
340 if msg == nil {
341 msg = u.Edited
342 }
343 if msg == nil || msg.From == nil || msg.From.IsBot {
344 return
345 }
346 if msg.Chat.Type != "private" {
347 return
348 }
349
350 text := strings.TrimSpace(msg.Text)
351 command, isCommand := bot.ParseCommand(text)
352
353 // Pairing: first user who sends /start claims the bridge.
354 b.mu.Lock()
355 paired := b.Config.AllowedUserID
356 b.mu.Unlock()
357 if paired == 0 {
358 if isCommand && command == bot.CmdStart {
359 b.mu.Lock()
360 b.Config.AllowedUserID = msg.From.ID
361 b.chatID = msg.Chat.ID
362 _ = b.Save(b.Config)
363 b.mu.Unlock()
364 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
365 fmt.Sprintf("paired with @%s. messages you send here now mirror into the zut tui.", msg.From.Username),
366 msg.MessageID)
367 b.Host.Notify("success", fmt.Sprintf("telegram paired with user %d (@%s)", msg.From.ID, msg.From.Username))
368 return
369 }
370 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
371 "this bot isn't paired yet. send /start to claim it.",
372 msg.MessageID)
373 return
374 }
375
376 if msg.From.ID != paired {
377 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
378 "this bot is paired with a different user.",
379 msg.MessageID)
380 return
381 }
382
383 // Remember the chat id so replies can go out without waiting
384 // for another update round-trip.
385 b.mu.Lock()
386 b.chatID = msg.Chat.ID
387 b.mu.Unlock()
388
389 // Built-in commands that bypass the agent.
390 if isCommand {
391 switch command {
392 case bot.CmdStart, bot.CmdHelp:
393 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
394 "mirror is active. send me a message and it'll be forwarded to the zut tui. commands: /status, /stop, or plain stop.",
395 msg.MessageID)
396 case bot.CmdStatus:
397 _ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID)
398 case bot.CmdStop:
399 b.Host.CancelTurn()
400 b.stopWorking()
401 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
402 "cancelled the current turn.", msg.MessageID)
403 }
404 return
405 }
406 if isStopCommand(text) {
407 b.Host.CancelTurn()
408 b.stopWorking()
409 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
410 "cancelled the current turn.", msg.MessageID)
411 return
412 }
413
414 // Build the prompt: text + caption; download image attachments.
415 prompt := strings.TrimSpace(msg.Text)
416 if msg.Caption != "" {
417 if prompt != "" {
418 prompt += "\n"
419 }
420 prompt += msg.Caption
421 }
422
423 var images []provider.ImageBlock
424 if len(msg.Photo) > 0 {
425 largest := msg.Photo[len(msg.Photo)-1]
426 if data, mime, err := b.download(ctx, largest.FileID, ""); err == nil {
427 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
428 }
429 }
430 if msg.Document != nil && bot.IsImageMIME(msg.Document.MimeType) {
431 if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
432 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
433 }
434 }
435
436 if prompt == "" && len(images) == 0 {
437 return
438 }
439
440 b.mu.Lock()
441 b.nextReplyFromTelegram = true
442 b.mu.Unlock()
443 b.indicateWorking(ctx, msg.Chat.ID)
444 b.Host.SubmitOrQueue(prompt, images)
445}
446
447// download fetches the file referenced by fileID and returns its
448// bytes + mime type. Mime overrides the detected value when non-empty.
449func (b *Bridge) download(ctx context.Context, fileID, mimeOverride string) ([]byte, string, error) {
450 f, err := b.Client.GetFile(ctx, fileID)
451 if err != nil {
452 return nil, "", err
453 }
454 data, err := b.Client.DownloadFile(ctx, f.FilePath)
455 if err != nil {
456 return nil, "", err
457 }
458 mime := mimeOverride
459 if mime == "" {
460 mime = bot.GuessImageMIME(f.FilePath)
461 }
462 return data, mime, nil
463}