Skip to content

Commit da170b1

Browse files
author
Saveliy Yudin
committed
feat: add upload convenience methods, new permissions, and godoc examples (v0.4.0)
Add UploadPhotoFromFile, UploadPhotoFromURL, UploadMediaFromFile, UploadMediaFromURL with security hardening: URL scheme validation (http/https only), 50 MB download limit, Content-Disposition filename sanitization, and proper timeout propagation via ensureTimeout. Introduce ErrFetch error kind for external URL and local file failures, separating them from ErrAPI (Max Bot API errors) and ErrNetwork. Add 5 new ChatAdminPermission constants documented on dev.max.ru and 8 example tests for godoc documentation.
1 parent 1919385 commit da170b1

File tree

8 files changed

+525
-1
lines changed

8 files changed

+525
-1
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## [v0.4.0] - 2026-03-11
4+
5+
### Added
6+
- 5 new `ChatAdminPermission` constants: `PermCanCall`, `PermEditLink`, `PermPostEditDeleteMessage`, `PermEditMessage`, `PermDeleteMessage`
7+
- Upload convenience methods: `UploadPhotoFromFile`, `UploadPhotoFromURL`, `UploadMediaFromFile`, `UploadMediaFromURL`
8+
- `ErrFetch` error kind for external URL and local file failures in upload helpers
9+
- Example tests for godoc documentation
10+
11+
### Security
12+
- `FromURL` upload methods validate URL scheme (only http/https allowed, prevents SSRF via file:// etc.)
13+
- `FromURL` upload methods limit download size to 50 MB (`maxFetchSize`)
14+
- `extractFilename` sanitizes Content-Disposition filenames with `filepath.Base` (prevents path traversal)
15+
316
## [v0.3.0] - 2026-02-23
417

518
### Added

client.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,14 @@ func (c *Client) parseAPIError(op string, statusCode int, body []byte) *Error {
201201
return apiError(op, statusCode, msg)
202202
}
203203

204+
// ensureTimeout applies the default timeout if the context has no deadline.
205+
func (c *Client) ensureTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
206+
if _, ok := ctx.Deadline(); !ok && c.timeout > 0 {
207+
return context.WithTimeout(ctx, c.timeout)
208+
}
209+
return ctx, func() {}
210+
}
211+
204212
func isTimeout(err error) bool {
205213
var t interface{ Timeout() bool }
206214
if errors.As(err, &t) {

errors.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ const (
2424
ErrTimeout
2525
// ErrDecode indicates a JSON marshal or unmarshal failure.
2626
ErrDecode
27+
// ErrFetch indicates a failure when downloading from an external URL
28+
// (used by UploadPhotoFromURL and UploadMediaFromURL).
29+
ErrFetch
2730
)
2831

2932
// String returns a human-readable name for the error kind.
@@ -37,6 +40,8 @@ func (k ErrorKind) String() string {
3740
return "timeout"
3841
case ErrDecode:
3942
return "decode"
43+
case ErrFetch:
44+
return "fetch"
4045
default:
4146
return "unknown"
4247
}
@@ -70,7 +75,7 @@ type Error struct {
7075

7176
// Error returns a formatted error string including the operation, kind, and details.
7277
func (e *Error) Error() string {
73-
if e.Kind == ErrAPI {
78+
if e.Kind == ErrAPI || e.Kind == ErrFetch {
7479
return fmt.Sprintf("%s: %s error %d: %s", e.Op, e.Kind, e.StatusCode, e.Message)
7580
}
7681
if e.Message != "" {
@@ -125,3 +130,12 @@ func decodeError(op string, err error) *Error {
125130
Err: err,
126131
}
127132
}
133+
134+
func fetchError(op string, statusCode int, message string) *Error {
135+
return &Error{
136+
Kind: ErrFetch,
137+
StatusCode: statusCode,
138+
Message: message,
139+
Op: op,
140+
}
141+
}

example_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package maxigo_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
11+
"github.com/maxigo-bot/maxigo-client"
12+
)
13+
14+
func ExampleNew() {
15+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
16+
_ = json.NewEncoder(w).Encode(maxigo.BotInfo{
17+
UserWithPhoto: maxigo.UserWithPhoto{
18+
User: maxigo.User{UserID: 1, FirstName: "TestBot", IsBot: true},
19+
},
20+
})
21+
}))
22+
defer srv.Close()
23+
24+
client, err := maxigo.New("test-token", maxigo.WithBaseURL(srv.URL))
25+
if err != nil {
26+
fmt.Println("error:", err)
27+
return
28+
}
29+
30+
bot, err := client.GetBot(context.Background())
31+
if err != nil {
32+
fmt.Println("error:", err)
33+
return
34+
}
35+
fmt.Println(bot.FirstName)
36+
// Output: TestBot
37+
}
38+
39+
func ExampleClient_SendMessage() {
40+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
41+
resp := struct {
42+
Message maxigo.Message `json:"message"`
43+
}{
44+
Message: maxigo.Message{
45+
Body: maxigo.MessageBody{MID: "mid-1", Text: strPtr("Hello!")},
46+
},
47+
}
48+
_ = json.NewEncoder(w).Encode(resp)
49+
}))
50+
defer srv.Close()
51+
52+
client, _ := maxigo.New("test-token", maxigo.WithBaseURL(srv.URL))
53+
msg, err := client.SendMessage(context.Background(), 12345, &maxigo.NewMessageBody{
54+
Text: maxigo.Some("Hello!"),
55+
})
56+
if err != nil {
57+
fmt.Println("error:", err)
58+
return
59+
}
60+
fmt.Println(*msg.Body.Text)
61+
// Output: Hello!
62+
}
63+
64+
func ExampleClient_AnswerCallback() {
65+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
66+
_ = json.NewEncoder(w).Encode(maxigo.SimpleQueryResult{Success: true})
67+
}))
68+
defer srv.Close()
69+
70+
client, _ := maxigo.New("test-token", maxigo.WithBaseURL(srv.URL))
71+
result, err := client.AnswerCallback(context.Background(), "cb-123", &maxigo.CallbackAnswer{
72+
Notification: maxigo.Some("Done!"),
73+
})
74+
if err != nil {
75+
fmt.Println("error:", err)
76+
return
77+
}
78+
fmt.Println(result.Success)
79+
// Output: true
80+
}
81+
82+
func ExampleClient_UploadPhoto() {
83+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84+
switch r.URL.Path {
85+
case "/uploads":
86+
// GetUploadURL — return the same server
87+
_ = json.NewEncoder(w).Encode(maxigo.UploadEndpoint{
88+
URL: "http://" + r.Host + "/do-upload",
89+
})
90+
default:
91+
// Actual upload
92+
_ = json.NewEncoder(w).Encode(maxigo.PhotoTokens{
93+
Photos: map[string]maxigo.PhotoToken{"default": {Token: "photo-tok"}},
94+
})
95+
}
96+
}))
97+
defer srv.Close()
98+
99+
client, _ := maxigo.New("test-token", maxigo.WithBaseURL(srv.URL))
100+
tokens, err := client.UploadPhoto(context.Background(), "photo.jpg", strings.NewReader("image data"))
101+
if err != nil {
102+
fmt.Println("error:", err)
103+
return
104+
}
105+
fmt.Println(tokens.Photos["default"].Token)
106+
// Output: photo-tok
107+
}
108+
109+
func ExampleNewCallbackButton() {
110+
btn := maxigo.NewCallbackButton("OK", "confirm")
111+
fmt.Printf("type=%s text=%s payload=%s\n", btn.Type, btn.Text, btn.Payload)
112+
// Output: type=callback text=OK payload=confirm
113+
}
114+
115+
func ExampleNewLinkButton() {
116+
btn := maxigo.NewLinkButton("Open", "https://example.com")
117+
fmt.Printf("type=%s url=%s\n", btn.Type, btn.URL)
118+
// Output: type=link url=https://example.com
119+
}
120+
121+
func ExampleSome() {
122+
opt := maxigo.Some("hello")
123+
fmt.Printf("set=%t value=%s\n", opt.Set, opt.Value)
124+
125+
var empty maxigo.OptString
126+
fmt.Printf("set=%t value=%q\n", empty.Set, empty.Value)
127+
// Output:
128+
// set=true value=hello
129+
// set=false value=""
130+
}
131+
132+
func ExampleNewInlineKeyboardAttachment() {
133+
kb := maxigo.NewInlineKeyboardAttachment([][]maxigo.Button{
134+
{
135+
maxigo.NewCallbackButton("Yes", "yes"),
136+
maxigo.NewCallbackButton("No", "no"),
137+
},
138+
})
139+
fmt.Println(kb.Type)
140+
// Output: inline_keyboard
141+
}
142+
143+
func strPtr(s string) *string { return &s }

types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ const (
105105
PermChangeChatInfo ChatAdminPermission = "change_chat_info"
106106
PermPinMessage ChatAdminPermission = "pin_message"
107107
PermWrite ChatAdminPermission = "write"
108+
109+
PermCanCall ChatAdminPermission = "can_call"
110+
PermEditLink ChatAdminPermission = "edit_link"
111+
PermPostEditDeleteMessage ChatAdminPermission = "post_edit_delete_message"
112+
PermEditMessage ChatAdminPermission = "edit_message"
113+
PermDeleteMessage ChatAdminPermission = "delete_message"
108114
)
109115

110116
// User represents a Max user or bot.

types_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,49 @@ func TestParseAttachments(t *testing.T) {
548548
})
549549
}
550550

551+
func TestChatAdminPermissionJSON(t *testing.T) {
552+
allPerms := []ChatAdminPermission{
553+
PermReadAllMessages,
554+
PermAddRemoveMembers,
555+
PermAddAdmins,
556+
PermChangeChatInfo,
557+
PermPinMessage,
558+
PermWrite,
559+
PermCanCall,
560+
PermEditLink,
561+
PermPostEditDeleteMessage,
562+
PermEditMessage,
563+
PermDeleteMessage,
564+
}
565+
566+
admin := ChatAdmin{
567+
UserID: 12345,
568+
Permissions: allPerms,
569+
}
570+
571+
data, err := json.Marshal(admin)
572+
if err != nil {
573+
t.Fatalf("Marshal error: %v", err)
574+
}
575+
576+
var got ChatAdmin
577+
if err := json.Unmarshal(data, &got); err != nil {
578+
t.Fatalf("Unmarshal error: %v", err)
579+
}
580+
581+
if got.UserID != admin.UserID {
582+
t.Errorf("UserID = %d, want %d", got.UserID, admin.UserID)
583+
}
584+
if len(got.Permissions) != len(allPerms) {
585+
t.Fatalf("Permissions count = %d, want %d", len(got.Permissions), len(allPerms))
586+
}
587+
for i, p := range got.Permissions {
588+
if p != allPerms[i] {
589+
t.Errorf("Permissions[%d] = %q, want %q", i, p, allPerms[i])
590+
}
591+
}
592+
}
593+
551594
func TestContactAttachmentPayload_Phone(t *testing.T) {
552595
tests := []struct {
553596
name string

0 commit comments

Comments
 (0)