v0.0.61 c09a5c5

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// SendDocument uploads a local file as a document attachment.
146func (c *Client) SendDocument(ctx context.Context, chatID int64, path, caption string) error {
147	f, err := openFile(path)
148	if err != nil {
149		return err
150	}
151	defer f.Close()
152
153	var buf bytes.Buffer
154	w := multipart.NewWriter(&buf)
155	_ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
156	if caption != "" {
157		_ = w.WriteField("caption", caption)
158	}
159	part, err := w.CreateFormFile("document", lastPathElem(path))
160	if err != nil {
161		return err
162	}
163	if _, err := io.Copy(part, f); err != nil {
164		return err
165	}
166	w.Close()
167
168	req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendDocument", &buf)
169	if err != nil {
170		return err
171	}
172	req.Header.Set("content-type", w.FormDataContentType())
173	resp, err := c.http.Do(req)
174	if err != nil {
175		return err
176	}
177	defer resp.Body.Close()
178	if resp.StatusCode >= 400 {
179		body, _ := io.ReadAll(resp.Body)
180		return fmt.Errorf("sendDocument http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
181	}
182	return nil
183}
184
185// GetFile resolves a file_id to a downloadable path.
186func (c *Client) GetFile(ctx context.Context, fileID string) (*File, error) {
187	q := url.Values{}
188	q.Set("file_id", fileID)
189	var resp apiResponse[File]
190	if err := c.call(ctx, "getFile?"+q.Encode(), nil, &resp); err != nil {
191		return nil, err
192	}
193	if !resp.OK {
194		return nil, fmt.Errorf("getFile: %s", resp.Description)
195	}
196	return &resp.Result, nil
197}
198
199// DownloadFile downloads the file at filePath (from GetFile) into memory.
200func (c *Client) DownloadFile(ctx context.Context, filePath string) ([]byte, error) {
201	u := "https://api.telegram.org/file/bot" + c.token + "/" + filePath
202	req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
203	if err != nil {
204		return nil, err
205	}
206	resp, err := c.http.Do(req)
207	if err != nil {
208		return nil, err
209	}
210	defer resp.Body.Close()
211	if resp.StatusCode != 200 {
212		return nil, fmt.Errorf("download %s: http %d", filePath, resp.StatusCode)
213	}
214	return io.ReadAll(resp.Body)
215}
216
217// call issues a GET or POST request depending on whether body is nil.
218func (c *Client) call(ctx context.Context, endpoint string, body map[string]any, out any) error {
219	var req *http.Request
220	var err error
221	if body == nil {
222		req, err = http.NewRequestWithContext(ctx, "GET", c.baseURL()+"/"+endpoint, nil)
223	} else {
224		b, _ := json.Marshal(body)
225		req, err = http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/"+endpoint, bytes.NewReader(b))
226		if err == nil {
227			req.Header.Set("content-type", "application/json")
228		}
229	}
230	if err != nil {
231		return err
232	}
233	resp, err := c.http.Do(req)
234	if err != nil {
235		return err
236	}
237	defer resp.Body.Close()
238	// Telegram returns 200 with ok=false for logical errors; read either way.
239	respBody, _ := io.ReadAll(resp.Body)
240	if len(respBody) == 0 {
241		return fmt.Errorf("%s: empty response (status %d)", endpoint, resp.StatusCode)
242	}
243	return json.Unmarshal(respBody, out)
244}
245
246// small helpers kept here so api.go has no other deps.
247func lastPathElem(p string) string {
248	for i := len(p) - 1; i >= 0; i-- {
249		if p[i] == '/' || p[i] == '\\' {
250			return p[i+1:]
251		}
252	}
253	return p
254}
255
256func openFile(path string) (io.ReadCloser, error) {
257	return osOpen(path)
258}
259
260// overridden in tests.
261var osOpen = defaultOpen
262
263// Sleep is a package-level hook so tests can stub it.
264var 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