v0.2.65 2bf9508

api.go RawHistoryFolder

1package telegram
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"fmt"
8	"io"
9	"mime/multipart"
10	"net/http"
11	"net/url"
12	"strconv"
13	"strings"
14	"time"
15)
16
17// Telegram Bot API types used by the bridge. Only the subset we need.
18
19type User struct {
20	ID        int64  `json:"id"`
21	IsBot     bool   `json:"is_bot"`
22	FirstName string `json:"first_name"`
23	Username  string `json:"username"`
24}
25
26type Chat struct {
27	ID   int64  `json:"id"`
28	Type string `json:"type"` // "private" | "group" | ...
29}
30
31type PhotoSize struct {
32	FileID   string `json:"file_id"`
33	FileSize int    `json:"file_size,omitempty"`
34	Width    int    `json:"width,omitempty"`
35	Height   int    `json:"height,omitempty"`
36}
37
38type Document struct {
39	FileID   string `json:"file_id"`
40	FileName string `json:"file_name,omitempty"`
41	MimeType string `json:"mime_type,omitempty"`
42	FileSize int    `json:"file_size,omitempty"`
43}
44
45type Message struct {
46	MessageID int         `json:"message_id"`
47	From      *User       `json:"from"`
48	Chat      Chat        `json:"chat"`
49	Date      int64       `json:"date"`
50	Text      string      `json:"text,omitempty"`
51	Caption   string      `json:"caption,omitempty"`
52	Photo     []PhotoSize `json:"photo,omitempty"`
53	Document  *Document   `json:"document,omitempty"`
54}
55
56type Update struct {
57	UpdateID int64    `json:"update_id"`
58	Message  *Message `json:"message"`
59	Edited   *Message `json:"edited_message"`
60}
61
62type File struct {
63	FileID   string `json:"file_id"`
64	FilePath string `json:"file_path"`
65	FileSize int    `json:"file_size,omitempty"`
66}
67
68// apiResponse is Telegram's envelope.
69type apiResponse[T any] struct {
70	OK          bool   `json:"ok"`
71	Description string `json:"description"`
72	Result      T      `json:"result"`
73}
74
75// Client is a minimal Telegram Bot API client.
76type Client struct {
77	token string
78	http  *http.Client
79}
80
81func NewClient(token string) *Client {
82	return &Client{token: token, http: &http.Client{Timeout: 0}}
83}
84
85func (c *Client) baseURL() string { return "https://api.telegram.org/bot" + c.token }
86
87// GetMe verifies the token and returns the bot's own User.
88func (c *Client) GetMe(ctx context.Context) (*User, error) {
89	var resp apiResponse[User]
90	if err := c.call(ctx, "getMe", nil, &resp); err != nil {
91		return nil, err
92	}
93	if !resp.OK {
94		return nil, fmt.Errorf("getMe: %s", resp.Description)
95	}
96	return &resp.Result, nil
97}
98
99// GetUpdates polls for new updates since offset with a long-poll timeout.
100func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSec int) ([]Update, error) {
101	q := url.Values{}
102	if offset != 0 {
103		q.Set("offset", strconv.FormatInt(offset, 10))
104	}
105	q.Set("timeout", strconv.Itoa(timeoutSec))
106	q.Set("allowed_updates", `["message","edited_message"]`)
107
108	var resp apiResponse[[]Update]
109	if err := c.call(ctx, "getUpdates?"+q.Encode(), nil, &resp); err != nil {
110		return nil, err
111	}
112	if !resp.OK {
113		return nil, fmt.Errorf("getUpdates: %s", resp.Description)
114	}
115	return resp.Result, nil
116}
117
118// SendMessage sends a plain-text reply.
119func (c *Client) SendMessage(ctx context.Context, chatID int64, text string, replyTo int) error {
120	body := map[string]any{
121		"chat_id": chatID,
122		"text":    text,
123	}
124	if replyTo > 0 {
125		body["reply_to_message_id"] = replyTo
126	}
127	var resp apiResponse[json.RawMessage]
128	if err := c.call(ctx, "sendMessage", body, &resp); err != nil {
129		return err
130	}
131	if !resp.OK {
132		return fmt.Errorf("sendMessage: %s", resp.Description)
133	}
134	return nil
135}
136
137// SendChatAction keeps the "typing..." indicator alive. Call every ~4s.
138func (c *Client) SendChatAction(ctx context.Context, chatID int64, action string) error {
139	body := map[string]any{"chat_id": chatID, "action": action}
140	var resp apiResponse[json.RawMessage]
141	_ = c.call(ctx, "sendChatAction", body, &resp) // ignore errors; it's advisory
142	return nil
143}
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.
148func (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
187// SendDocument uploads a local file as a document attachment.
188func (c *Client) SendDocument(ctx context.Context, chatID int64, path, caption string) error {
189	f, err := openFile(path)
190	if err != nil {
191		return err
192	}
193	defer f.Close()
194
195	var buf bytes.Buffer
196	w := multipart.NewWriter(&buf)
197	_ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
198	if caption != "" {
199		_ = w.WriteField("caption", caption)
200	}
201	part, err := w.CreateFormFile("document", lastPathElem(path))
202	if err != nil {
203		return err
204	}
205	if _, err := io.Copy(part, f); err != nil {
206		return err
207	}
208	w.Close()
209
210	req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendDocument", &buf)
211	if err != nil {
212		return err
213	}
214	req.Header.Set("content-type", w.FormDataContentType())
215	resp, err := c.http.Do(req)
216	if err != nil {
217		return err
218	}
219	defer resp.Body.Close()
220	if resp.StatusCode >= 400 {
221		body, _ := io.ReadAll(resp.Body)
222		return fmt.Errorf("sendDocument http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
223	}
224	return nil
225}
226
227// GetFile resolves a file_id to a downloadable path.
228func (c *Client) GetFile(ctx context.Context, fileID string) (*File, error) {
229	q := url.Values{}
230	q.Set("file_id", fileID)
231	var resp apiResponse[File]
232	if err := c.call(ctx, "getFile?"+q.Encode(), nil, &resp); err != nil {
233		return nil, err
234	}
235	if !resp.OK {
236		return nil, fmt.Errorf("getFile: %s", resp.Description)
237	}
238	return &resp.Result, nil
239}
240
241// DownloadFile downloads the file at filePath (from GetFile) into memory.
242func (c *Client) DownloadFile(ctx context.Context, filePath string) ([]byte, error) {
243	u := "https://api.telegram.org/file/bot" + c.token + "/" + filePath
244	req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
245	if err != nil {
246		return nil, err
247	}
248	resp, err := c.http.Do(req)
249	if err != nil {
250		return nil, err
251	}
252	defer resp.Body.Close()
253	if resp.StatusCode != 200 {
254		return nil, fmt.Errorf("download %s: http %d", filePath, resp.StatusCode)
255	}
256	return io.ReadAll(resp.Body)
257}
258
259// call issues a GET or POST request depending on whether body is nil.
260func (c *Client) call(ctx context.Context, endpoint string, body map[string]any, out any) error {
261	var req *http.Request
262	var err error
263	if body == nil {
264		req, err = http.NewRequestWithContext(ctx, "GET", c.baseURL()+"/"+endpoint, nil)
265	} else {
266		b, _ := json.Marshal(body)
267		req, err = http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/"+endpoint, bytes.NewReader(b))
268		if err == nil {
269			req.Header.Set("content-type", "application/json")
270		}
271	}
272	if err != nil {
273		return err
274	}
275	resp, err := c.http.Do(req)
276	if err != nil {
277		return err
278	}
279	defer resp.Body.Close()
280	// Telegram returns 200 with ok=false for logical errors; read either way.
281	respBody, _ := io.ReadAll(resp.Body)
282	if len(respBody) == 0 {
283		return fmt.Errorf("%s: empty response (status %d)", endpoint, resp.StatusCode)
284	}
285	return json.Unmarshal(respBody, out)
286}
287
288// small helpers kept here so api.go has no other deps.
289func lastPathElem(p string) string {
290	for i := len(p) - 1; i >= 0; i-- {
291		if p[i] == '/' || p[i] == '\\' {
292			return p[i+1:]
293		}
294	}
295	return p
296}
297
298func openFile(path string) (io.ReadCloser, error) {
299	return osOpen(path)
300}
301
302// overridden in tests.
303var osOpen = defaultOpen
304
305// Sleep is a package-level hook so tests can stub it.
306var sleep = func(d time.Duration) { time.Sleep(d) }

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