v0.0.89 8d3b7ff

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

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