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