v0.14.0 75d4151

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

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