v0.12.0 0c5f8d5

bridge.go RawHistoryFolder

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

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