v0.2.76 9559c57

bridge.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close