detached cead9fd

telegram: let zot send images and files back through the bridge

The bridge already mirrored the assistant's text reply into the
paired Telegram chat but had no way to push real attachments. A
turn that came in over Telegram could only ever produce a textual
description of an image, never the image itself.

Add two model-facing tools, registered on the running agent only
while the bridge is connected:

- telegram_send_image(path, caption?) uploads a local image
  (png/jpg/gif/webp) as an inline Telegram photo. Telegram
  compresses for preview, which is what you usually want for a
  screenshot or chart.

- telegram_send_file(path, caption?) uploads any local file as a
  document attachment with no compression. Use for non-images or
  when the recipient needs the original bytes.

Plumbing:

- Client.SendPhoto multipart upload mirrors SendDocument, hitting
  sendPhoto so Telegram renders the image inline.
- Bridge.SendImage / SendDocument resolve the paired chat id and
  return a clear error when the bridge is not running or no user
  has paired yet.
- A small TelegramSender interface in package tools keeps the
  tools package free of any telegram dependency; an adapter in
  interactive.go forwards to the live *telegram.Bridge.
- applyTelegramTools mutates the running agent's tool registry on
  /telegram connect / disconnect, on /model swaps, and on login
  rebuilds. Walks the live registry rather than restoring from a
  snapshot so extension or /reload-ext additions survive a later
  disconnect; we only add or strip the two telegram entries.

Both tools respect the sandbox, refuse non-image inputs in
send_image, and reject directories. They return a one-line text
result the model can use to confirm the upload ("sent /path/foo.png
to telegram (1.2 MB)").
commit
cead9fdff1342c0e6065ad422a2caeea756186ff
author
patriceckhart · · 2026-04-28 06:23 UTC
parent
998a062
signature
unsigned
refs
tag: v0.1.34
4 files · +303 -1
M internal/agent/modes/interactive.go +65 -0
@@ -2502,6 +2502,11 @@ func (i *Interactive) applyModelSelection(prov, model string) {
25022502 // identical messages will reuse the existing entries. Nothing
25032503 // to invalidate.
25042504 i.mu.Unlock()
2505+ // The new agent was built off the base tool registry, so any
2506+ // dynamically-registered tools (telegram_send_*) need to be
2507+ // reattached. applyTelegramTools is a no-op when the bridge is
2508+ // idle so the cross-provider path still works on a vanilla setup.
2509+ i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
25052510 if i.cfg.PersistModel != nil {
25062511 i.cfg.PersistModel(p, md)
25072512 }
@@ -2529,6 +2534,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
25292534 i.statusErr = ""
25302535 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
25312536 i.mu.Unlock()
2537+ i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
25322538 i.dialog.ShowResult(true, "")
25332539 }
25342540 }
@@ -3091,6 +3097,7 @@ func (i *Interactive) telegramConnect() {
30913097 i.invalidate()
30923098 return
30933099 }
3100+ i.applyTelegramTools(true)
30943101 state := i.telegramBridge.State()
30953102 label := "telegram connected"
30963103 if state.Username != "" {
@@ -3117,6 +3124,7 @@ func (i *Interactive) telegramDisconnect() {
31173124 return
31183125 }
31193126 i.telegramBridge.Stop()
3127+ i.applyTelegramTools(false)
31203128 i.mu.Lock()
31213129 i.statusOK = "telegram disconnected"
31223130 i.statusErr = ""
@@ -3124,6 +3132,63 @@ func (i *Interactive) telegramDisconnect() {
31243132 i.invalidate()
31253133 }
31263134
3135+// telegramSenderAdapter wraps the bridge so the tools package can
3136+// drive it without importing telegram directly. The Active() check
3137+// is forwarded to the bridge so the tool can fail clearly with a
3138+// model-readable error when the user disconnected mid-turn.
3139+type telegramSenderAdapter struct {
3140+ bridge *telegram.Bridge
3141+}
3142+
3143+func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
3144+ if a.bridge == nil {
3145+ return fmt.Errorf("telegram bridge is not connected")
3146+ }
3147+ return a.bridge.SendImage(ctx, path, caption)
3148+}
3149+
3150+func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
3151+ if a.bridge == nil {
3152+ return fmt.Errorf("telegram bridge is not connected")
3153+ }
3154+ return a.bridge.SendDocument(ctx, path, caption)
3155+}
3156+
3157+func (a telegramSenderAdapter) Active() bool {
3158+ return a.bridge != nil && a.bridge.Active()
3159+}
3160+
3161+// applyTelegramTools registers (active=true) or removes (active=false)
3162+// the telegram_send_image and telegram_send_file tools on the running
3163+// agent so the model only sees them while the bridge is connected.
3164+// Snapshots and mutates the live tool registry so any extension or
3165+// /reload-ext additions made while Telegram is connected survive a
3166+// later /telegram disconnect (we only add or strip the two telegram
3167+// entries, never the rest).
3168+func (i *Interactive) applyTelegramTools(active bool) {
3169+ if i.agent == nil {
3170+ return
3171+ }
3172+ current := i.agent.Tools
3173+ next := core.Registry{}
3174+ for name, t := range current {
3175+ if name == "telegram_send_image" || name == "telegram_send_file" {
3176+ continue
3177+ }
3178+ next[name] = t
3179+ }
3180+ if active {
3181+ sender := telegramSenderAdapter{bridge: i.telegramBridge}
3182+ next["telegram_send_image"] = &tools.TelegramSendImageTool{
3183+ CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
3184+ }
3185+ next["telegram_send_file"] = &tools.TelegramSendFileTool{
3186+ CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
3187+ }
3188+ }
3189+ i.agent.SetTools(next)
3190+}
3191+
31273192 // telegramStatus writes a one-liner describing the bridge state.
31283193 // Reports on both the in-tui bridge and the background daemon so
31293194 // the user isn't confused when the daemon owns the poll loop.
@@ -2502,6 +2502,11 @@ func (i *Interactive) applyModelSelection(prov, model string) {
2502 // identical messages will reuse the existing entries. Nothing2502 // identical messages will reuse the existing entries. Nothing
2503 // to invalidate.2503 // to invalidate.
2504 i.mu.Unlock()2504 i.mu.Unlock()
2505+ // The new agent was built off the base tool registry, so any
2506+ // dynamically-registered tools (telegram_send_*) need to be
2507+ // reattached. applyTelegramTools is a no-op when the bridge is
2508+ // idle so the cross-provider path still works on a vanilla setup.
2509+ i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
2505 if i.cfg.PersistModel != nil {2510 if i.cfg.PersistModel != nil {
2506 i.cfg.PersistModel(p, md)2511 i.cfg.PersistModel(p, md)
2507 }2512 }
@@ -2529,6 +2534,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
2529 i.statusErr = ""2534 i.statusErr = ""
2530 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method2535 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
2531 i.mu.Unlock()2536 i.mu.Unlock()
2537+ i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
2532 i.dialog.ShowResult(true, "")2538 i.dialog.ShowResult(true, "")
2533 }2539 }
2534 }2540 }
@@ -3091,6 +3097,7 @@ func (i *Interactive) telegramConnect() {
3091 i.invalidate()3097 i.invalidate()
3092 return3098 return
3093 }3099 }
3100+ i.applyTelegramTools(true)
3094 state := i.telegramBridge.State()3101 state := i.telegramBridge.State()
3095 label := "telegram connected"3102 label := "telegram connected"
3096 if state.Username != "" {3103 if state.Username != "" {
@@ -3117,6 +3124,7 @@ func (i *Interactive) telegramDisconnect() {
3117 return3124 return
3118 }3125 }
3119 i.telegramBridge.Stop()3126 i.telegramBridge.Stop()
3127+ i.applyTelegramTools(false)
3120 i.mu.Lock()3128 i.mu.Lock()
3121 i.statusOK = "telegram disconnected"3129 i.statusOK = "telegram disconnected"
3122 i.statusErr = ""3130 i.statusErr = ""
@@ -3124,6 +3132,63 @@ func (i *Interactive) telegramDisconnect() {
3124 i.invalidate()3132 i.invalidate()
3125 }3133 }
3126 3134
3135+// telegramSenderAdapter wraps the bridge so the tools package can
3136+// drive it without importing telegram directly. The Active() check
3137+// is forwarded to the bridge so the tool can fail clearly with a
3138+// model-readable error when the user disconnected mid-turn.
3139+type telegramSenderAdapter struct {
3140+ bridge *telegram.Bridge
3141+}
3142+
3143+func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
3144+ if a.bridge == nil {
3145+ return fmt.Errorf("telegram bridge is not connected")
3146+ }
3147+ return a.bridge.SendImage(ctx, path, caption)
3148+}
3149+
3150+func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
3151+ if a.bridge == nil {
3152+ return fmt.Errorf("telegram bridge is not connected")
3153+ }
3154+ return a.bridge.SendDocument(ctx, path, caption)
3155+}
3156+
3157+func (a telegramSenderAdapter) Active() bool {
3158+ return a.bridge != nil && a.bridge.Active()
3159+}
3160+
3161+// applyTelegramTools registers (active=true) or removes (active=false)
3162+// the telegram_send_image and telegram_send_file tools on the running
3163+// agent so the model only sees them while the bridge is connected.
3164+// Snapshots and mutates the live tool registry so any extension or
3165+// /reload-ext additions made while Telegram is connected survive a
3166+// later /telegram disconnect (we only add or strip the two telegram
3167+// entries, never the rest).
3168+func (i *Interactive) applyTelegramTools(active bool) {
3169+ if i.agent == nil {
3170+ return
3171+ }
3172+ current := i.agent.Tools
3173+ next := core.Registry{}
3174+ for name, t := range current {
3175+ if name == "telegram_send_image" || name == "telegram_send_file" {
3176+ continue
3177+ }
3178+ next[name] = t
3179+ }
3180+ if active {
3181+ sender := telegramSenderAdapter{bridge: i.telegramBridge}
3182+ next["telegram_send_image"] = &tools.TelegramSendImageTool{
3183+ CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
3184+ }
3185+ next["telegram_send_file"] = &tools.TelegramSendFileTool{
3186+ CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
3187+ }
3188+ }
3189+ i.agent.SetTools(next)
3190+}
3191+
3127 // telegramStatus writes a one-liner describing the bridge state.3192 // telegramStatus writes a one-liner describing the bridge state.
3128 // Reports on both the in-tui bridge and the background daemon so3193 // Reports on both the in-tui bridge and the background daemon so
3129 // the user isn't confused when the daemon owns the poll loop.3194 // the user isn't confused when the daemon owns the poll loop.
M internal/agent/modes/telegram/api.go +42 -0
@@ -142,6 +142,48 @@ func (c *Client) SendChatAction(ctx context.Context, chatID int64, action string
142142 return nil
143143 }
144144
145+// SendPhoto uploads a local image file as a Telegram photo. Telegram
146+// re-encodes / scales photos for inline preview; use SendDocument
147+// when the recipient needs the original bytes.
148+func (c *Client) SendPhoto(ctx context.Context, chatID int64, path, caption string) error {
149+ f, err := openFile(path)
150+ if err != nil {
151+ return err
152+ }
153+ defer f.Close()
154+
155+ var buf bytes.Buffer
156+ w := multipart.NewWriter(&buf)
157+ _ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
158+ if caption != "" {
159+ _ = w.WriteField("caption", caption)
160+ }
161+ part, err := w.CreateFormFile("photo", lastPathElem(path))
162+ if err != nil {
163+ return err
164+ }
165+ if _, err := io.Copy(part, f); err != nil {
166+ return err
167+ }
168+ w.Close()
169+
170+ req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendPhoto", &buf)
171+ if err != nil {
172+ return err
173+ }
174+ req.Header.Set("content-type", w.FormDataContentType())
175+ resp, err := c.http.Do(req)
176+ if err != nil {
177+ return err
178+ }
179+ defer resp.Body.Close()
180+ if resp.StatusCode >= 400 {
181+ body, _ := io.ReadAll(resp.Body)
182+ return fmt.Errorf("sendPhoto http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
183+ }
184+ return nil
185+}
186+
145187 // SendDocument uploads a local file as a document attachment.
146188 func (c *Client) SendDocument(ctx context.Context, chatID int64, path, caption string) error {
147189 f, err := openFile(path)
@@ -142,6 +142,48 @@ func (c *Client) SendChatAction(ctx context.Context, chatID int64, action string
142 return nil142 return nil
143 }143 }
144 144
145+// SendPhoto uploads a local image file as a Telegram photo. Telegram
146+// re-encodes / scales photos for inline preview; use SendDocument
147+// when the recipient needs the original bytes.
148+func (c *Client) SendPhoto(ctx context.Context, chatID int64, path, caption string) error {
149+ f, err := openFile(path)
150+ if err != nil {
151+ return err
152+ }
153+ defer f.Close()
154+
155+ var buf bytes.Buffer
156+ w := multipart.NewWriter(&buf)
157+ _ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
158+ if caption != "" {
159+ _ = w.WriteField("caption", caption)
160+ }
161+ part, err := w.CreateFormFile("photo", lastPathElem(path))
162+ if err != nil {
163+ return err
164+ }
165+ if _, err := io.Copy(part, f); err != nil {
166+ return err
167+ }
168+ w.Close()
169+
170+ req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendPhoto", &buf)
171+ if err != nil {
172+ return err
173+ }
174+ req.Header.Set("content-type", w.FormDataContentType())
175+ resp, err := c.http.Do(req)
176+ if err != nil {
177+ return err
178+ }
179+ defer resp.Body.Close()
180+ if resp.StatusCode >= 400 {
181+ body, _ := io.ReadAll(resp.Body)
182+ return fmt.Errorf("sendPhoto http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
183+ }
184+ return nil
185+}
186+
145 // SendDocument uploads a local file as a document attachment.187 // SendDocument uploads a local file as a document attachment.
146 func (c *Client) SendDocument(ctx context.Context, chatID int64, path, caption string) error {188 func (c *Client) SendDocument(ctx context.Context, chatID int64, path, caption string) error {
147 f, err := openFile(path)189 f, err := openFile(path)
M internal/agent/modes/telegram/bridge.go +38 -1
@@ -153,7 +153,8 @@ func (b *Bridge) Stop() {
153153 // visual noise to a plain back-and-forth).
154154 func (b *Bridge) OnAssistantText(text string) {
155155 b.mu.Lock()
156- prefix := "zot: "
156+ // prefix := "zot: "
157+ prefix := ""
157158 if b.nextReplyFromTelegram {
158159 prefix = ""
159160 b.nextReplyFromTelegram = false
@@ -198,6 +199,42 @@ func (b *Bridge) sendToPaired(text, prefix string) {
198199 }
199200 }
200201
202+// SendImage uploads path to the paired Telegram chat as an inline
203+// photo. Returns an error if the bridge is not running, no user has
204+// paired yet, or the upload itself fails. Used by the
205+// telegram_send_image tool so a Telegram-originated turn can yield
206+// a real image instead of a textual description.
207+func (b *Bridge) SendImage(ctx context.Context, path, caption string) error {
208+ b.mu.Lock()
209+ chatID := b.chatID
210+ running := b.running
211+ b.mu.Unlock()
212+ if !running {
213+ return fmt.Errorf("telegram bridge is not running")
214+ }
215+ if chatID == 0 {
216+ return fmt.Errorf("telegram bridge has no paired chat yet")
217+ }
218+ return b.Client.SendPhoto(ctx, chatID, path, caption)
219+}
220+
221+// SendDocument uploads path to the paired Telegram chat as a raw
222+// document attachment (no compression). Counterpart of SendImage for
223+// the telegram_send_file tool.
224+func (b *Bridge) SendDocument(ctx context.Context, path, caption string) error {
225+ b.mu.Lock()
226+ chatID := b.chatID
227+ running := b.running
228+ b.mu.Unlock()
229+ if !running {
230+ return fmt.Errorf("telegram bridge is not running")
231+ }
232+ if chatID == 0 {
233+ return fmt.Errorf("telegram bridge has no paired chat yet")
234+ }
235+ return b.Client.SendDocument(ctx, chatID, path, caption)
236+}
237+
201238 // pollLoop long-polls Telegram and dispatches each update. Runs
202239 // until ctx cancels.
203240 func (b *Bridge) pollLoop(ctx context.Context) {
@@ -153,7 +153,8 @@ func (b *Bridge) Stop() {
153 // visual noise to a plain back-and-forth).153 // visual noise to a plain back-and-forth).
154 func (b *Bridge) OnAssistantText(text string) {154 func (b *Bridge) OnAssistantText(text string) {
155 b.mu.Lock()155 b.mu.Lock()
156- prefix := "zot: "156+ // prefix := "zot: "
157+ prefix := ""
157 if b.nextReplyFromTelegram {158 if b.nextReplyFromTelegram {
158 prefix = ""159 prefix = ""
159 b.nextReplyFromTelegram = false160 b.nextReplyFromTelegram = false
@@ -198,6 +199,42 @@ func (b *Bridge) sendToPaired(text, prefix string) {
198 }199 }
199 }200 }
200 201
202+// SendImage uploads path to the paired Telegram chat as an inline
203+// photo. Returns an error if the bridge is not running, no user has
204+// paired yet, or the upload itself fails. Used by the
205+// telegram_send_image tool so a Telegram-originated turn can yield
206+// a real image instead of a textual description.
207+func (b *Bridge) SendImage(ctx context.Context, path, caption string) error {
208+ b.mu.Lock()
209+ chatID := b.chatID
210+ running := b.running
211+ b.mu.Unlock()
212+ if !running {
213+ return fmt.Errorf("telegram bridge is not running")
214+ }
215+ if chatID == 0 {
216+ return fmt.Errorf("telegram bridge has no paired chat yet")
217+ }
218+ return b.Client.SendPhoto(ctx, chatID, path, caption)
219+}
220+
221+// SendDocument uploads path to the paired Telegram chat as a raw
222+// document attachment (no compression). Counterpart of SendImage for
223+// the telegram_send_file tool.
224+func (b *Bridge) SendDocument(ctx context.Context, path, caption string) error {
225+ b.mu.Lock()
226+ chatID := b.chatID
227+ running := b.running
228+ b.mu.Unlock()
229+ if !running {
230+ return fmt.Errorf("telegram bridge is not running")
231+ }
232+ if chatID == 0 {
233+ return fmt.Errorf("telegram bridge has no paired chat yet")
234+ }
235+ return b.Client.SendDocument(ctx, chatID, path, caption)
236+}
237+
201 // pollLoop long-polls Telegram and dispatches each update. Runs238 // pollLoop long-polls Telegram and dispatches each update. Runs
202 // until ctx cancels.239 // until ctx cancels.
203 func (b *Bridge) pollLoop(ctx context.Context) {240 func (b *Bridge) pollLoop(ctx context.Context) {
A internal/agent/tools/telegram_send.go +158 -0
@@ -0,0 +1,158 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+
9+ "github.com/patriceckhart/zot/internal/core"
10+ "github.com/patriceckhart/zot/internal/provider"
11+)
12+
13+// TelegramSender is the small affordance the telegram-send tools call
14+// into. The real implementation lives in the interactive runtime and
15+// forwards to the active *telegram.Bridge; tests can pass any stub.
16+//
17+// The kind argument distinguishes "photo" (compressed inline image
18+// preview) from "document" (raw file attachment, no compression). For
19+// images Telegram resizes to its preview format, which loses detail
20+// but renders inline; documents preserve the original bytes but show
21+// up as a file the recipient downloads.
22+type TelegramSender interface {
23+ // SendImage uploads path as an inline-rendered photo with an
24+ // optional caption. Returns an error if the bridge is not
25+ // active or the upload fails.
26+ SendImage(ctx context.Context, path, caption string) error
27+ // SendDocument uploads path as a raw attachment.
28+ SendDocument(ctx context.Context, path, caption string) error
29+ // Active reports whether a paired Telegram chat is currently
30+ // reachable. Tools surface a clear error to the model when it
31+ // tries to send without a connected bridge.
32+ Active() bool
33+}
34+
35+// TelegramSendImageTool exposes the bridge's photo-send affordance to
36+// the model so a turn that comes in over Telegram can produce a real
37+// image reply (a screenshot, a generated chart, a downloaded asset)
38+// instead of a textual description of one. Only registered while the
39+// bridge is connected; deregistered on disconnect.
40+type TelegramSendImageTool struct {
41+ CWD string
42+ Sandbox *Sandbox
43+ Sender TelegramSender
44+}
45+
46+type telegramSendImageArgs struct {
47+ Path string `json:"path"`
48+ Caption string `json:"caption,omitempty"`
49+}
50+
51+const telegramSendImageSchema = `{"type":"object","properties":{"path":{"type":"string","description":"absolute or cwd-relative path to a local image file (png/jpg/gif/webp)"},"caption":{"type":"string","description":"optional caption sent alongside the image"}},"required":["path"]}`
52+
53+func (t *TelegramSendImageTool) Name() string { return "telegram_send_image" }
54+func (t *TelegramSendImageTool) Description() string {
55+ return "Send a local image file to the paired Telegram chat as an inline photo. Use when the user (over Telegram) asks to see an image rather than have it described."
56+}
57+func (t *TelegramSendImageTool) Schema() json.RawMessage {
58+ return json.RawMessage(telegramSendImageSchema)
59+}
60+
61+func (t *TelegramSendImageTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
62+ var a telegramSendImageArgs
63+ if err := json.Unmarshal(raw, &a); err != nil {
64+ return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
65+ }
66+ if a.Path == "" {
67+ return core.ToolResult{}, fmt.Errorf("path is required")
68+ }
69+ if t.Sender == nil || !t.Sender.Active() {
70+ return core.ToolResult{
71+ IsError: true,
72+ Content: []provider.Content{provider.TextBlock{Text: "telegram bridge is not connected; cannot send image"}},
73+ }, nil
74+ }
75+ path := resolvePath(t.CWD, a.Path)
76+ if err := t.Sandbox.CheckPath(path); err != nil {
77+ return core.ToolResult{}, err
78+ }
79+ info, err := os.Stat(path)
80+ if err != nil {
81+ return core.ToolResult{}, err
82+ }
83+ if info.IsDir() {
84+ return core.ToolResult{}, fmt.Errorf("%s is a directory", path)
85+ }
86+ if mime := imageMIME(path); mime == "" {
87+ return core.ToolResult{
88+ IsError: true,
89+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("%s is not a recognised image format (png/jpg/gif/webp); use telegram_send_file for arbitrary attachments", path)}},
90+ }, nil
91+ }
92+ if err := t.Sender.SendImage(ctx, path, a.Caption); err != nil {
93+ return core.ToolResult{}, fmt.Errorf("send: %w", err)
94+ }
95+ kb := info.Size() / 1024
96+ return core.ToolResult{
97+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("sent %s to telegram (%d KB)", path, kb)}},
98+ }, nil
99+}
100+
101+// TelegramSendFileTool uploads any local file to the paired chat as a
102+// document attachment. Use this for non-image files or when the model
103+// needs the recipient to receive the original bytes (no Telegram
104+// compression). For images you usually want telegram_send_image.
105+type TelegramSendFileTool struct {
106+ CWD string
107+ Sandbox *Sandbox
108+ Sender TelegramSender
109+}
110+
111+type telegramSendFileArgs struct {
112+ Path string `json:"path"`
113+ Caption string `json:"caption,omitempty"`
114+}
115+
116+const telegramSendFileSchema = `{"type":"object","properties":{"path":{"type":"string","description":"absolute or cwd-relative path to any local file"},"caption":{"type":"string","description":"optional caption sent alongside the file"}},"required":["path"]}`
117+
118+func (t *TelegramSendFileTool) Name() string { return "telegram_send_file" }
119+func (t *TelegramSendFileTool) Description() string {
120+ return "Send a local file to the paired Telegram chat as a document attachment (no compression). Use for non-image files or when the recipient needs the original bytes."
121+}
122+func (t *TelegramSendFileTool) Schema() json.RawMessage {
123+ return json.RawMessage(telegramSendFileSchema)
124+}
125+
126+func (t *TelegramSendFileTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
127+ var a telegramSendFileArgs
128+ if err := json.Unmarshal(raw, &a); err != nil {
129+ return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
130+ }
131+ if a.Path == "" {
132+ return core.ToolResult{}, fmt.Errorf("path is required")
133+ }
134+ if t.Sender == nil || !t.Sender.Active() {
135+ return core.ToolResult{
136+ IsError: true,
137+ Content: []provider.Content{provider.TextBlock{Text: "telegram bridge is not connected; cannot send file"}},
138+ }, nil
139+ }
140+ path := resolvePath(t.CWD, a.Path)
141+ if err := t.Sandbox.CheckPath(path); err != nil {
142+ return core.ToolResult{}, err
143+ }
144+ info, err := os.Stat(path)
145+ if err != nil {
146+ return core.ToolResult{}, err
147+ }
148+ if info.IsDir() {
149+ return core.ToolResult{}, fmt.Errorf("%s is a directory", path)
150+ }
151+ if err := t.Sender.SendDocument(ctx, path, a.Caption); err != nil {
152+ return core.ToolResult{}, fmt.Errorf("send: %w", err)
153+ }
154+ kb := info.Size() / 1024
155+ return core.ToolResult{
156+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("sent %s to telegram (%d KB)", path, kb)}},
157+ }, nil
158+}
@@ -0,0 +1,158 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+
9+ "github.com/patriceckhart/zot/internal/core"
10+ "github.com/patriceckhart/zot/internal/provider"
11+)
12+
13+// TelegramSender is the small affordance the telegram-send tools call
14+// into. The real implementation lives in the interactive runtime and
15+// forwards to the active *telegram.Bridge; tests can pass any stub.
16+//
17+// The kind argument distinguishes "photo" (compressed inline image
18+// preview) from "document" (raw file attachment, no compression). For
19+// images Telegram resizes to its preview format, which loses detail
20+// but renders inline; documents preserve the original bytes but show
21+// up as a file the recipient downloads.
22+type TelegramSender interface {
23+ // SendImage uploads path as an inline-rendered photo with an
24+ // optional caption. Returns an error if the bridge is not
25+ // active or the upload fails.
26+ SendImage(ctx context.Context, path, caption string) error
27+ // SendDocument uploads path as a raw attachment.
28+ SendDocument(ctx context.Context, path, caption string) error
29+ // Active reports whether a paired Telegram chat is currently
30+ // reachable. Tools surface a clear error to the model when it
31+ // tries to send without a connected bridge.
32+ Active() bool
33+}
34+
35+// TelegramSendImageTool exposes the bridge's photo-send affordance to
36+// the model so a turn that comes in over Telegram can produce a real
37+// image reply (a screenshot, a generated chart, a downloaded asset)
38+// instead of a textual description of one. Only registered while the
39+// bridge is connected; deregistered on disconnect.
40+type TelegramSendImageTool struct {
41+ CWD string
42+ Sandbox *Sandbox
43+ Sender TelegramSender
44+}
45+
46+type telegramSendImageArgs struct {
47+ Path string `json:"path"`
48+ Caption string `json:"caption,omitempty"`
49+}
50+
51+const telegramSendImageSchema = `{"type":"object","properties":{"path":{"type":"string","description":"absolute or cwd-relative path to a local image file (png/jpg/gif/webp)"},"caption":{"type":"string","description":"optional caption sent alongside the image"}},"required":["path"]}`
52+
53+func (t *TelegramSendImageTool) Name() string { return "telegram_send_image" }
54+func (t *TelegramSendImageTool) Description() string {
55+ return "Send a local image file to the paired Telegram chat as an inline photo. Use when the user (over Telegram) asks to see an image rather than have it described."
56+}
57+func (t *TelegramSendImageTool) Schema() json.RawMessage {
58+ return json.RawMessage(telegramSendImageSchema)
59+}
60+
61+func (t *TelegramSendImageTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
62+ var a telegramSendImageArgs
63+ if err := json.Unmarshal(raw, &a); err != nil {
64+ return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
65+ }
66+ if a.Path == "" {
67+ return core.ToolResult{}, fmt.Errorf("path is required")
68+ }
69+ if t.Sender == nil || !t.Sender.Active() {
70+ return core.ToolResult{
71+ IsError: true,
72+ Content: []provider.Content{provider.TextBlock{Text: "telegram bridge is not connected; cannot send image"}},
73+ }, nil
74+ }
75+ path := resolvePath(t.CWD, a.Path)
76+ if err := t.Sandbox.CheckPath(path); err != nil {
77+ return core.ToolResult{}, err
78+ }
79+ info, err := os.Stat(path)
80+ if err != nil {
81+ return core.ToolResult{}, err
82+ }
83+ if info.IsDir() {
84+ return core.ToolResult{}, fmt.Errorf("%s is a directory", path)
85+ }
86+ if mime := imageMIME(path); mime == "" {
87+ return core.ToolResult{
88+ IsError: true,
89+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("%s is not a recognised image format (png/jpg/gif/webp); use telegram_send_file for arbitrary attachments", path)}},
90+ }, nil
91+ }
92+ if err := t.Sender.SendImage(ctx, path, a.Caption); err != nil {
93+ return core.ToolResult{}, fmt.Errorf("send: %w", err)
94+ }
95+ kb := info.Size() / 1024
96+ return core.ToolResult{
97+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("sent %s to telegram (%d KB)", path, kb)}},
98+ }, nil
99+}
100+
101+// TelegramSendFileTool uploads any local file to the paired chat as a
102+// document attachment. Use this for non-image files or when the model
103+// needs the recipient to receive the original bytes (no Telegram
104+// compression). For images you usually want telegram_send_image.
105+type TelegramSendFileTool struct {
106+ CWD string
107+ Sandbox *Sandbox
108+ Sender TelegramSender
109+}
110+
111+type telegramSendFileArgs struct {
112+ Path string `json:"path"`
113+ Caption string `json:"caption,omitempty"`
114+}
115+
116+const telegramSendFileSchema = `{"type":"object","properties":{"path":{"type":"string","description":"absolute or cwd-relative path to any local file"},"caption":{"type":"string","description":"optional caption sent alongside the file"}},"required":["path"]}`
117+
118+func (t *TelegramSendFileTool) Name() string { return "telegram_send_file" }
119+func (t *TelegramSendFileTool) Description() string {
120+ return "Send a local file to the paired Telegram chat as a document attachment (no compression). Use for non-image files or when the recipient needs the original bytes."
121+}
122+func (t *TelegramSendFileTool) Schema() json.RawMessage {
123+ return json.RawMessage(telegramSendFileSchema)
124+}
125+
126+func (t *TelegramSendFileTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
127+ var a telegramSendFileArgs
128+ if err := json.Unmarshal(raw, &a); err != nil {
129+ return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
130+ }
131+ if a.Path == "" {
132+ return core.ToolResult{}, fmt.Errorf("path is required")
133+ }
134+ if t.Sender == nil || !t.Sender.Active() {
135+ return core.ToolResult{
136+ IsError: true,
137+ Content: []provider.Content{provider.TextBlock{Text: "telegram bridge is not connected; cannot send file"}},
138+ }, nil
139+ }
140+ path := resolvePath(t.CWD, a.Path)
141+ if err := t.Sandbox.CheckPath(path); err != nil {
142+ return core.ToolResult{}, err
143+ }
144+ info, err := os.Stat(path)
145+ if err != nil {
146+ return core.ToolResult{}, err
147+ }
148+ if info.IsDir() {
149+ return core.ToolResult{}, fmt.Errorf("%s is a directory", path)
150+ }
151+ if err := t.Sender.SendDocument(ctx, path, a.Caption); err != nil {
152+ return core.ToolResult{}, fmt.Errorf("send: %w", err)
153+ }
154+ kb := info.Size() / 1024
155+ return core.ToolResult{
156+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("sent %s to telegram (%d KB)", path, kb)}},
157+ }, nil
158+}

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