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
50// State is the snapshot /telegram status reports.
51type State struct {
52 Running bool
53 Username string // bot username, e.g. "zotbot"
54 PairedID int64 // 0 when no user has claimed the bot yet
55}
56
57// Active reports whether the bridge is currently polling.
58func (b *Bridge) Active() bool {
59 if b == nil {
60 return false
61 }
62 b.mu.Lock()
63 defer b.mu.Unlock()
64 return b.running
65}
66
67// State returns a snapshot of the bridge for /telegram status.
68func (b *Bridge) State() State {
69 if b == nil {
70 return State{}
71 }
72 b.mu.Lock()
73 defer b.mu.Unlock()
74 s := State{Running: b.running, PairedID: b.Config.AllowedUserID}
75 if b.me != nil {
76 s.Username = b.me.Username
77 }
78 return s
79}
80
81// Start kicks off the polling loop. Idempotent: calling twice
82// returns nil the second time and leaves the existing loop alone.
83// Verifies the bot token with GetMe before starting the loop so
84// obvious configuration errors surface immediately.
85func (b *Bridge) Start(parent context.Context) error {
86 b.mu.Lock()
87 if b.running {
88 b.mu.Unlock()
89 return nil
90 }
91 if b.Config.BotToken == "" {
92 b.mu.Unlock()
93 return fmt.Errorf("no bot token configured; run `zot telegram-bot setup` first")
94 }
95 b.mu.Unlock()
96
97 ctx, cancel := context.WithCancel(parent)
98 // Quick handshake before committing the loop.
99 me, err := b.Client.GetMe(ctx)
100 if err != nil {
101 cancel()
102 return fmt.Errorf("getMe: %w", err)
103 }
104
105 b.mu.Lock()
106 b.running = true
107 b.cancel = cancel
108 b.me = me
109 if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username {
110 b.Config.BotID = me.ID
111 b.Config.BotUsername = me.Username
112 _ = b.Save(b.Config)
113 }
114 b.mu.Unlock()
115
116 go b.pollLoop(ctx)
117 return nil
118}
119
120// Stop halts the polling loop. Safe to call when not running.
121func (b *Bridge) Stop() {
122 b.mu.Lock()
123 cancel := b.cancel
124 b.running = false
125 b.cancel = nil
126 b.mu.Unlock()
127 if cancel != nil {
128 cancel()
129 }
130}
131
132// OnAssistantText should be called by the TUI with the assistant's
133// final visible text for each turn. The bridge forwards it to the
134// paired chat in message-sized chunks. Safe to call from any
135// goroutine; a no-op when the bridge is stopped or no chat is
136// known yet.
137func (b *Bridge) OnAssistantText(text string) {
138 b.mu.Lock()
139 chatID := b.chatID
140 running := b.running
141 b.mu.Unlock()
142 if !running || chatID == 0 {
143 return
144 }
145 text = strings.TrimSpace(text)
146 if text == "" {
147 return
148 }
149 // Telegram caps at 4096 chars; chunk to be safe.
150 for _, chunk := range chunkMessage(text, 4000) {
151 if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {
152 fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)
153 return
154 }
155 }
156}
157
158// pollLoop long-polls Telegram and dispatches each update. Runs
159// until ctx cancels.
160func (b *Bridge) pollLoop(ctx context.Context) {
161 backoff := time.Second
162 for {
163 if err := ctx.Err(); err != nil {
164 return
165 }
166 updates, err := b.Client.GetUpdates(ctx, b.Config.LastUpdateID+1, 30)
167 if err != nil {
168 if ctx.Err() != nil {
169 return
170 }
171 b.Host.Notify("warn", fmt.Sprintf("telegram: getUpdates: %v", err))
172 select {
173 case <-ctx.Done():
174 return
175 case <-time.After(backoff):
176 }
177 if backoff < 30*time.Second {
178 backoff *= 2
179 }
180 continue
181 }
182 backoff = time.Second
183 for _, u := range updates {
184 b.handleUpdate(ctx, u)
185 b.mu.Lock()
186 b.Config.LastUpdateID = u.UpdateID
187 _ = b.Save(b.Config)
188 b.mu.Unlock()
189 }
190 }
191}
192
193// handleUpdate applies pairing, gates on the allowed user, decodes
194// the interesting bits (text, caption, image attachments), and
195// forwards them to the Host. Built-in slash commands (/start,
196// /help, /status, /stop) are handled inline without touching the
197// agent.
198func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
199 msg := u.Message
200 if msg == nil {
201 msg = u.Edited
202 }
203 if msg == nil || msg.From == nil || msg.From.IsBot {
204 return
205 }
206 if msg.Chat.Type != "private" {
207 return
208 }
209
210 text := strings.TrimSpace(msg.Text)
211
212 // Pairing: first user who sends /start claims the bridge.
213 b.mu.Lock()
214 paired := b.Config.AllowedUserID
215 b.mu.Unlock()
216 if paired == 0 {
217 if strings.HasPrefix(text, "/start") {
218 b.mu.Lock()
219 b.Config.AllowedUserID = msg.From.ID
220 b.chatID = msg.Chat.ID
221 _ = b.Save(b.Config)
222 b.mu.Unlock()
223 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
224 fmt.Sprintf("paired with @%s. messages you send here now mirror into the zot tui.", msg.From.Username),
225 msg.MessageID)
226 b.Host.Notify("success", fmt.Sprintf("telegram paired with user %d (@%s)", msg.From.ID, msg.From.Username))
227 return
228 }
229 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
230 "this bot isn't paired yet. send /start to claim it.",
231 msg.MessageID)
232 return
233 }
234
235 if msg.From.ID != paired {
236 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
237 "this bot is paired with a different user.",
238 msg.MessageID)
239 return
240 }
241
242 // Remember the chat id so replies can go out without waiting
243 // for another update round-trip.
244 b.mu.Lock()
245 b.chatID = msg.Chat.ID
246 b.mu.Unlock()
247
248 // Built-in commands that bypass the agent.
249 switch text {
250 case "/start", "/help":
251 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
252 "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop.",
253 msg.MessageID)
254 return
255 case "/status":
256 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
257 fmt.Sprintf("mirror active. paired user: %d.", paired),
258 msg.MessageID)
259 return
260 case "/stop":
261 b.Host.CancelTurn()
262 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
263 "cancelled the current turn.", msg.MessageID)
264 return
265 }
266
267 // Build the prompt: text + caption; download image attachments.
268 prompt := strings.TrimSpace(msg.Text)
269 if msg.Caption != "" {
270 if prompt != "" {
271 prompt += "\n"
272 }
273 prompt += msg.Caption
274 }
275
276 var images []provider.ImageBlock
277 if len(msg.Photo) > 0 {
278 largest := msg.Photo[len(msg.Photo)-1]
279 if data, mime, err := b.download(ctx, largest.FileID, ""); err == nil {
280 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
281 }
282 }
283 if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
284 if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
285 images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
286 }
287 }
288
289 if prompt == "" && len(images) == 0 {
290 return
291 }
292
293 b.Host.SubmitOrQueue(prompt, images)
294}
295
296// download fetches the file referenced by fileID and returns its
297// bytes + mime type. Mime overrides the detected value when non-empty.
298func (b *Bridge) download(ctx context.Context, fileID, mimeOverride string) ([]byte, string, error) {
299 f, err := b.Client.GetFile(ctx, fileID)
300 if err != nil {
301 return nil, "", err
302 }
303 data, err := b.Client.DownloadFile(ctx, f.FilePath)
304 if err != nil {
305 return nil, "", err
306 }
307 mime := mimeOverride
308 if mime == "" {
309 mime = guessImageMIME(f.FilePath)
310 }
311 return data, mime, nil
312}