add authorization policies

Introduce first-class policy helpers and composition through middleware

Normalize chat context for more updates and add regression coverage

Document the completed backlog item in changelog, TODO, and wiki
This commit is contained in:
2026-03-30 18:17:28 +03:00
parent e2444752c2
commit 140f3397b2
17 changed files with 1221 additions and 667 deletions
+3 -243
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
@@ -61,103 +60,6 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
}
}
func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
var msg *tgapi.Message
if update.Message != nil {
msg = update.Message
} else if update.ChannelPost != nil {
msg = update.ChannelPost
} else {
return
}
var text string
if len(msg.Text) > 0 {
text = msg.Text
} else if len(msg.Caption) > 0 {
text = msg.Caption
} else {
return
}
prefix, cmd, args := bot.parseCommand(text)
if cmd == "" {
return
}
ctx.Prefix = prefix
if strings.Contains(cmd, "@") {
botUsername := bot.username
if botUsername != "" && strings.HasSuffix(cmd, "@"+botUsername) {
cmd = cmd[:len(cmd)-len("@"+botUsername)] // убираем @botname
}
}
// Ищем команду по точному совпадению
for _, plugin := range bot.plugins {
if _, exists := plugin.commands[cmd]; exists {
ctx.Text = args
ctx.Args = strings.Fields(args) // Убирает лишние пробелы
if plugin.logger != nil {
ctx.Logger = plugin.logger
}
if !plugin.executeMiddlewares(ctx, bot.appData) {
return
}
plugin.executeCmd(cmd, ctx, bot.appData)
return
}
}
}
func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
data, err := bot.decodePayload(update.CallbackQuery.Data)
if err != nil {
bot.logger.Errorln(err)
return
}
ctx.Args = data.Args
for _, plugin := range bot.plugins {
_, ok := plugin.payloads[data.Command]
if !ok {
continue
}
ctx.Logger = plugin.logger
if ctx.Logger == nil {
ctx.Logger = bot.logger
}
if !plugin.executeMiddlewares(ctx, bot.appData) {
return
}
plugin.executePayload(data.Command, ctx, bot.appData)
return
}
}
func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) {
for _, plugin := range bot.plugins {
handler, ok := plugin.handlers[u.Type]
if !ok {
continue
}
pluginCtx := cloneMsgContext(ctx)
if plugin.logger != nil {
pluginCtx.Logger = plugin.logger
}
if !plugin.executeMiddlewares(pluginCtx, bot.appData) {
continue
}
if err := handler(pluginCtx, bot.appData); err != nil {
pluginCtx.error(err)
}
}
}
func cloneMsgContext(src *MsgContext) *MsgContext {
cloned := *src
if src.Args != nil {
@@ -166,139 +68,6 @@ func cloneMsgContext(src *MsgContext) *MsgContext {
return &cloned
}
func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) {
var from *tgapi.User
switch u.Type {
case tgapi.UpdateTypeMessage:
if u.Message != nil {
ctx.Msg = u.Message
}
case tgapi.UpdateTypeEditedMessage:
if u.EditedMessage != nil {
ctx.Msg = u.EditedMessage
}
case tgapi.UpdateTypeChannelPost:
if u.ChannelPost != nil {
ctx.Msg = u.ChannelPost
}
case tgapi.UpdateTypeEditedChannelPost:
if u.EditedChannelPost != nil {
ctx.Msg = u.EditedChannelPost
}
case tgapi.UpdateTypeBusinessMessage:
if u.BusinessMessage != nil {
ctx.Msg = u.BusinessMessage
}
case tgapi.UpdateTypeEditedBusinessMessage:
if u.EditedBusinessMessage != nil {
ctx.Msg = u.EditedBusinessMessage
}
case tgapi.UpdateTypeInlineQuery:
if u.InlineQuery != nil {
from = &u.InlineQuery.From
}
case tgapi.UpdateTypeChosenInlineResult:
if u.ChosenInlineResult != nil {
from = &u.ChosenInlineResult.From
}
case tgapi.UpdateTypeCallbackQuery:
if u.CallbackQuery != nil {
if u.CallbackQuery.Message != nil {
ctx.Msg = u.CallbackQuery.Message
ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID
}
if u.CallbackQuery.InlineMessageID != nil {
ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID
}
ctx.CallbackQueryId = u.CallbackQuery.ID
from = &u.CallbackQuery.From
}
case tgapi.UpdateTypeShippingQuery:
if u.ShippingQuery != nil {
from = &u.ShippingQuery.From
}
case tgapi.UpdateTypePreCheckoutQuery:
if u.PreCheckoutQuery != nil {
from = &u.PreCheckoutQuery.From
}
case tgapi.UpdateTypePurchasedPaidMedia:
if u.PurchasedPaidMedia != nil {
from = &u.PurchasedPaidMedia.From
}
case tgapi.UpdateTypeMyChatMember:
if u.MyChatMember != nil {
from = &u.MyChatMember.From
}
case tgapi.UpdateTypeChatMember:
if u.ChatMember != nil {
from = &u.ChatMember.From
}
case tgapi.UpdateTypeChatJoinRequest:
if u.ChatJoinRequest != nil {
from = &u.ChatJoinRequest.From
}
case tgapi.UpdateTypeBusinessConnection:
if u.BusinessConnection != nil {
from = &u.BusinessConnection.User
}
case tgapi.UpdateTypePollAnswer:
if u.PollAnswer != nil {
from = &u.PollAnswer.User
}
case tgapi.UpdateTypeMessageReaction:
if u.MessageReaction != nil {
from = u.MessageReaction.User
}
case tgapi.UpdateTypeChatBoost:
if u.ChatBoost != nil {
from = &u.ChatBoost.Boost.Source.User
}
case tgapi.UpdateTypeRemovedChatBoost:
if u.RemovedChatBoost != nil {
from = &u.RemovedChatBoost.Source.User
}
}
if ctx.Msg != nil && from == nil {
from = ctx.Msg.From
}
if from != nil {
ctx.From = from
ctx.FromID = from.ID
}
}
func (bot *Bot[T]) checkPrefixes(text string) (string, bool) {
for _, prefix := range bot.prefixes {
if prefix == "" {
if bot.logger != nil {
bot.logger.Warnln("empty prefix is not allowed")
}
continue
}
if strings.HasPrefix(text, prefix) {
return prefix, true
}
}
return "", false
}
func (bot *Bot[T]) parseCommand(text string) (prefix, cmd, args string) {
if prefix, hasPrefix := bot.checkPrefixes(text); hasPrefix {
text = strings.TrimSpace(text[len(prefix):])
spaceIndex := strings.Index(text, " ")
var cmd string
var args string
if spaceIndex == -1 {
cmd = text
args = ""
} else {
cmd = text[:spaceIndex]
args = strings.TrimSpace(text[spaceIndex:])
}
return prefix, cmd, args
}
return "", "", ""
}
func encodeJsonPayload(d CallbackData) (string, error) {
b, err := json.Marshal(d)
if err != nil {
@@ -306,11 +75,13 @@ func encodeJsonPayload(d CallbackData) (string, error) {
}
return string(b), nil
}
func decodeJsonPayload(s string) (CallbackData, error) {
var data CallbackData
err := json.Unmarshal([]byte(s), &data)
return data, err
}
func encodeBase64Payload(d CallbackData) (string, error) {
data, err := encodeJsonPayload(d)
if err != nil {
@@ -321,15 +92,6 @@ func encodeBase64Payload(d CallbackData) (string, error) {
return string(dst), nil
}
// func encodePayload(payloadType BotPayloadType, d CallbackData) (string, error) {
// switch payloadType {
// case BotPayloadBase64:
// return encodeBase64Payload(d)
// case BotPayloadJson:
// return encodeJsonPayload(d)
// }
// return "", ErrInvalidPayloadType
// }
func decodeBase64Payload(s string) (CallbackData, error) {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
@@ -337,6 +99,7 @@ func decodeBase64Payload(s string) (CallbackData, error) {
}
return decodeJsonPayload(string(b))
}
func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) {
switch payloadType {
case BotPayloadBase64:
@@ -369,9 +132,6 @@ func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackD
return CallbackData{}, "", ErrInvalidPayloadType
}
// func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) {
// return encodePayload(bot.payloadType, d)
// }
func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) {
data, decodedType, err := decodePayload(bot.payloadType, s, bot.strictPayloadType)
if err != nil {