v0.0.54 098a797

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
50// State is the snapshot /telegram status reports.
51type State struct {
52	Running  bool
53	Username string // bot username, e.g. "zotbot"
54	PairedID int64  // 0 when no user has claimed the bot yet
55}
56
57// Active reports whether the bridge is currently polling.
58func (b *Bridge) Active() bool {
59	if b == nil {
60		return false
61	}
62	b.mu.Lock()
63	defer b.mu.Unlock()
64	return b.running
65}
66
67// State returns a snapshot of the bridge for /telegram status.
68func (b *Bridge) State() State {
69	if b == nil {
70		return State{}
71	}
72	b.mu.Lock()
73	defer b.mu.Unlock()
74	s := State{Running: b.running, PairedID: b.Config.AllowedUserID}
75	if b.me != nil {
76		s.Username = b.me.Username
77	}
78	return s
79}
80
81// Start kicks off the polling loop. Idempotent: calling twice
82// returns nil the second time and leaves the existing loop alone.
83// Verifies the bot token with GetMe before starting the loop so
84// obvious configuration errors surface immediately.
85func (b *Bridge) Start(parent context.Context) error {
86	b.mu.Lock()
87	if b.running {
88		b.mu.Unlock()
89		return nil
90	}
91	if b.Config.BotToken == "" {
92		b.mu.Unlock()
93		return fmt.Errorf("no bot token configured; run `zot telegram-bot setup` first")
94	}
95	b.mu.Unlock()
96
97	ctx, cancel := context.WithCancel(parent)
98	// Quick handshake before committing the loop.
99	me, err := b.Client.GetMe(ctx)
100	if err != nil {
101		cancel()
102		return fmt.Errorf("getMe: %w", err)
103	}
104
105	b.mu.Lock()
106	b.running = true
107	b.cancel = cancel
108	b.me = me
109	if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username {
110		b.Config.BotID = me.ID
111		b.Config.BotUsername = me.Username
112		_ = b.Save(b.Config)
113	}
114	b.mu.Unlock()
115
116	go b.pollLoop(ctx)
117	return nil
118}
119
120// Stop halts the polling loop. Safe to call when not running.
121func (b *Bridge) Stop() {
122	b.mu.Lock()
123	cancel := b.cancel
124	b.running = false
125	b.cancel = nil
126	b.mu.Unlock()
127	if cancel != nil {
128		cancel()
129	}
130}
131
132// OnAssistantText should be called by the TUI with the assistant's
133// final visible text for each turn. The bridge forwards it to the
134// paired chat in message-sized chunks. Safe to call from any
135// goroutine; a no-op when the bridge is stopped or no chat is
136// known yet.
137func (b *Bridge) OnAssistantText(text string) {
138	b.mu.Lock()
139	chatID := b.chatID
140	running := b.running
141	b.mu.Unlock()
142	if !running || chatID == 0 {
143		return
144	}
145	text = strings.TrimSpace(text)
146	if text == "" {
147		return
148	}
149	// Telegram caps at 4096 chars; chunk to be safe.
150	for _, chunk := range chunkMessage(text, 4000) {
151		if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {
152			fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)
153			return
154		}
155	}
156}
157
158// pollLoop long-polls Telegram and dispatches each update. Runs
159// until ctx cancels.
160func (b *Bridge) pollLoop(ctx context.Context) {
161	backoff := time.Second
162	for {
163		if err := ctx.Err(); err != nil {
164			return
165		}
166		updates, err := b.Client.GetUpdates(ctx, b.Config.LastUpdateID+1, 30)
167		if err != nil {
168			if ctx.Err() != nil {
169				return
170			}
171			b.Host.Notify("warn", fmt.Sprintf("telegram: getUpdates: %v", err))
172			select {
173			case <-ctx.Done():
174				return
175			case <-time.After(backoff):
176			}
177			if backoff < 30*time.Second {
178				backoff *= 2
179			}
180			continue
181		}
182		backoff = time.Second
183		for _, u := range updates {
184			b.handleUpdate(ctx, u)
185			b.mu.Lock()
186			b.Config.LastUpdateID = u.UpdateID
187			_ = b.Save(b.Config)
188			b.mu.Unlock()
189		}
190	}
191}
192
193// handleUpdate applies pairing, gates on the allowed user, decodes
194// the interesting bits (text, caption, image attachments), and
195// forwards them to the Host. Built-in slash commands (/start,
196// /help, /status, /stop) are handled inline without touching the
197// agent.
198func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
199	msg := u.Message
200	if msg == nil {
201		msg = u.Edited
202	}
203	if msg == nil || msg.From == nil || msg.From.IsBot {
204		return
205	}
206	if msg.Chat.Type != "private" {
207		return
208	}
209
210	text := strings.TrimSpace(msg.Text)
211
212	// Pairing: first user who sends /start claims the bridge.
213	b.mu.Lock()
214	paired := b.Config.AllowedUserID
215	b.mu.Unlock()
216	if paired == 0 {
217		if strings.HasPrefix(text, "/start") {
218			b.mu.Lock()
219			b.Config.AllowedUserID = msg.From.ID
220			b.chatID = msg.Chat.ID
221			_ = b.Save(b.Config)
222			b.mu.Unlock()
223			_ = b.Client.SendMessage(ctx, msg.Chat.ID,
224				fmt.Sprintf("paired with @%s. messages you send here now mirror into the zot tui.", msg.From.Username),
225				msg.MessageID)
226			b.Host.Notify("success", fmt.Sprintf("telegram paired with user %d (@%s)", msg.From.ID, msg.From.Username))
227			return
228		}
229		_ = b.Client.SendMessage(ctx, msg.Chat.ID,
230			"this bot isn't paired yet. send /start to claim it.",
231			msg.MessageID)
232		return
233	}
234
235	if msg.From.ID != paired {
236		_ = b.Client.SendMessage(ctx, msg.Chat.ID,
237			"this bot is paired with a different user.",
238			msg.MessageID)
239		return
240	}
241
242	// Remember the chat id so replies can go out without waiting
243	// for another update round-trip.
244	b.mu.Lock()
245	b.chatID = msg.Chat.ID
246	b.mu.Unlock()
247
248	// Built-in commands that bypass the agent.
249	switch text {
250	case "/start", "/help":
251		_ = b.Client.SendMessage(ctx, msg.Chat.ID,
252			"mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop.",
253			msg.MessageID)
254		return
255	case "/status":
256		_ = b.Client.SendMessage(ctx, msg.Chat.ID,
257			fmt.Sprintf("mirror active. paired user: %d.", paired),
258			msg.MessageID)
259		return
260	case "/stop":
261		b.Host.CancelTurn()
262		_ = b.Client.SendMessage(ctx, msg.Chat.ID,
263			"cancelled the current turn.", msg.MessageID)
264		return
265	}
266
267	// Build the prompt: text + caption; download image attachments.
268	prompt := strings.TrimSpace(msg.Text)
269	if msg.Caption != "" {
270		if prompt != "" {
271			prompt += "\n"
272		}
273		prompt += msg.Caption
274	}
275
276	var images []provider.ImageBlock
277	if len(msg.Photo) > 0 {
278		largest := msg.Photo[len(msg.Photo)-1]
279		if data, mime, err := b.download(ctx, largest.FileID, ""); err == nil {
280			images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
281		}
282	}
283	if msg.Document != nil && isImageMIME(msg.Document.MimeType) {
284		if data, mime, err := b.download(ctx, msg.Document.FileID, msg.Document.MimeType); err == nil {
285			images = append(images, provider.ImageBlock{MimeType: mime, Data: data})
286		}
287	}
288
289	if prompt == "" && len(images) == 0 {
290		return
291	}
292
293	b.Host.SubmitOrQueue(prompt, images)
294}
295
296// download fetches the file referenced by fileID and returns its
297// bytes + mime type. Mime overrides the detected value when non-empty.
298func (b *Bridge) download(ctx context.Context, fileID, mimeOverride string) ([]byte, string, error) {
299	f, err := b.Client.GetFile(ctx, fileID)
300	if err != nil {
301		return nil, "", err
302	}
303	data, err := b.Client.DownloadFile(ctx, f.FilePath)
304	if err != nil {
305		return nil, "", err
306	}
307	mime := mimeOverride
308	if mime == "" {
309		mime = guessImageMIME(f.FilePath)
310	}
311	return data, mime, nil
312}

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