v0.3.57 169b025

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

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 476

↑↓ moveenter openesc close