fix: correct Telegram update/keyboard models and harden env parsing
This commit is contained in:
@@ -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
85
utils/multipart_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user