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