v0.2.71 c18646e

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

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