10 Commits
v0.3.3 ... dev

Author SHA1 Message Date
2e9e82d43b slices, queue and map now independent module 2026-02-04 12:49:12 +03:00
55d4065259 work on HashMap and slices 2026-02-04 11:59:25 +03:00
b89f27574f work on HashMap and slices 2026-02-04 11:52:25 +03:00
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
0921d306fd some fixes 2026-01-29 12:08:28 +03:00
6970c37c6b bump slog to v1.0.2 2026-01-29 11:59:38 +03:00
15 changed files with 521 additions and 179 deletions

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# Laniakea
A lightweight, easy to use and performance Telegram API wrapper for bot development.

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

23
bot.go
View File

@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"git.nix13.pw/scuroneko/extypes"
"git.nix13.pw/scuroneko/slog" "git.nix13.pw/scuroneko/slog"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"github.com/vinovest/sqlx" "github.com/vinovest/sqlx"
@@ -30,8 +31,8 @@ type Bot struct {
logger *slog.Logger logger *slog.Logger
requestLogger *slog.Logger requestLogger *slog.Logger
plugins []*Plugin plugins []Plugin
middlewares []*Middleware middlewares []Middleware
prefixes []string prefixes []string
runners []Runner runners []Runner
@@ -39,7 +40,7 @@ type Bot struct {
updateOffset int updateOffset int
updateTypes []string updateTypes []string
updateQueue *Queue[*Update] updateQueue *extypes.Queue[*Update]
} }
type BotSettings struct { type BotSettings struct {
@@ -73,9 +74,9 @@ func LoadPrefixesFromEnv() []string {
return strings.Split(prefixesS, ";") return strings.Split(prefixesS, ";")
} }
func NewBot(settings *BotSettings) *Bot { func NewBot(settings *BotSettings) *Bot {
updateQueue := CreateQueue[*Update](256) updateQueue := extypes.CreateQueue[*Update](256)
bot := &Bot{ bot := &Bot{
updateOffset: 0, plugins: make([]*Plugin, 0), debug: settings.Debug, errorTemplate: "%s", updateOffset: 0, plugins: make([]Plugin, 0), debug: settings.Debug, errorTemplate: "%s",
prefixes: settings.Prefixes, updateTypes: make([]string, 0), runners: make([]Runner, 0), prefixes: settings.Prefixes, updateTypes: make([]string, 0), runners: make([]Runner, 0),
updateQueue: updateQueue, updateQueue: updateQueue,
token: settings.Token, token: settings.Token,
@@ -117,6 +118,12 @@ func NewBot(settings *BotSettings) *Bot {
} }
} }
u, err := bot.GetMe()
if err != nil {
bot.logger.Fatal(err)
}
bot.logger.Infof("Authorized as %s\n", u.FirstName)
return bot return bot
} }
@@ -171,20 +178,20 @@ func (b *Bot) Debug(debug bool) *Bot {
b.debug = debug b.debug = debug
return b return b
} }
func (b *Bot) AddPlugins(plugin ...*Plugin) *Bot { func (b *Bot) AddPlugins(plugin ...Plugin) *Bot {
b.plugins = append(b.plugins, plugin...) b.plugins = append(b.plugins, plugin...)
for _, p := range plugin { for _, p := range plugin {
b.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.Name)) b.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.Name))
} }
return b return b
} }
func (b *Bot) AddMiddleware(middleware ...*Middleware) *Bot { func (b *Bot) AddMiddleware(middleware ...Middleware) *Bot {
b.middlewares = append(b.middlewares, middleware...) b.middlewares = append(b.middlewares, middleware...)
for _, m := range middleware { for _, m := range middleware {
b.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.Name)) b.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.Name))
} }
sort.Slice(&b.middlewares, func(i, j int) bool { sort.Slice(b.middlewares, func(i, j int) bool {
first := b.middlewares[i] first := b.middlewares[i]
second := b.middlewares[j] second := b.middlewares[j]
if first.Order == second.Order { if first.Order == second.Order {

5
go.mod
View File

@@ -1,9 +1,10 @@
module git.nix13.pw/scuroneko/laniakea module git.nix13.pw/scuroneko/laniakea
go 1.25 go 1.25.6
require ( require (
git.nix13.pw/scuroneko/slog v1.0.0 git.nix13.pw/scuroneko/extypes v1.0.2
git.nix13.pw/scuroneko/slog v1.0.2
github.com/redis/go-redis/v9 v9.17.3 github.com/redis/go-redis/v9 v9.17.3
github.com/vinovest/sqlx v1.7.1 github.com/vinovest/sqlx v1.7.1
go.mongodb.org/mongo-driver/v2 v2.5.0 go.mongodb.org/mongo-driver/v2 v2.5.0

6
go.sum
View File

@@ -1,7 +1,9 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.nix13.pw/scuroneko/slog v1.0.0 h1:PI0YePrmCopjrljUfwCtBIEwNYB+PBgDzPcCXbetpcE= git.nix13.pw/scuroneko/extypes v1.0.2 h1:Qz1InLccaB9crXY33oGrSetPHePKfQAUqW/p/iYXmJk=
git.nix13.pw/scuroneko/slog v1.0.0/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= git.nix13.pw/scuroneko/extypes v1.0.2/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw=
git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g=
git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=

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) {
@@ -130,7 +145,99 @@ type DeleteMessageP struct {
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
} }
func (b *Bot) DeleteMessage(params *DeleteMessageP) (*Message, error) { func (b *Bot) DeleteMessage(params *DeleteMessageP) (bool, error) {
req := NewRequest[Message]("deleteMessage", params) req := NewRequest[bool]("deleteMessage", params)
ok, err := req.Do(b)
if err != nil {
return false, 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) 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
}
type SetMessageReactionP struct {
ChatId int `json:"chat_id"`
MessageId int `json:"message_id"`
IsBig bool `json:"is_big,omitempty"`
}
type SetMessageReactionEmojiP struct {
SetMessageReactionP
Reaction []ReactionTypeEmoji `json:"reaction"`
}
func (b *Bot) SetMessageReactionEmoji(params SetMessageReactionEmojiP) (bool, error) {
req := NewRequest[bool]("setMessageReaction", params)
res, err := req.Do(b)
if err != nil {
return false, err
}
return *res, err
}
type SetMessageReactionCustomEmojiP struct {
SetMessageReactionP
Reaction []ReactionTypeCustomEmoji `json:"reaction"`
}
func (b *Bot) SetMessageReactionCustom(params SetMessageReactionCustomEmojiP) (bool, error) {
req := NewRequest[bool]("setMessageReaction", params)
res, err := req.Do(b)
if err != nil {
return false, err
}
return *res, err
}
type SetMessageReactionPaidP struct {
SetMessageReactionP
}
func (b *Bot) SetMessageReactionPaid(params SetMessageReactionPaidP) (bool, error) {
req := NewRequest[bool]("setMessageReaction", 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
}

View File

@@ -45,17 +45,16 @@ func (p *PluginBuilder) UpdateListener(listener CommandExecutor) *PluginBuilder
return p return p
} }
func (p *PluginBuilder) Build() *Plugin { func (p *PluginBuilder) Build() Plugin {
if len(p.commands) == 0 && len(p.payloads) == 0 { if len(p.commands) == 0 && len(p.payloads) == 0 {
log.Println("no command or payloads") log.Println("no command or payloads")
} }
plugin := &Plugin{ return Plugin{
Name: p.name, Name: p.name,
Commands: p.commands, Commands: p.commands,
Payloads: p.payloads, Payloads: p.payloads,
UpdateListener: p.updateListener, UpdateListener: p.updateListener,
} }
return plugin
} }
func (p *Plugin) Execute(cmd string, ctx *MsgContext, dbContext *DatabaseContext) { func (p *Plugin) Execute(cmd string, ctx *MsgContext, dbContext *DatabaseContext) {
@@ -68,13 +67,13 @@ func (p *Plugin) ExecutePayload(payload string, ctx *MsgContext, dbContext *Data
type Middleware struct { type Middleware struct {
Name string Name string
Executor *CommandExecutor Executor CommandExecutor
Order int Order int
Async bool Async bool
} }
type MiddlewareBuilder struct { type MiddlewareBuilder struct {
name string name string
executor *CommandExecutor executor CommandExecutor
order int order int
async bool async bool
} }
@@ -87,7 +86,7 @@ func (m *MiddlewareBuilder) SetName(name string) *MiddlewareBuilder {
return m return m
} }
func (m *MiddlewareBuilder) SetExecutor(executor CommandExecutor) *MiddlewareBuilder { func (m *MiddlewareBuilder) SetExecutor(executor CommandExecutor) *MiddlewareBuilder {
m.executor = &executor m.executor = executor
return m return m
} }
func (m *MiddlewareBuilder) SetOrder(order int) *MiddlewareBuilder { func (m *MiddlewareBuilder) SetOrder(order int) *MiddlewareBuilder {
@@ -98,19 +97,18 @@ func (m *MiddlewareBuilder) SetAsync(async bool) *MiddlewareBuilder {
m.async = async m.async = async
return m return m
} }
func (m *MiddlewareBuilder) Build() *Middleware { func (m *MiddlewareBuilder) Build() Middleware {
return &Middleware{ return Middleware{
Name: m.name, Name: m.name,
Executor: m.executor, Executor: m.executor,
Order: m.order, Order: m.order,
Async: m.async, Async: m.async,
} }
} }
func (m *Middleware) Execute(ctx *MsgContext, db *DatabaseContext) { func (m Middleware) Execute(ctx *MsgContext, db *DatabaseContext) {
exec := *m.Executor
if m.Async { if m.Async {
go exec(ctx, db) go m.Executor(ctx, db)
} else { } else {
exec(ctx, db) m.Execute(ctx, db)
} }
} }

View File

@@ -1,71 +0,0 @@
package laniakea
import (
"errors"
"sync"
)
var QueueFullErr = errors.New("queue full")
type Queue[T any] struct {
size uint64
mu sync.RWMutex
queue []T
}
func CreateQueue[T any](size uint64) *Queue[T] {
return &Queue[T]{
queue: make([]T, 0),
size: size,
}
}
func (q *Queue[T]) Enqueue(el T) error {
if q.IsFull() {
return QueueFullErr
}
q.queue = append(q.queue, el)
return nil
}
func (q *Queue[T]) Peak() T {
q.mu.RLock()
defer q.mu.RUnlock()
return q.queue[0]
}
func (q *Queue[T]) IsEmpty() bool {
return q.Length() == 0
}
func (q *Queue[T]) IsFull() bool {
return q.Length() == q.size
}
func (q *Queue[T]) Length() uint64 {
q.mu.RLock()
defer q.mu.RUnlock()
return uint64(len(q.queue))
}
func (q *Queue[T]) Dequeue() T {
q.mu.RLock()
el := q.queue[0]
q.mu.RUnlock()
if q.Length() == 1 {
q.mu.Lock()
q.queue = make([]T, 0)
q.mu.Unlock()
return el
}
q.mu.Lock()
q.queue = q.queue[1:]
q.mu.Unlock()
return el
}
func (q *Queue[T]) Raw() []T {
return q.queue
}

View File

@@ -1,5 +1,7 @@
package laniakea package laniakea
import "git.nix13.pw/scuroneko/extypes"
type Update struct { type Update struct {
UpdateID int `json:"update_id"` UpdateID int `json:"update_id"`
Message *Message `json:"message"` Message *Message `json:"message"`
@@ -54,7 +56,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 extypes.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 +174,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
} }
@@ -73,57 +73,3 @@ func EscapeMarkdownV2(s string) string {
} }
return s return s
} }
func GetUnclosedTag(markdown string) string {
// order is important!
var tags = []string{
"```",
"`",
"*",
"_",
}
var currentTag = ""
markdownRunes := []rune(markdown)
var i = 0
outer:
for i < len(markdownRunes) {
// skip escaped characters (only outside tags)
if markdownRunes[i] == '\\' && currentTag == "" {
i += 2
continue
}
if currentTag != "" {
if strings.HasPrefix(string(markdownRunes[i:]), currentTag) {
// turn a tag off
i += len(currentTag)
currentTag = ""
continue
}
} else {
for _, tag := range tags {
if strings.HasPrefix(string(markdownRunes[i:]), tag) {
// turn a tag on
currentTag = tag
i += len(currentTag)
continue outer
}
}
}
i++
}
return currentTag
}
func IsValid(markdown string) bool {
return GetUnclosedTag(markdown) == ""
}
func FixMarkdown(markdown string) string {
tag := GetUnclosedTag(markdown)
if tag == "" {
return markdown
}
return markdown + tag
}

View File

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