5 Commits

Author SHA1 Message Date
689eb8a5e2 slices additions; v0.3.8 2026-02-04 11:39:08 +03:00
6fd482b58f chat actions and file uploading; v0.3.7 2026-02-04 11:13:41 +03:00
913fa20e19 chat actions and file uploading; v0.3.7 2026-02-04 11:13:26 +03:00
c71aad0c79 uploader 2026-02-03 16:41:34 +03:00
90e2f38c18 v0.3.6; answerCallbackQuery 2026-02-03 14:51:57 +03:00
10 changed files with 583 additions and 28 deletions

15
api.go
View File

@@ -63,3 +63,18 @@ func (r TelegramRequest[R, P]) Do(bot *Bot) (*R, error) {
} }
return &response.Result, nil return &response.Result, nil
} }
func (b *Bot) GetFileByLink(link string) ([]byte, error) {
c := http.DefaultClient
u := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", b.token, link)
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return nil, err
}
res, err := c.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
return io.ReadAll(res.Body)
}

View File

@@ -57,6 +57,7 @@ func (b *Bot) handleCallback(update *Update, ctx *MsgContext) {
ctx.From = update.CallbackQuery.From ctx.From = update.CallbackQuery.From
ctx.Msg = update.CallbackQuery.Message ctx.Msg = update.CallbackQuery.Message
ctx.CallbackMsgId = update.CallbackQuery.Message.MessageID ctx.CallbackMsgId = update.CallbackQuery.Message.MessageID
ctx.CallbackQueryId = update.CallbackQuery.ID
ctx.Args = data.Args ctx.Args = data.Args
for _, plugin := range b.plugins { for _, plugin := range b.plugins {

View File

@@ -73,21 +73,36 @@ func (b *Bot) SendMessage(params *SendMessageP) (*Message, error) {
return req.Do(b) 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 { type SendPhotoP struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int `json:"chat_id"` ChatID int `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"` ParseMode ParseMode `json:"parse_mode,omitempty"`
Photo string `json:"photo"`
Caption string `json:"caption,omitempty"` Caption string `json:"caption,omitempty"`
CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"`
ShowCaptionAboveMedia bool `json:"show_caption_above_media"` ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"`
HasSpoiler bool `json:"has_spoiler"` HasSpoiler bool `json:"has_spoiler,omitempty"`
DisableNotifications bool `json:"disable_notifications,omitempty"` DisableNotifications bool `json:"disable_notifications,omitempty"`
ProtectContent bool `json:"protect_content,omitempty"` ProtectContent bool `json:"protect_content,omitempty"`
AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"`
MessageEffectID string `json:"message_effect_id,omitempty"` MessageEffectID string `json:"message_effect_id,omitempty"`
ReplyMarkup InlineKeyboardMarkup `json:"reply_markup,omitempty"` ReplyMarkup InlineKeyboardMarkup `json:"reply_markup,omitempty"`
Photo string `json:"photo"`
} }
func (b *Bot) SendPhoto(params *SendPhotoP) (*Message, error) { func (b *Bot) SendPhoto(params *SendPhotoP) (*Message, error) {
@@ -138,3 +153,45 @@ func (b *Bot) DeleteMessage(params *DeleteMessageP) (bool, error) {
} }
return *ok, err return *ok, err
} }
type AnswerCallbackQueryP struct {
CallbackQueryID string `json:"callback_query_id"`
Text string `json:"text,omitempty"`
ShowAlert bool `json:"show_alert,omitempty"`
URL string `json:"url,omitempty"`
CacheTime int `json:"cache_time,omitempty"`
}
func (b *Bot) AnswerCallbackQuery(params *AnswerCallbackQueryP) (bool, error) {
req := NewRequest[bool]("answerCallbackQuery", params)
ok, err := req.Do(b)
if err != nil {
return false, err
}
return *ok, err
}
type GetFileP struct {
FileId string `json:"file_id"`
}
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
}

View File

@@ -8,6 +8,7 @@ type MsgContext struct {
Update *Update Update *Update
From *User From *User
CallbackMsgId int CallbackMsgId int
CallbackQueryId string
FromID int FromID int
Prefix string Prefix string
Text string Text string
@@ -75,6 +76,10 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
} }
} }
func (m *AnswerMessage) EditCaption(text string) *AnswerMessage { func (m *AnswerMessage) EditCaption(text string) *AnswerMessage {
if m.MessageID == 0 {
m.ctx.Bot.logger.Errorln("Can't edit caption message, message id is zero")
return m
}
return m.ctx.editPhotoText(m.MessageID, text, nil) return m.ctx.editPhotoText(m.MessageID, text, nil)
} }
func (m *AnswerMessage) EditCaptionKeyboard(text string, kb *InlineKeyboard) *AnswerMessage { func (m *AnswerMessage) EditCaptionKeyboard(text string, kb *InlineKeyboard) *AnswerMessage {
@@ -114,8 +119,8 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard) *An
params := &SendPhotoP{ params := &SendPhotoP{
ChatID: ctx.Msg.Chat.ID, ChatID: ctx.Msg.Chat.ID,
Caption: text, Caption: text,
Photo: photoId,
ParseMode: ParseMD, ParseMode: ParseMD,
Photo: photoId,
} }
if kb != nil { if kb != nil {
params.ReplyMarkup = kb.Get() params.ReplyMarkup = kb.Get()
@@ -123,6 +128,9 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard) *An
msg, err := ctx.Bot.SendPhoto(params) msg, err := ctx.Bot.SendPhoto(params)
if err != nil { if err != nil {
ctx.Bot.logger.Errorln(err) ctx.Bot.logger.Errorln(err)
return &AnswerMessage{
ctx: ctx, Text: text, IsMedia: true,
}
} }
return &AnswerMessage{ return &AnswerMessage{
MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: true, MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: true,
@@ -151,14 +159,50 @@ func (ctx *MsgContext) CallbackDelete() {
ctx.delete(ctx.CallbackMsgId) ctx.delete(ctx.CallbackMsgId)
} }
func (ctx *MsgContext) Error(err error) { func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
_, sendErr := ctx.Bot.SendMessage(&SendMessageP{ if len(ctx.CallbackQueryId) == 0 {
ChatID: ctx.Msg.Chat.ID, return
Text: fmt.Sprintf(ctx.Bot.errorTemplate, EscapeMarkdown(err.Error())), }
_, err := ctx.Bot.AnswerCallbackQuery(&AnswerCallbackQueryP{
CallbackQueryID: ctx.CallbackQueryId,
Text: text, ShowAlert: showAlert, URL: url,
}) })
if err != nil {
ctx.Bot.logger.Errorln(err) ctx.Bot.logger.Errorln(err)
}
}
func (ctx *MsgContext) AnswerCbQuery() {
ctx.answerCallbackQuery("", "", false)
}
func (ctx *MsgContext) AnswerCbQueryText(text string) {
ctx.answerCallbackQuery("", text, false)
}
func (ctx *MsgContext) AnswerCbQueryAlert(text string) {
ctx.answerCallbackQuery("", text, true)
}
func (ctx *MsgContext) AnswerCbQueryUrl(u string) {
ctx.answerCallbackQuery(u, "", false)
}
if sendErr != nil { func (ctx *MsgContext) SendAction(action ChatActions) {
ctx.Bot.logger.Errorln(sendErr) _, 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()))
if ctx.CallbackQueryId != "" {
ctx.answerCallbackQuery("", text, false)
} else {
ctx.answer(text, nil)
}
ctx.Bot.logger.Errorln(err)
}
func (ctx *MsgContext) Error(err error) {
ctx.error(err)
}

131
multipart.go Normal file
View File

@@ -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
}

151
slice.go Normal file
View File

@@ -0,0 +1,151 @@
package laniakea
import "slices"
type Slice[T comparable] []T
func NewSliceFrom[T comparable](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]) Remove(el T) Slice[T] {
index := slices.Index(s, el)
if index == -1 {
return s
}
return s.Pop(index)
}
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
}
type Set[T comparable] []T
func NewSetFrom[T comparable](slice []T) Set[T] {
s := make(Set[T], 0)
for _, v := range slice {
if slices.Index(slice, v) >= 0 {
continue
}
s = append(s, v)
}
return s
}
func (s Set[T]) Len() int {
return len(s)
}
func (s Set[T]) Cap() int {
return cap(s)
}
func (s Set[T]) Get(index int) T {
return s[index]
}
func (s Set[T]) Last() T {
return s[s.Len()-1]
}
func (s Set[T]) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func (s Set[T]) Add(v T) Set[T] {
index := slices.Index(s, v)
if index >= 0 {
return s
}
return append(s, v)
}
func (s Set[T]) Pop(index int) Set[T] {
if index == 0 {
return s[1:]
}
out := make(Set[T], s.Len()-index)
for i, e := range s {
if i == index {
continue
}
out[i] = e
}
return out
}
func (s Set[T]) Remove(el T) Set[T] {
index := slices.Index(s, el)
if index == -1 {
return s
}
return s.Pop(index)
}
func (s Set[T]) ToSlice() Slice[T] {
out := make(Slice[T], s.Len())
copy(out, s)
return out
}
func (s Set[T]) ToArray() []T {
out := make([]T, len(s))
copy(out, s)
return out
}
func (s Set[T]) ToAnyArray() []any {
out := make([]any, len(s))
for i, v := range s {
out[i] = v
}
return out
}

View File

@@ -54,7 +54,7 @@ type Message struct {
Chat *Chat `json:"chat,omitempty"` Chat *Chat `json:"chat,omitempty"`
Text string `json:"text"` Text string `json:"text"`
Photo []*PhotoSize `json:"photo,omitempty"` Photo Slice[*PhotoSize] `json:"photo,omitempty"`
Caption string `json:"caption,omitempty"` Caption string `json:"caption,omitempty"`
ReplyToMessage *Message `json:"reply_to_message"` ReplyToMessage *Message `json:"reply_to_message"`
@@ -172,3 +172,23 @@ type ReactionCount struct {
Type *ReactionType `json:"type"` Type *ReactionType `json:"type"`
TotalCount int `json:"total_count"` TotalCount int `json:"total_count"`
} }
type File struct {
FileId string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
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"
)

136
uploader.go Normal file
View File

@@ -0,0 +1,136 @@
package laniakea
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"path/filepath"
)
type Uploader struct {
bot *Bot
}
func NewUploader(bot *Bot) *Uploader {
return &Uploader{bot: bot}
}
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)
fw, err := w.CreateFormFile(string(u.file.t), u.file.filename)
if err != nil {
w.Close()
return nil, err
}
_, err = fw.Write(u.file.data)
if err != nil {
w.Close()
return nil, err
}
err = Encode(w, u.params)
if err != nil {
w.Close()
return nil, err
}
err = w.Close()
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", url, buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", w.FormDataContentType())
bot.logger.Debugln("UPLOADER REQ", u.method)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
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
}
}

View File

@@ -41,12 +41,12 @@ func MapToJson(m map[string]any) (string, error) {
return string(data), err 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) data, err := json.Marshal(s)
if err != nil { if err != nil {
return nil, err return nil, err
} }
m := make(map[string]interface{}) m := make(map[string]any)
err = json.Unmarshal(data, &m) err = json.Unmarshal(data, &m)
return m, err return m, err
} }

View File

@@ -1,8 +1,8 @@
package laniakea package laniakea
const ( const (
VersionString = "0.3.2" VersionString = "0.3.8"
VersionMajor = 0 VersionMajor = 0
VersionMinor = 3 VersionMinor = 3
VersionPatch = 2 VersionPatch = 8
) )