fix: correct Telegram update/keyboard models and harden env parsing

This commit is contained in:
2026-03-17 16:17:26 +03:00
parent 1e043da05d
commit 4ebe76dd4a
26 changed files with 992 additions and 140 deletions

View File

@@ -1,6 +1,7 @@
package utils
import (
"encoding/json"
"fmt"
"io"
"mime/multipart"
@@ -12,12 +13,8 @@ import (
// Encode writes struct fields into multipart form-data using json tags as field names.
func Encode[T any](w *multipart.Writer, req T) error {
v := reflect.ValueOf(req)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() != reflect.Struct {
v := unwrapMultipartValue(reflect.ValueOf(req))
if !v.IsValid() || v.Kind() != reflect.Struct {
return fmt.Errorf("req must be a struct")
}
@@ -33,6 +30,9 @@ func Encode[T any](w *multipart.Writer, req T) error {
parts := strings.Split(jsonTag, ",")
fieldName := parts[0]
if fieldName == "" {
fieldName = fieldType.Name
}
if fieldName == "-" {
continue
}
@@ -43,96 +43,73 @@ func Encode[T any](w *multipart.Writer, req T) error {
continue
}
var (
fw io.Writer
err error
)
switch field.Kind() {
case reflect.String:
fw, err = w.CreateFormField(fieldName)
if err == nil {
_, err = fw.Write([]byte(field.String()))
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
fw, err = w.CreateFormField(fieldName)
if err == nil {
_, err = fw.Write([]byte(strconv.FormatInt(field.Int(), 10)))
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
fw, err = w.CreateFormField(fieldName)
if err == nil {
_, err = fw.Write([]byte(strconv.FormatUint(field.Uint(), 10)))
}
case reflect.Float32:
fw, err = w.CreateFormField(fieldName)
if err == nil {
_, err = fw.Write([]byte(strconv.FormatFloat(field.Float(), 'f', -1, 32)))
}
case reflect.Float64:
fw, err = w.CreateFormField(fieldName)
if err == nil {
_, err = fw.Write([]byte(strconv.FormatFloat(field.Float(), 'f', -1, 64)))
}
case reflect.Bool:
fw, err = w.CreateFormField(fieldName)
if err == nil {
_, err = fw.Write([]byte(strconv.FormatBool(field.Bool())))
}
case reflect.Slice:
if field.Type().Elem().Kind() == reflect.Uint8 && !field.IsNil() {
// Handle []byte as file upload (e.g., thumbnail)
filename := fieldType.Tag.Get("filename")
if filename == "" {
filename = fieldName
}
fw, err = w.CreateFormFile(fieldName, filename)
if err == nil {
_, err = fw.Write(field.Bytes())
}
} else if !field.IsNil() {
// Handle []string, []int, etc. — send as multiple fields with same name
for j := 0; j < field.Len(); j++ {
elem := field.Index(j)
fw, err = w.CreateFormField(fieldName)
if err != nil {
break
}
switch elem.Kind() {
case reflect.String:
_, err = fw.Write([]byte(elem.String()))
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
_, err = fw.Write([]byte(strconv.FormatInt(elem.Int(), 10)))
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
_, err = fw.Write([]byte(strconv.FormatUint(elem.Uint(), 10)))
case reflect.Bool:
_, err = fw.Write([]byte(strconv.FormatBool(elem.Bool())))
case reflect.Float32:
_, err = fw.Write([]byte(strconv.FormatFloat(elem.Float(), 'f', -1, 32)))
case reflect.Float64:
_, err = fw.Write([]byte(strconv.FormatFloat(elem.Float(), 'f', -1, 64)))
default:
continue
}
if err != nil {
break
}
}
}
case reflect.Struct:
// Don't serialize structs as JSON — flatten them!
// Telegram doesn't support nested JSON in form-data.
// If you need nested data, use separate fields (e.g., ParseMode, CaptionEntities)
// This is a design choice — you should avoid nested structs in params.
return fmt.Errorf("nested structs are not supported in params — use flat fields")
}
if err != nil {
if err := writeMultipartField(w, fieldName, fieldType.Tag.Get("filename"), field); err != nil {
return err
}
}
return nil
}
func unwrapMultipartValue(v reflect.Value) reflect.Value {
for v.IsValid() && (v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface) {
if v.IsNil() {
return reflect.Value{}
}
v = v.Elem()
}
return v
}
func writeMultipartField(w *multipart.Writer, fieldName, filename string, field reflect.Value) error {
value := unwrapMultipartValue(field)
if !value.IsValid() {
return nil
}
switch value.Kind() {
case reflect.String:
return writeMultipartValue(w, fieldName, []byte(value.String()))
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return writeMultipartValue(w, fieldName, []byte(strconv.FormatInt(value.Int(), 10)))
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return writeMultipartValue(w, fieldName, []byte(strconv.FormatUint(value.Uint(), 10)))
case reflect.Float32:
return writeMultipartValue(w, fieldName, []byte(strconv.FormatFloat(value.Float(), 'f', -1, 32)))
case reflect.Float64:
return writeMultipartValue(w, fieldName, []byte(strconv.FormatFloat(value.Float(), 'f', -1, 64)))
case reflect.Bool:
return writeMultipartValue(w, fieldName, []byte(strconv.FormatBool(value.Bool())))
case reflect.Slice:
if value.Type().Elem().Kind() == reflect.Uint8 {
if filename == "" {
filename = fieldName
}
fw, err := w.CreateFormFile(fieldName, filename)
if err != nil {
return err
}
_, err = fw.Write(value.Bytes())
return err
}
}
// Telegram expects nested objects and arrays in multipart requests as JSON strings.
data, err := json.Marshal(value.Interface())
if err != nil {
return err
}
if string(data) == "null" {
return nil
}
return writeMultipartValue(w, fieldName, data)
}
func writeMultipartValue(w *multipart.Writer, fieldName string, value []byte) error {
fw, err := w.CreateFormField(fieldName)
if err != nil {
return err
}
_, err = io.Copy(fw, strings.NewReader(string(value)))
return err
}

85
utils/multipart_test.go Normal file
View File

@@ -0,0 +1,85 @@
package utils_test
import (
"bytes"
"io"
"mime/multipart"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils"
)
type multipartEncodeParams struct {
ChatID int64 `json:"chat_id"`
MessageThreadID *int `json:"message_thread_id,omitempty"`
ReplyMarkup *tgapi.ReplyMarkup `json:"reply_markup,omitempty"`
CaptionEntities []tgapi.MessageEntity `json:"caption_entities,omitempty"`
ReplyParameters *tgapi.ReplyParameters `json:"reply_parameters,omitempty"`
}
func TestEncodeMultipartJSONFields(t *testing.T) {
threadID := 7
params := multipartEncodeParams{
ChatID: 42,
MessageThreadID: &threadID,
ReplyMarkup: &tgapi.ReplyMarkup{
InlineKeyboard: [][]tgapi.InlineKeyboardButton{{
{Text: "A", CallbackData: "b"},
}},
},
CaptionEntities: []tgapi.MessageEntity{{
Type: tgapi.MessageEntityBold,
Offset: 0,
Length: 4,
}},
}
body := bytes.NewBuffer(nil)
writer := multipart.NewWriter(body)
if err := utils.Encode(writer, params); err != nil {
t.Fatalf("Encode returned error: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("writer.Close returned error: %v", err)
}
got := readMultipartFields(t, body.Bytes(), writer.Boundary())
if got["chat_id"] != "42" {
t.Fatalf("chat_id mismatch: %q", got["chat_id"])
}
if got["message_thread_id"] != "7" {
t.Fatalf("message_thread_id mismatch: %q", got["message_thread_id"])
}
if got["reply_markup"] != `{"inline_keyboard":[[{"text":"A","callback_data":"b"}]]}` {
t.Fatalf("reply_markup mismatch: %q", got["reply_markup"])
}
if got["caption_entities"] != `[{"type":"bold","offset":0,"length":4}]` {
t.Fatalf("caption_entities mismatch: %q", got["caption_entities"])
}
if _, ok := got["reply_parameters"]; ok {
t.Fatalf("reply_parameters should be omitted when nil")
}
}
func readMultipartFields(t *testing.T, body []byte, boundary string) map[string]string {
t.Helper()
reader := multipart.NewReader(bytes.NewReader(body), boundary)
fields := make(map[string]string)
for {
part, err := reader.NextPart()
if err == io.EOF {
return fields
}
if err != nil {
t.Fatalf("NextPart returned error: %v", err)
}
data, err := io.ReadAll(part)
if err != nil {
t.Fatalf("ReadAll returned error: %v", err)
}
fields[part.FormName()] = string(data)
}
}