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