v0.3.88 ed7e2b4

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// DeleteWebhook removes any webhook so the bot can receive updates through
100// long polling. Pending updates are preserved unless dropPendingUpdates is true.
101func (c *Client) DeleteWebhook(ctx context.Context, dropPendingUpdates bool) error {
102	body := map[string]any{"drop_pending_updates": dropPendingUpdates}
103	var resp apiResponse[bool]
104	if err := c.call(ctx, "deleteWebhook", body, &resp); err != nil {
105		return err
106	}
107	if !resp.OK {
108		return fmt.Errorf("deleteWebhook: %s", resp.Description)
109	}
110	if !resp.Result {
111		return fmt.Errorf("deleteWebhook: telegram did not remove the webhook")
112	}
113	return nil
114}
115
116// GetUpdates polls for new updates since offset with a long-poll timeout.
117func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSec int) ([]Update, error) {
118	q := url.Values{}
119	if offset != 0 {
120		q.Set("offset", strconv.FormatInt(offset, 10))
121	}
122	q.Set("timeout", strconv.Itoa(timeoutSec))
123	q.Set("allowed_updates", `["message","edited_message"]`)
124
125	var resp apiResponse[[]Update]
126	if err := c.call(ctx, "getUpdates?"+q.Encode(), nil, &resp); err != nil {
127		return nil, err
128	}
129	if !resp.OK {
130		return nil, fmt.Errorf("getUpdates: %s", resp.Description)
131	}
132	return resp.Result, nil
133}
134
135// SendMessage sends a plain-text reply.
136func (c *Client) SendMessage(ctx context.Context, chatID int64, text string, replyTo int) error {
137	body := map[string]any{
138		"chat_id": chatID,
139		"text":    text,
140	}
141	if replyTo > 0 {
142		body["reply_to_message_id"] = replyTo
143	}
144	var resp apiResponse[json.RawMessage]
145	if err := c.call(ctx, "sendMessage", body, &resp); err != nil {
146		return err
147	}
148	if !resp.OK {
149		return fmt.Errorf("sendMessage: %s", resp.Description)
150	}
151	return nil
152}
153
154// SendChatAction keeps the "typing..." indicator alive. Call every ~4s.
155func (c *Client) SendChatAction(ctx context.Context, chatID int64, action string) error {
156	body := map[string]any{"chat_id": chatID, "action": action}
157	var resp apiResponse[json.RawMessage]
158	_ = c.call(ctx, "sendChatAction", body, &resp) // ignore errors; it's advisory
159	return nil
160}
161
162// SendPhoto uploads a local image file as a Telegram photo. Telegram
163// re-encodes / scales photos for inline preview; use SendDocument
164// when the recipient needs the original bytes.
165func (c *Client) SendPhoto(ctx context.Context, chatID int64, path, caption string) error {
166	f, err := openFile(path)
167	if err != nil {
168		return err
169	}
170	defer f.Close()
171
172	var buf bytes.Buffer
173	w := multipart.NewWriter(&buf)
174	_ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
175	if caption != "" {
176		_ = w.WriteField("caption", caption)
177	}
178	part, err := w.CreateFormFile("photo", lastPathElem(path))
179	if err != nil {
180		return err
181	}
182	if _, err := io.Copy(part, f); err != nil {
183		return err
184	}
185	w.Close()
186
187	req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendPhoto", &buf)
188	if err != nil {
189		return err
190	}
191	req.Header.Set("content-type", w.FormDataContentType())
192	resp, err := c.http.Do(req)
193	if err != nil {
194		return err
195	}
196	defer resp.Body.Close()
197	if resp.StatusCode >= 400 {
198		body, _ := io.ReadAll(resp.Body)
199		return fmt.Errorf("sendPhoto http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
200	}
201	return nil
202}
203
204// SendDocument uploads a local file as a document attachment.
205func (c *Client) SendDocument(ctx context.Context, chatID int64, path, caption string) error {
206	f, err := openFile(path)
207	if err != nil {
208		return err
209	}
210	defer f.Close()
211
212	var buf bytes.Buffer
213	w := multipart.NewWriter(&buf)
214	_ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
215	if caption != "" {
216		_ = w.WriteField("caption", caption)
217	}
218	part, err := w.CreateFormFile("document", lastPathElem(path))
219	if err != nil {
220		return err
221	}
222	if _, err := io.Copy(part, f); err != nil {
223		return err
224	}
225	w.Close()
226
227	req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendDocument", &buf)
228	if err != nil {
229		return err
230	}
231	req.Header.Set("content-type", w.FormDataContentType())
232	resp, err := c.http.Do(req)
233	if err != nil {
234		return err
235	}
236	defer resp.Body.Close()
237	if resp.StatusCode >= 400 {
238		body, _ := io.ReadAll(resp.Body)
239		return fmt.Errorf("sendDocument http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
240	}
241	return nil
242}
243
244// GetFile resolves a file_id to a downloadable path.
245func (c *Client) GetFile(ctx context.Context, fileID string) (*File, error) {
246	q := url.Values{}
247	q.Set("file_id", fileID)
248	var resp apiResponse[File]
249	if err := c.call(ctx, "getFile?"+q.Encode(), nil, &resp); err != nil {
250		return nil, err
251	}
252	if !resp.OK {
253		return nil, fmt.Errorf("getFile: %s", resp.Description)
254	}
255	return &resp.Result, nil
256}
257
258// DownloadFile downloads the file at filePath (from GetFile) into memory.
259func (c *Client) DownloadFile(ctx context.Context, filePath string) ([]byte, error) {
260	u := "https://api.telegram.org/file/bot" + c.token + "/" + filePath
261	req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
262	if err != nil {
263		return nil, err
264	}
265	resp, err := c.http.Do(req)
266	if err != nil {
267		return nil, err
268	}
269	defer resp.Body.Close()
270	if resp.StatusCode != 200 {
271		return nil, fmt.Errorf("download %s: http %d", filePath, resp.StatusCode)
272	}
273	return io.ReadAll(resp.Body)
274}
275
276// call issues a GET or POST request depending on whether body is nil.
277func (c *Client) call(ctx context.Context, endpoint string, body map[string]any, out any) error {
278	var req *http.Request
279	var err error
280	if body == nil {
281		req, err = http.NewRequestWithContext(ctx, "GET", c.baseURL()+"/"+endpoint, nil)
282	} else {
283		b, _ := json.Marshal(body)
284		req, err = http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/"+endpoint, bytes.NewReader(b))
285		if err == nil {
286			req.Header.Set("content-type", "application/json")
287		}
288	}
289	if err != nil {
290		return err
291	}
292	resp, err := c.http.Do(req)
293	if err != nil {
294		return err
295	}
296	defer resp.Body.Close()
297	// Telegram returns 200 with ok=false for logical errors; read either way.
298	respBody, _ := io.ReadAll(resp.Body)
299	if len(respBody) == 0 {
300		return fmt.Errorf("%s: empty response (status %d)", endpoint, resp.StatusCode)
301	}
302	return json.Unmarshal(respBody, out)
303}
304
305// small helpers kept here so api.go has no other deps.
306func lastPathElem(p string) string {
307	for i := len(p) - 1; i >= 0; i-- {
308		if p[i] == '/' || p[i] == '\\' {
309			return p[i+1:]
310		}
311	}
312	return p
313}
314
315func openFile(path string) (io.ReadCloser, error) {
316	return osOpen(path)
317}
318
319// overridden in tests.
320var osOpen = defaultOpen
321
322// Sleep is a package-level hook so tests can stub it.
323var 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