v0.1.88 14ffdb6

bridge.go RawHistoryFolder

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

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