diff --git a/methods.go b/methods.go index 723a321..f0cdcfb 100644 --- a/methods.go +++ b/methods.go @@ -73,21 +73,36 @@ func (b *Bot) SendMessage(params *SendMessageP) (*Message, error) { return req.Do(b) } +type SendPhotoBaseP struct { + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + Caption string `json:"caption,omitempty"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` + HasSpoiler bool `json:"has_spoiler,omitempty"` + DisableNotifications bool `json:"disable_notifications,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` + AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` + MessageEffectID string `json:"message_effect_id,omitempty"` + ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` +} type SendPhotoP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` ParseMode ParseMode `json:"parse_mode,omitempty"` - Photo string `json:"photo"` Caption string `json:"caption,omitempty"` CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` - ShowCaptionAboveMedia bool `json:"show_caption_above_media"` - HasSpoiler bool `json:"has_spoiler"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` + HasSpoiler bool `json:"has_spoiler,omitempty"` DisableNotifications bool `json:"disable_notifications,omitempty"` ProtectContent bool `json:"protect_content,omitempty"` AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` MessageEffectID string `json:"message_effect_id,omitempty"` ReplyMarkup InlineKeyboardMarkup `json:"reply_markup,omitempty"` + Photo string `json:"photo"` } func (b *Bot) SendPhoto(params *SendPhotoP) (*Message, error) { @@ -164,3 +179,19 @@ func (b *Bot) GetFile(params *GetFileP) (*File, error) { req := NewRequest[File]("getFile", params) return req.Do(b) } + +type SendChatActionP struct { + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + Action ChatActions `json:"action"` +} + +func (b *Bot) SendChatAction(params SendChatActionP) (bool, error) { + req := NewRequest[bool]("sendChatAction", params) + res, err := req.Do(b) + if err != nil { + return false, err + } + return *res, err +} diff --git a/msg_context.go b/msg_context.go index 319dc56..e1d92bc 100644 --- a/msg_context.go +++ b/msg_context.go @@ -119,8 +119,8 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard) *An params := &SendPhotoP{ ChatID: ctx.Msg.Chat.ID, Caption: text, - Photo: photoId, ParseMode: ParseMD, + Photo: photoId, } if kb != nil { params.ReplyMarkup = kb.Get() @@ -184,6 +184,15 @@ func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, "", false) } +func (ctx *MsgContext) SendAction(action ChatActions) { + _, err := ctx.Bot.SendChatAction(SendChatActionP{ + ChatID: ctx.Msg.Chat.ID, Action: action, + }) + if err != nil { + ctx.Bot.logger.Errorln(err) + } +} + func (ctx *MsgContext) error(err error) { text := fmt.Sprintf(ctx.Bot.errorTemplate, EscapeMarkdown(err.Error())) diff --git a/multipart.go b/multipart.go new file mode 100644 index 0000000..e928fc3 --- /dev/null +++ b/multipart.go @@ -0,0 +1,131 @@ +package laniakea + +import ( + "encoding/json" + "fmt" + "io" + "mime/multipart" + "reflect" + "slices" + "strconv" + "strings" +) + +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 { + return fmt.Errorf("req must be a struct") + } + + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + fieldType := t.Field(i) + + formTags := strings.Split(fieldType.Tag.Get("json"), ",") + fieldName := "" + if len(formTags) == 0 { + formTags = strings.Split(fieldType.Tag.Get("json"), ",") + } + + if len(formTags) > 0 { + fieldName = formTags[0] + if fieldName == "-" { + continue + } + if slices.Index(formTags, "omitempty") >= 0 { + if field.IsZero() { + continue + } + } + } else { + fieldName = strings.ToLower(fieldType.Name) + } + + var ( + fw io.Writer + err error + ) + switch field.Kind() { + case reflect.String: + if field.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, 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() { + filename := fieldType.Tag.Get("filename") + if filename == "" { + filename = fieldName + } + + ext := "" + filename = filename + ext + + fw, err = w.CreateFormFile(fieldName, filename) + if err == nil { + _, err = fw.Write(field.Bytes()) + } + } else if !field.IsNil() { + // Handle slice of primitive values (as multiple form fields with the same name) + for j := 0; j < field.Len(); j++ { + elem := field.Index(j) + fw, err = w.CreateFormField(fieldName) + if err == nil { + 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.Struct: + var jsonData []byte + jsonData, err = json.Marshal(field.Interface()) + if err == nil { + fw, err = w.CreateFormField(fieldName) + if err == nil { + _, err = fw.Write(jsonData) + } + } + } + + if err != nil { + return err + } + } + + return nil +} diff --git a/slice.go b/slice.go new file mode 100644 index 0000000..3964800 --- /dev/null +++ b/slice.go @@ -0,0 +1,70 @@ +package laniakea + +type Slice[T any] []T + +func NewSliceFrom[T any](slice []T) Slice[T] { + s := make(Slice[T], len(slice)) + copy(s[:], slice) + return s +} +func (s Slice[T]) Len() int { + return len(s) +} +func (s Slice[T]) Cap() int { + return cap(s) +} +func (s Slice[T]) Get(index int) T { + return s[index] +} +func (s Slice[T]) Last() T { + return s.Get(s.Len() - 1) +} +func (s Slice[T]) Swap(i, j int) Slice[T] { + s[i], s[j] = s[j], s[i] + return s +} +func (s Slice[T]) Filter(f func(e T) bool) Slice[T] { + out := make(Slice[T], 0) + for _, v := range s { + if f(v) { + out = append(out, v) + } + } + return out +} +func (s Slice[T]) Map(f func(e T) T) Slice[T] { + out := make(Slice[T], s.Len()) + for i, v := range s { + out[i] = f(v) + } + return out +} +func (s Slice[T]) Pop(index int) Slice[T] { + if index == 0 { + return s[1:] + } + out := make(Slice[T], s.Len()-index) + for i, e := range s { + if i == index { + continue + } + out[i] = e + } + return out +} +func (s Slice[T]) Push(e T) Slice[T] { + return append(s, e) +} + +func (s Slice[T]) ToArray() []T { + out := make([]T, len(s)) + copy(out, s) + return out +} +func (s Slice[T]) ToAnyArray() []any { + out := make([]any, len(s)) + for i, v := range s { + out[i] = v + } + return out +} diff --git a/types.go b/types.go index d6c278f..8791e96 100644 --- a/types.go +++ b/types.go @@ -54,9 +54,9 @@ type Message struct { Chat *Chat `json:"chat,omitempty"` Text string `json:"text"` - Photo []*PhotoSize `json:"photo,omitempty"` - Caption string `json:"caption,omitempty"` - ReplyToMessage *Message `json:"reply_to_message"` + Photo Slice[*PhotoSize] `json:"photo,omitempty"` + Caption string `json:"caption,omitempty"` + ReplyToMessage *Message `json:"reply_to_message"` ReplyMarkup *MessageReplyMarkup `json:"reply_markup,omitempty"` } @@ -179,3 +179,16 @@ type File struct { FileSize int `json:"file_size,omitempty"` FilePath string `json:"file_path,omitempty"` } + +type ChatActions string + +const ( + ChatActionTyping ChatActions = "typing" + ChatActionUploadPhoto ChatActions = "upload_photo" + ChatActionUploadVideo ChatActions = "upload_video" + ChatActionUploadVoice ChatActions = "upload_voice" + ChatActionUploadDocument ChatActions = "upload_document" + ChatActionChooseSticker ChatActions = "choose_sticker" + ChatActionFindLocation ChatActions = "find_location" + ChatActionUploadVideoNone ChatActions = "upload_video_none" +) diff --git a/uploader.go b/uploader.go index 3c1fadb..def36ce 100644 --- a/uploader.go +++ b/uploader.go @@ -2,12 +2,12 @@ package laniakea import ( "bytes" + "encoding/json" "fmt" "io" - "log" "mime/multipart" "net/http" - "strconv" + "path/filepath" ) type Uploader struct { @@ -17,43 +17,120 @@ type Uploader struct { func NewUploader(bot *Bot) *Uploader { return &Uploader{bot: bot} } -func (u *Uploader) UploadPhoto(chatId int, data []byte) error { - url := fmt.Sprintf("https://api.telegram.org/bot%s/sendPhoto", u.bot.token) + +type UploaderFileType string + +const ( + UploaderPhotoType UploaderFileType = "photo" + UploaderVideoType UploaderFileType = "video" + UploaderAudioType UploaderFileType = "audio" + UploaderDocumentType UploaderFileType = "document" + UploaderVoiceType UploaderFileType = "voice" + UploaderVideoNoteType UploaderFileType = "video_note" +) + +type UploaderFile struct { + filename string + data []byte + t UploaderFileType +} + +func NewUploaderFile(name string, data []byte) UploaderFile { + t := uploaderTypeByExt(name) + return UploaderFile{filename: name, data: data, t: t} +} + +// SetType used when auto-detect failed. I.e. you sending a voice message, but it detects as audio +func (f UploaderFile) SetType(t UploaderFileType) UploaderFile { + f.t = t + return f +} + +type UploaderRequest[R, P any] struct { + method string + file UploaderFile + params P +} + +func NewUploaderRequest[R, P any](method string, file UploaderFile, params P) UploaderRequest[R, P] { + return UploaderRequest[R, P]{method, file, params} +} + +func (u UploaderRequest[R, P]) Do(bot *Bot) (*R, error) { + url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", bot.token, u.method) buf := bytes.NewBuffer(nil) w := multipart.NewWriter(buf) - defer w.Close() - err := w.WriteField("chat_id", strconv.Itoa(chatId)) + + fw, err := w.CreateFormFile(string(u.file.t), u.file.filename) if err != nil { - return err + w.Close() + return nil, err } - fw, err := w.CreateFormFile("photo", "photo.jpg") + _, err = fw.Write(u.file.data) if err != nil { - return err + w.Close() + return nil, err } - _, err = fw.Write(data) + err = Encode(w, u.params) if err != nil { - return err + w.Close() + return nil, err } err = w.Close() if err != nil { - return err + return nil, err } req, err := http.NewRequest("POST", url, buf) if err != nil { - return err + return nil, err } req.Header.Set("Content-Type", w.FormDataContentType()) - resp, err := http.DefaultClient.Do(req) + bot.logger.Debugln("UPLOADER REQ", u.method) + res, err := http.DefaultClient.Do(req) if err != nil { - return err + return nil, err } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) if err != nil { - return err + return nil, err + } + bot.logger.Debugln("UPLOADER RES", u.method, string(body)) + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("[%d] %s", res.StatusCode, string(body)) + } + + response := new(ApiResponse[*R]) + err = json.Unmarshal(body, response) + if err != nil { + return nil, err + } + if !response.Ok { + return nil, fmt.Errorf("[%d] %s", response.ErrorCode, response.Description) + } + return response.Result, nil +} + +func (u *Uploader) UploadPhoto(file UploaderFile, params SendPhotoBaseP) (*Message, error) { + req := NewUploaderRequest[Message]("sendPhoto", file, params) + return req.Do(u.bot) +} + +func uploaderTypeByExt(filename string) UploaderFileType { + ext := filepath.Ext(filename) + switch ext { + case ".jpg", ".jpeg", ".png", ".webp", ".bmp": + return UploaderPhotoType + case ".mp4": + return UploaderVideoType + case ".mp3", ".m4a": + return UploaderAudioType + case ".ogg": + return UploaderVoiceType + default: + return UploaderDocumentType } - log.Println("upload", string(body)) - return nil } diff --git a/utils.go b/utils.go index ac5cd38..5bfad8d 100644 --- a/utils.go +++ b/utils.go @@ -41,12 +41,12 @@ func MapToJson(m map[string]any) (string, error) { return string(data), err } -func StructToMap(s interface{}) (map[string]interface{}, error) { +func StructToMap(s any) (map[string]any, error) { data, err := json.Marshal(s) if err != nil { return nil, err } - m := make(map[string]interface{}) + m := make(map[string]any) err = json.Unmarshal(data, &m) return m, err }