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
54 // nextReplyFromTelegram is set when the next assistant reply
55 // should be sent bare (no "zot: " 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 "zot: " 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. "zotbot"
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 `zot 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
118 b.mu.Lock()
119 b.running = true
120 b.cancel = cancel
121 b.me = me
122 // Telegram private-chat ids are the same as the user id, so if
123 // we've already paired in a previous session we can send to the
124 // user immediately without waiting for them to DM first.
125 if b.Config.AllowedUserID != 0 && b.chatID == 0 {
126 b.chatID = b.Config.AllowedUserID
127 }
128 if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username {
129 b.Config.BotID = me.ID
130 b.Config.BotUsername = me.Username
131 _ = b.Save(b.Config)
132 }
133 b.mu.Unlock()
134
135 go b.pollLoop(ctx)
136 return nil
137}
138
139// Stop halts the polling loop. Safe to call when not running.
140func (b *Bridge) Stop() {
141 b.mu.Lock()
142 cancel := b.cancel
143 b.running = false
144 b.cancel = nil
145 b.mu.Unlock()
146 if cancel != nil {
147 cancel()
148 }
149}
150
151// OnAssistantText should be called by the TUI with the assistant's
152// final visible text for each turn. The bridge forwards it to the
153// paired chat in message-sized chunks. Prefix depends on which
154// side initiated the turn: TUI-originated turns get "zot: " so the
155// two-sided transcript reads naturally ("you: ..." / "zot: ..."),
156// while Telegram-originated turns send bare text (the user's own
157// bubble is already on-screen, a "zot: " prefix would just add
158// visual noise to a plain back-and-forth).
159func (b *Bridge) OnAssistantText(text string) {
160 b.mu.Lock()
161 // prefix := "zot: "
162 prefix := ""
163 if b.nextReplyFromTelegram {
164 prefix = ""
165 b.nextReplyFromTelegram = false
166 }
167 b.mu.Unlock()
168 b.sendToPaired(text, prefix)
169}
170
171// OnUserTyped mirrors a message the user typed in the zot TUI into
172// the paired Telegram chat, tagged "you:" so the Telegram thread
173// stays a complete record of the conversation (both TUI-originated
174// and Telegram-originated turns). Messages sent from Telegram
175// itself aren't mirrored back (they already appear as the user's
176// own bubble), only TUI-originated prompts flow through here.
177func (b *Bridge) OnUserTyped(text string) {
178 b.sendToPaired(text, "you: ")
179}
180
181// sendToPaired writes text (with an optional prefix, chunked to
182// Telegram's 4096-char cap) to the paired chat. No-op when the
183// bridge is stopped or before the paired chat id is known.
184func (b *Bridge) sendToPaired(text, prefix string) {
185 b.mu.Lock()
186 chatID := b.chatID
187 running := b.running
188 b.mu.Unlock()
189 if !running || chatID == 0 {
190 return
191 }
192 text = strings.TrimSpace(text)
193 if text == "" {
194 return
195 }
196 if prefix != "" {
197 text = prefix + text
198 }
199 for _, chunk := range bot.ChunkMessage(text, 4000) {
200 if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {
201 fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)
202 return
203 }
204 }
205}
206
207// SendImage uploads path to the paired Telegram chat as an inline
208// photo. Returns an error if the bridge is not running, no user has
209// paired yet, or the upload itself fails. Used by the
210// telegram_send_image tool so a Telegram-originated turn can yield
211// a real image instead of a textual description.
212func (b *Bridge) SendImage(ctx context.Context, path, caption string) error {
213 b.mu.Lock()
214 chatID := b.chatID
215 running := b.running
216 b.mu.Unlock()
217 if !running {
218 return fmt.Errorf("telegram bridge is not running")
219 }
220 if chatID == 0 {
221 return fmt.Errorf("telegram bridge has no paired chat yet")
222 }
223 return b.Client.SendPhoto(ctx, chatID, path, caption)
224}
225
226// SendDocument uploads path to the paired Telegram chat as a raw
227// document attachment (no compression). Counterpart of SendImage for
228// the telegram_send_file tool.
229func (b *Bridge) SendDocument(ctx context.Context, path, caption string) error {
230 b.mu.Lock()
231 chatID := b.chatID
232 running := b.running
233 b.mu.Unlock()
234 if !running {
235 return fmt.Errorf("telegram bridge is not running")
236 }
237 if chatID == 0 {
238 return fmt.Errorf("telegram bridge has no paired chat yet")
239 }
240 return b.Client.SendDocument(ctx, chatID, path, caption)
241}
242
243// pollLoop long-polls Telegram and dispatches each update. Runs
244// until ctx cancels.
245func (b *Bridge) pollLoop(ctx context.Context) {
246 backoff := time.Second
247 for {
248 if err := ctx.Err(); err != nil {
249 return
250 }
251 updates, err := b.Client.GetUpdates(ctx, b.Config.LastUpdateID+1, 30)
252 if err != nil {
253 if ctx.Err() != nil {
254 return
255 }
256 b.Host.Notify("warn", fmt.Sprintf("telegram: getUpdates: %v", err))
257 select {
258 case <-ctx.Done():
259 return
260 case <-time.After(backoff):
261 }
262 if backoff < 30*time.Second {
263 backoff *= 2
264 }
265 continue
266 }
267 backoff = time.Second
268 for _, u := range updates {
269 b.handleUpdate(ctx, u)
270 b.mu.Lock()
271 b.Config.LastUpdateID = u.UpdateID
272 _ = b.Save(b.Config)
273 b.mu.Unlock()
274 }
275 }
276}
277
278// handleUpdate applies pairing, gates on the allowed user, decodes
279// the interesting bits (text, caption, image attachments), and
280// forwards them to the Host. Built-in commands (/start, /help,
281// /status, /stop, and plain "stop") are handled inline without
282// touching the agent.
283func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
284 msg := u.Message
285 if msg == nil {
286 msg = u.Edited
287 }
288 if msg == nil || msg.From == nil || msg.From.IsBot {
289 return
290 }
291 if msg.Chat.Type != "private" {
292 return
293 }
294
295 text := strings.TrimSpace(msg.Text)
296
297 // Pairing: first user who sends /start claims the bridge.
298 b.mu.Lock()
299 paired := b.Config.AllowedUserID
300 b.mu.Unlock()
301 if paired == 0 {
302 if strings.HasPrefix(text, "/start") {
303 b.mu.Lock()
304 b.Config.AllowedUserID = msg.From.ID
305 b.chatID = msg.Chat.ID
306 _ = b.Save(b.Config)
307 b.mu.Unlock()
308 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
309 fmt.Sprintf("paired with @%s. messages you send here now mirror into the zot tui.", msg.From.Username),
310 msg.MessageID)
311 b.Host.Notify("success", fmt.Sprintf("telegram paired with user %d (@%s)", msg.From.ID, msg.From.Username))
312 return
313 }
314 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
315 "this bot isn't paired yet. send /start to claim it.",
316 msg.MessageID)
317 return
318 }
319
320 if msg.From.ID != paired {
321 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
322 "this bot is paired with a different user.",
323 msg.MessageID)
324 return
325 }
326
327 // Remember the chat id so replies can go out without waiting
328 // for another update round-trip.
329 b.mu.Lock()
330 b.chatID = msg.Chat.ID
331 b.mu.Unlock()
332
333 // Built-in commands that bypass the agent.
334 switch text {
335 case "/start", "/help":
336 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
337 "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.",
338 msg.MessageID)
339 return
340 case "/status":
341 _ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID)
342 return
343 case "/stop":
344 b.Host.CancelTurn()
345 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
346 "cancelled the current turn.", msg.MessageID)
347 return
348 }
349 if isStopCommand(text) {
350 b.Host.CancelTurn()
351 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
352 "cancelled the current turn.", msg.MessageID)
353 return
354 }
355
356 // Build the prompt: text + caption; download image attachments.
357 prompt := strings.TrimSpace(msg.Text)
358 if msg.Caption != "" {
359 if prompt != "" {
360 prompt += "\n"
361 }
362 prompt += msg.Caption
363 }
364
365 var images []provider.ImageBlock
366 if len(msg.Photo) > 0 {
367 largest := msg.Photo[len(msg.Photo)-1]
368 if data, mime, err := b.download(ctx, largest.FileID, ""); err == nil {
369 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
370 }
371 }
372 if msg.Document != nil && bot.IsImageMIME(msg.Document.MimeType) {
373 if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
374 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
375 }
376 }
377
378 if prompt == "" && len(images) == 0 {
379 return
380 }
381
382 b.mu.Lock()
383 b.nextReplyFromTelegram = true
384 b.mu.Unlock()
385 b.Host.SubmitOrQueue(prompt, images)
386}
387
388// download fetches the file referenced by fileID and returns its
389// bytes + mime type. Mime overrides the detected value when non-empty.
390func (b *Bridge) download(ctx context.Context, fileID, mimeOverride string) ([]byte, string, error) {
391 f, err := b.Client.GetFile(ctx, fileID)
392 if err != nil {
393 return nil, "", err
394 }
395 data, err := b.Client.DownloadFile(ctx, f.FilePath)
396 if err != nil {
397 return nil, "", err
398 }
399 mime := mimeOverride
400 if mime == "" {
401 mime = bot.GuessImageMIME(f.FilePath)
402 }
403 return data, mime, nil
404}