diff --git a/feeders/telegrambot.go b/feeders/telegrambot.go new file mode 100644 index 0000000..94dc186 --- /dev/null +++ b/feeders/telegrambot.go @@ -0,0 +1,686 @@ +package feeders + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Matrix86/driplane/data" + "github.com/evilsocket/islazy/log" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +// TelegramBot is a Feeder that consumes Telegram Bot API updates. +type TelegramBot struct { + Base + + token string + mode string + addr string + webhookURL string + webhookSecret string + deleteWebhook bool + commands []string + allowedUsers map[int64]bool + allowedUsernames map[string]bool + allowedChats map[int64]bool + events map[string]bool + debug bool + + bot *bot.Bot + server *http.Server + ctx context.Context + cancel context.CancelFunc +} + +// NewTelegramBotFeeder is the factory method registered for this feeder. +func NewTelegramBotFeeder(conf map[string]string) (Feeder, error) { + t := &TelegramBot{ + mode: "polling", + addr: ":3000", + allowedUsers: map[int64]bool{}, + allowedUsernames: map[string]bool{}, + allowedChats: map[int64]bool{}, + events: defaultTelegramBotEvents(), + } + + if v, ok := conf["telegrambot.token"]; ok { + t.token = v + } + if v, ok := conf["telegrambot.mode"]; ok { + t.mode = strings.ToLower(strings.TrimSpace(v)) + } + if v, ok := conf["telegrambot.addr"]; ok && v != "" { + t.addr = v + } + if v, ok := conf["telegrambot.webhook_url"]; ok { + t.webhookURL = v + } + if v, ok := conf["telegrambot.webhook_secret"]; ok { + t.webhookSecret = v + } + if v, ok := conf["telegrambot.delete_webhook_on_stop"]; ok && v == "true" { + t.deleteWebhook = true + } + if v, ok := conf["telegrambot.debug"]; ok && v == "true" { + t.debug = true + } + + if v, ok := conf["telegrambot.allowed_users"]; ok { + t.allowedUsers, t.allowedUsernames = parseAllowedUsers(v) + } + if v, ok := conf["telegrambot.allowed_chats"]; ok { + t.allowedChats = parseAllowedChats(v) + } + if v, ok := conf["telegrambot.events"]; ok { + t.events = parseEvents(v) + } + if v, ok := conf["telegrambot.commands"]; ok { + t.commands = parseCommands(v) + } + + if t.token == "" { + return nil, fmt.Errorf("the param 'telegrambot.token' is required by telegrambot feeder") + } + if t.mode != "polling" && t.mode != "webhook" { + return nil, fmt.Errorf("telegrambot.mode must be 'polling' or 'webhook', got '%s'", t.mode) + } + if t.mode == "webhook" && t.webhookURL == "" { + return nil, fmt.Errorf("telegrambot.webhook_url is required when mode='webhook'") + } + + return t, nil +} + +func defaultTelegramBotEvents() map[string]bool { + return map[string]bool{ + "message": true, + "command": true, + "callback_query": true, + "edited_message": true, + "channel_post": true, + "edited_channel_post": true, + "chat_member": true, + "my_chat_member": true, + } +} + +func parseAllowedUsers(raw string) (map[int64]bool, map[string]bool) { + ids := map[int64]bool{} + names := map[string]bool{} + for _, item := range strings.Split(raw, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if strings.HasPrefix(item, "@") { + names[strings.ToLower(item[1:])] = true + continue + } + n, err := strconv.ParseInt(item, 10, 64) + if err != nil { + log.Warning("telegrambot: ignoring malformed allowed_users entry '%s'", item) + continue + } + ids[n] = true + } + return ids, names +} + +func parseAllowedChats(raw string) map[int64]bool { + out := map[int64]bool{} + for _, item := range strings.Split(raw, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + n, err := strconv.ParseInt(item, 10, 64) + if err != nil { + log.Warning("telegrambot: ignoring malformed allowed_chats entry '%s'", item) + continue + } + out[n] = true + } + return out +} + +func parseEvents(raw string) map[string]bool { + all := defaultTelegramBotEvents() + out := map[string]bool{} + for _, item := range strings.Split(raw, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := all[item]; !ok { + log.Warning("telegrambot: ignoring unknown event '%s'", item) + continue + } + out[item] = true + } + return out +} + +func parseCommands(raw string) []string { + var out []string + for _, item := range strings.Split(raw, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + out = append(out, item) + } + return out +} + +// Start initializes the bot client and begins consuming updates. +func (t *TelegramBot) Start() { + log.Debug("Initialization of TelegramBot") + + opts := []bot.Option{ + bot.WithDefaultHandler(t.onUpdate), + } + if t.debug { + opts = append(opts, bot.WithDebug()) + } + if t.mode == "webhook" && t.webhookSecret != "" { + opts = append(opts, bot.WithWebhookSecretToken(t.webhookSecret)) + } + + b, err := bot.New(t.token, opts...) + if err != nil { + log.Error("telegrambot: bot.New failed: %s", err) + return + } + t.bot = b + + t.ctx, t.cancel = context.WithCancel(context.Background()) + + switch t.mode { + case "polling": + go t.bot.Start(t.ctx) + case "webhook": + if err := t.startWebhook(); err != nil { + log.Error("telegrambot: webhook start failed: %s", err) + return + } + } + t.isRunning = true +} + +// Stop tears down the bot client and any HTTP server. +func (t *TelegramBot) Stop() { + log.Debug("feeder '%s' stream stop", t.Name()) + if !t.isRunning { + return + } + t.isRunning = false + + if t.cancel != nil { + t.cancel() + } + + if t.mode == "webhook" { + t.stopWebhook() + } +} + +func (t *TelegramBot) startWebhook() error { + _, err := t.bot.SetWebhook(t.ctx, &bot.SetWebhookParams{ + URL: t.webhookURL, + SecretToken: t.webhookSecret, + }) + if err != nil { + return fmt.Errorf("SetWebhook: %w", err) + } + + path := "/" + if u, err := url.Parse(t.webhookURL); err == nil && u.Path != "" { + path = u.Path + } + + mux := http.NewServeMux() + mux.Handle(path, t.bot.WebhookHandler()) + t.server = &http.Server{Addr: t.addr, Handler: mux} + + go func() { + log.Info("telegrambot: webhook server listening on %s", t.addr) + if err := t.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error("telegrambot: webhook server error: %s", err) + } + }() + go t.bot.StartWebhook(t.ctx) + return nil +} + +func (t *TelegramBot) stopWebhook() { + if t.deleteWebhook && t.bot != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := t.bot.DeleteWebhook(ctx, &bot.DeleteWebhookParams{}); err != nil { + log.Error("telegrambot: DeleteWebhook: %s", err) + } + } + if t.server != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := t.server.Shutdown(ctx); err != nil { + log.Error("telegrambot: server.Shutdown: %s", err) + } + } +} + +// OnEvent handles bus events. +func (t *TelegramBot) OnEvent(e *data.Event) { + if e.Type == "shutdown" && t.isRunning { + t.Stop() + } +} + +// detectType returns the canonical event type for an update, or "" if the +// update has no handleable payload. +func (t *TelegramBot) detectType(u *models.Update) string { + switch { + case u.Message != nil: + if t.looksLikeCommand(u.Message.Text) { + return "command" + } + return "message" + case u.EditedMessage != nil: + return "edited_message" + case u.ChannelPost != nil: + return "channel_post" + case u.EditedChannelPost != nil: + return "edited_channel_post" + case u.CallbackQuery != nil: + return "callback_query" + case u.MyChatMember != nil: + return "my_chat_member" + case u.ChatMember != nil: + return "chat_member" + default: + return "" + } +} + +// looksLikeCommand decides whether a message text should be classified as a +// command for this feeder (respects the configured explicit list). +func (t *TelegramBot) looksLikeCommand(text string) bool { + if !strings.HasPrefix(text, "/") { + return false + } + cmd, _ := splitCommand(text) + if len(t.commands) == 0 { + return true + } + for _, c := range t.commands { + if c == cmd { + return true + } + } + return false +} + +// allowed reports whether an event passes the configured allowlists. +// +// Semantics: +// - When allowed_users is configured, the sender MUST be in the list. Updates +// without sender identity (e.g. channel posts) skip the user check. +// - When allowed_chats is configured, the chat must be in the list, EXCEPT +// for private chats from a verified allowed user — those bypass the chat +// list so users in allowed_users can DM the bot regardless of allowed_chats. +func (t *TelegramBot) allowed(userID int64, username string, chatID int64, chatType models.ChatType) bool { + userListConfigured := len(t.allowedUsers) > 0 || len(t.allowedUsernames) > 0 + chatListConfigured := len(t.allowedChats) > 0 + + if userListConfigured && (userID != 0 || username != "") { + userInList := (userID != 0 && t.allowedUsers[userID]) || + (username != "" && t.allowedUsernames[strings.ToLower(username)]) + if !userInList { + return false + } + } + + if chatListConfigured { + if t.allowedChats[chatID] { + return true + } + if userListConfigured && chatType == models.ChatTypePrivate { + return true + } + return false + } + + return true +} + +// extractIdentity pulls (userID, username, chatID, chatType) from an update. +// Any field may be zero when the update has no corresponding party. +func extractIdentity(u *models.Update) (int64, string, int64, models.ChatType) { + switch { + case u.Message != nil: + uid, name := userInfo(u.Message.From) + return uid, name, u.Message.Chat.ID, u.Message.Chat.Type + case u.EditedMessage != nil: + uid, name := userInfo(u.EditedMessage.From) + return uid, name, u.EditedMessage.Chat.ID, u.EditedMessage.Chat.Type + case u.ChannelPost != nil: + uid, name := userInfo(u.ChannelPost.From) + return uid, name, u.ChannelPost.Chat.ID, u.ChannelPost.Chat.Type + case u.EditedChannelPost != nil: + uid, name := userInfo(u.EditedChannelPost.From) + return uid, name, u.EditedChannelPost.Chat.ID, u.EditedChannelPost.Chat.Type + case u.CallbackQuery != nil: + chat, _ := callbackChat(u.CallbackQuery) + var chatID int64 + var ct models.ChatType + if chat != nil { + chatID = chat.ID + ct = chat.Type + } + return u.CallbackQuery.From.ID, u.CallbackQuery.From.Username, chatID, ct + case u.MyChatMember != nil: + return u.MyChatMember.From.ID, u.MyChatMember.From.Username, u.MyChatMember.Chat.ID, u.MyChatMember.Chat.Type + case u.ChatMember != nil: + return u.ChatMember.From.ID, u.ChatMember.From.Username, u.ChatMember.Chat.ID, u.ChatMember.Chat.Type + default: + return 0, "", 0, "" + } +} + +func userInfo(u *models.User) (int64, string) { + if u == nil { + return 0, "" + } + return u.ID, u.Username +} + +// callbackChat returns the chat the inline-keyboard message lives in, plus the +// message ID, regardless of whether the bot still has access to that message. +// Returns (nil, 0) for inline_message_id-only callbacks (no chat context). +func callbackChat(cq *models.CallbackQuery) (*models.Chat, int) { + if cq == nil { + return nil, 0 + } + switch cq.Message.Type { + case models.MaybeInaccessibleMessageTypeMessage: + if cq.Message.Message != nil { + return &cq.Message.Message.Chat, cq.Message.Message.ID + } + case models.MaybeInaccessibleMessageTypeInaccessibleMessage: + if cq.Message.InaccessibleMessage != nil { + return &cq.Message.InaccessibleMessage.Chat, cq.Message.InaccessibleMessage.MessageID + } + } + return nil, 0 +} + +// splitCommand returns (command, args-remainder). Strips any @botname suffix. +func splitCommand(text string) (string, string) { + parts := strings.SplitN(text, " ", 2) + head := parts[0] + rest := "" + if len(parts) == 2 { + rest = parts[1] + } + if idx := strings.Index(head, "@"); idx > 0 { + head = head[:idx] + } + return head, rest +} + +func fillExtraFromUser(extra map[string]interface{}, u *models.User) { + if u == nil { + return + } + extra["user_id"] = strconv.FormatInt(u.ID, 10) + extra["user_username"] = u.Username + extra["user_firstname"] = u.FirstName + extra["user_lastname"] = u.LastName + extra["user_language"] = u.LanguageCode + extra["user_isbot"] = strconv.FormatBool(u.IsBot) + extra["user_ispremium"] = strconv.FormatBool(u.IsPremium) +} + +func fillExtraFromChat(extra map[string]interface{}, c models.Chat) { + extra["chat_id"] = strconv.FormatInt(c.ID, 10) + extra["chat_type"] = string(c.Type) + extra["chat_title"] = c.Title + extra["chat_username"] = c.Username +} + +func fillExtraFromMessage(extra map[string]interface{}, m *models.Message, edited bool) { + extra["msg_id"] = strconv.Itoa(m.ID) + extra["msg_timestamp"] = strconv.Itoa(m.Date) + tm := time.Unix(int64(m.Date), 0) + extra["msg_date"] = tm.Format(time.DateOnly) + extra["msg_time"] = tm.Format(time.TimeOnly) + extra["msg_edited"] = strconv.FormatBool(edited) + extra["text"] = m.Text + + if m.Caption != "" { + extra["msg_caption"] = m.Caption + } + if m.ReplyToMessage != nil { + extra["msg_reply_to_id"] = strconv.Itoa(m.ReplyToMessage.ID) + } + if m.ForwardOrigin != nil { + switch { + case m.ForwardOrigin.MessageOriginUser != nil: + extra["msg_forward_from"] = strconv.FormatInt(m.ForwardOrigin.MessageOriginUser.SenderUser.ID, 10) + case m.ForwardOrigin.MessageOriginChat != nil: + extra["msg_forward_from_chat"] = strconv.FormatInt(m.ForwardOrigin.MessageOriginChat.SenderChat.ID, 10) + case m.ForwardOrigin.MessageOriginChannel != nil: + extra["msg_forward_from_chat"] = strconv.FormatInt(m.ForwardOrigin.MessageOriginChannel.Chat.ID, 10) + } + } + + hasMedia, kind, fileID, fileUID, name, ext, size := mediaInfo(m) + extra["msg_hasmedia"] = strconv.FormatBool(hasMedia) + if hasMedia { + extra["msg_mediatype"] = kind + extra["msg_file_id"] = fileID + extra["msg_file_unique_id"] = fileUID + extra["msg_medianame"] = name + extra["msg_mediaext"] = ext + extra["msg_mediasize"] = strconv.FormatInt(size, 10) + } +} + +// mediaInfo inspects a Message and returns (hasMedia, kind, fileID, fileUniqueID, name, ext, size). +func mediaInfo(m *models.Message) (bool, string, string, string, string, string, int64) { + switch { + case len(m.Photo) > 0: + biggest := m.Photo[0] + for _, p := range m.Photo[1:] { + if p.FileSize > biggest.FileSize { + biggest = p + } + } + name := fmt.Sprintf("photo_%s.jpg", biggest.FileUniqueID) + return true, "photo", biggest.FileID, biggest.FileUniqueID, name, ".jpg", int64(biggest.FileSize) + case m.Document != nil: + d := m.Document + name := d.FileName + ext := filepath.Ext(name) + if name == "" { + name = "doc_" + d.FileUniqueID + } + return true, "document", d.FileID, d.FileUniqueID, name, ext, int64(d.FileSize) + case m.Video != nil: + v := m.Video + name := fmt.Sprintf("video_%s.mp4", v.FileUniqueID) + return true, "video", v.FileID, v.FileUniqueID, name, ".mp4", int64(v.FileSize) + case m.Audio != nil: + a := m.Audio + name := a.FileName + ext := filepath.Ext(name) + if name == "" { + name = fmt.Sprintf("audio_%s.mp3", a.FileUniqueID) + ext = ".mp3" + } + return true, "audio", a.FileID, a.FileUniqueID, name, ext, int64(a.FileSize) + case m.Voice != nil: + v := m.Voice + return true, "voice", v.FileID, v.FileUniqueID, "voice_" + v.FileUniqueID + ".ogg", ".ogg", int64(v.FileSize) + case m.Animation != nil: + a := m.Animation + name := a.FileName + ext := filepath.Ext(name) + if name == "" { + name = "animation_" + a.FileUniqueID + ".mp4" + ext = ".mp4" + } + return true, "animation", a.FileID, a.FileUniqueID, name, ext, int64(a.FileSize) + case m.Sticker != nil: + s := m.Sticker + return true, "sticker", s.FileID, s.FileUniqueID, "sticker_" + s.FileUniqueID + ".webp", ".webp", int64(s.FileSize) + } + return false, "", "", "", "", "", 0 +} + +// onUpdate is the single entry point registered as the library's default handler. +func (t *TelegramBot) onUpdate(_ context.Context, _ *bot.Bot, u *models.Update) { + defer func() { + if r := recover(); r != nil { + log.Error("telegrambot: panic in onUpdate: %v", r) + } + }() + + evType := t.detectType(u) + if evType == "" { + return + } + if !t.events[evType] { + return + } + + userID, username, chatID, chatType := extractIdentity(u) + if !t.allowed(userID, username, chatID, chatType) { + log.Debug("telegrambot: update rejected by allowlist (user=%d chat=%d)", userID, chatID) + return + } + + extra := map[string]interface{}{ + "type": evType, + "update_id": strconv.FormatInt(u.ID, 10), + "_telegrambot_api": t.bot, + } + + main := t.fillByType(extra, evType, u) + + t.Propagate(data.NewMessageWithExtra(main, extra)) +} + +// fillByType routes to the per-type filler and returns the "main" text. +func (t *TelegramBot) fillByType(extra map[string]interface{}, evType string, u *models.Update) string { + switch evType { + case "message", "command": + m := u.Message + fillExtraFromUser(extra, m.From) + fillExtraFromChat(extra, m.Chat) + fillExtraFromMessage(extra, m, false) + if evType == "command" { + cmd, args := splitCommand(m.Text) + extra["command"] = cmd + extra["command_args"] = args + } + return mainOf(m) + case "edited_message": + m := u.EditedMessage + fillExtraFromUser(extra, m.From) + fillExtraFromChat(extra, m.Chat) + fillExtraFromMessage(extra, m, true) + if m.EditDate != 0 { + extra["msg_edit_date"] = strconv.Itoa(m.EditDate) + } + return mainOf(m) + case "channel_post": + m := u.ChannelPost + fillExtraFromUser(extra, m.From) + fillExtraFromChat(extra, m.Chat) + fillExtraFromMessage(extra, m, false) + return mainOf(m) + case "edited_channel_post": + m := u.EditedChannelPost + fillExtraFromUser(extra, m.From) + fillExtraFromChat(extra, m.Chat) + fillExtraFromMessage(extra, m, true) + if m.EditDate != 0 { + extra["msg_edit_date"] = strconv.Itoa(m.EditDate) + } + return mainOf(m) + case "callback_query": + cq := u.CallbackQuery + fillExtraFromUser(extra, &cq.From) + extra["callback_id"] = cq.ID + extra["callback_data"] = cq.Data + extra["callback_chatinstance"] = cq.ChatInstance + if chat, msgID := callbackChat(cq); chat != nil { + fillExtraFromChat(extra, *chat) + extra["msg_id"] = strconv.Itoa(msgID) + } + return cq.Data + case "chat_member", "my_chat_member": + var cmu *models.ChatMemberUpdated + if evType == "chat_member" { + cmu = u.ChatMember + } else { + cmu = u.MyChatMember + } + fillExtraFromUser(extra, &cmu.From) + fillExtraFromChat(extra, cmu.Chat) + extra["member_old_status"] = chatMemberStatus(cmu.OldChatMember) + extra["member_new_status"] = chatMemberStatus(cmu.NewChatMember) + mid, mname := chatMemberIdentity(cmu.NewChatMember) + if mid == 0 { + mid, mname = chatMemberIdentity(cmu.OldChatMember) + } + extra["member_user_id"] = strconv.FormatInt(mid, 10) + extra["member_user_username"] = mname + return "" + } + return "" +} + +func mainOf(m *models.Message) string { + if m.Text != "" { + return m.Text + } + return m.Caption +} + +// chatMemberStatus returns the Telegram status string for a ChatMember union. +func chatMemberStatus(cm models.ChatMember) string { + return string(cm.Type) +} + +// chatMemberIdentity extracts (user_id, username) from a ChatMember union by +// walking its non-nil variant. +func chatMemberIdentity(cm models.ChatMember) (int64, string) { + switch { + case cm.Owner != nil && cm.Owner.User != nil: + return cm.Owner.User.ID, cm.Owner.User.Username + case cm.Administrator != nil: + return cm.Administrator.User.ID, cm.Administrator.User.Username + case cm.Member != nil && cm.Member.User != nil: + return cm.Member.User.ID, cm.Member.User.Username + case cm.Restricted != nil && cm.Restricted.User != nil: + return cm.Restricted.User.ID, cm.Restricted.User.Username + case cm.Left != nil && cm.Left.User != nil: + return cm.Left.User.ID, cm.Left.User.Username + case cm.Banned != nil && cm.Banned.User != nil: + return cm.Banned.User.ID, cm.Banned.User.Username + } + return 0, "" +} + +func init() { + register("telegrambot", NewTelegramBotFeeder) +} diff --git a/feeders/telegrambot_test.go b/feeders/telegrambot_test.go new file mode 100644 index 0000000..b184464 --- /dev/null +++ b/feeders/telegrambot_test.go @@ -0,0 +1,622 @@ +package feeders + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/Matrix86/driplane/data" + "github.com/asaskevich/EventBus" + "github.com/go-telegram/bot/models" +) + +func TestTelegramBotRegistered(t *testing.T) { + if _, ok := feederFactories["telegrambotfeeder"]; !ok { + t.Errorf("telegrambot feeder should be registered as 'telegrambotfeeder'") + } +} + +func TestTelegramBotTokenRequired(t *testing.T) { + _, err := NewTelegramBotFeeder(map[string]string{}) + if err == nil { + t.Errorf("expected error when token is missing") + } +} + +func TestTelegramBotMinimalConfig(t *testing.T) { + feeder, err := NewTelegramBotFeeder(map[string]string{ + "telegrambot.token": "fake-token", + }) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tb, ok := feeder.(*TelegramBot) + if !ok { + t.Fatal("cannot cast to *TelegramBot") + } + if tb.token != "fake-token" { + t.Errorf("expected token 'fake-token', got '%s'", tb.token) + } + if tb.mode != "polling" { + t.Errorf("expected default mode 'polling', got '%s'", tb.mode) + } + if tb.addr != ":3000" { + t.Errorf("expected default addr ':3000', got '%s'", tb.addr) + } +} + +// newTestTelegramBot builds a feeder wired to a capture channel. +func newTestTelegramBot(t *testing.T, conf map[string]string) (*TelegramBot, chan *data.Message) { + t.Helper() + if _, ok := conf["telegrambot.token"]; !ok { + conf["telegrambot.token"] = "fake-token" + } + feeder, err := NewTelegramBotFeeder(conf) + if err != nil { + t.Fatalf("constructor returned error: %s", err) + } + tb, ok := feeder.(*TelegramBot) + if !ok { + t.Fatal("cannot cast to *TelegramBot") + } + bus := EventBus.New() + tb.setBus(bus) + tb.setName("telegrambotfeeder") + tb.setID(1) + + ch := make(chan *data.Message, 16) + bus.Subscribe(tb.GetIdentifier(), func(m *data.Message) { ch <- m }) + return tb, ch +} + +var _ = fmt.Sprintf // keep import around as helpers evolve + +func TestTelegramBotAllowedUsersParsing(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.allowed_users": "123, @alice , 456,@Bob", + }) + if !tb.allowedUsers[123] || !tb.allowedUsers[456] { + t.Errorf("expected numeric IDs 123, 456 in allowedUsers: %v", tb.allowedUsers) + } + if !tb.allowedUsernames["alice"] || !tb.allowedUsernames["bob"] { + t.Errorf("expected usernames 'alice', 'bob' (lowercase): %v", tb.allowedUsernames) + } +} + +func TestTelegramBotAllowedChatsParsing(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.allowed_chats": "-100123, 456", + }) + if !tb.allowedChats[-100123] || !tb.allowedChats[456] { + t.Errorf("expected chat IDs -100123, 456: %v", tb.allowedChats) + } +} + +func TestTelegramBotEventsFilter(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.events": "message, command , callback_query", + }) + if len(tb.events) != 3 { + t.Errorf("expected 3 events enabled, got %d: %v", len(tb.events), tb.events) + } + if !tb.events["message"] || !tb.events["command"] || !tb.events["callback_query"] { + t.Errorf("expected message+command+callback_query, got: %v", tb.events) + } + if tb.events["edited_message"] { + t.Errorf("expected edited_message disabled") + } +} + +func TestTelegramBotCommandsParsing(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.commands": "/start, /help,/status", + }) + expected := []string{"/start", "/help", "/status"} + if len(tb.commands) != len(expected) { + t.Fatalf("expected %d commands, got %d: %v", len(expected), len(tb.commands), tb.commands) + } + for i, c := range expected { + if tb.commands[i] != c { + t.Errorf("commands[%d]: expected '%s', got '%s'", i, c, tb.commands[i]) + } + } +} + +func TestTelegramBotMalformedAllowedUserSkipped(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.allowed_users": "123,not_a_number,@alice", + }) + if !tb.allowedUsers[123] { + t.Errorf("expected 123 parsed: %v", tb.allowedUsers) + } + if len(tb.allowedUsers) != 1 { + t.Errorf("expected 1 numeric ID, got: %v", tb.allowedUsers) + } + if !tb.allowedUsernames["alice"] { + t.Errorf("expected @alice parsed: %v", tb.allowedUsernames) + } +} + +func TestTelegramBotWebhookModeRequiresURL(t *testing.T) { + _, err := NewTelegramBotFeeder(map[string]string{ + "telegrambot.token": "fake", + "telegrambot.mode": "webhook", + }) + if err == nil { + t.Errorf("expected error when mode=webhook without webhook_url") + } +} + +func TestTelegramBotInvalidModeRejected(t *testing.T) { + _, err := NewTelegramBotFeeder(map[string]string{ + "telegrambot.token": "fake", + "telegrambot.mode": "gibberish", + }) + if err == nil { + t.Errorf("expected error for invalid mode") + } +} + +func TestTelegramBotDetectType(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{}) + + cases := []struct { + name string + u *models.Update + want string + }{ + {"message", &models.Update{Message: &models.Message{Text: "hi"}}, "message"}, + {"command_slash", &models.Update{Message: &models.Message{Text: "/start"}}, "command"}, + {"command_slash_with_args", &models.Update{Message: &models.Message{Text: "/help me"}}, "command"}, + {"edited_message", &models.Update{EditedMessage: &models.Message{Text: "hi"}}, "edited_message"}, + {"channel_post", &models.Update{ChannelPost: &models.Message{Text: "news"}}, "channel_post"}, + {"edited_channel_post", &models.Update{EditedChannelPost: &models.Message{Text: "news"}}, "edited_channel_post"}, + {"callback_query", &models.Update{CallbackQuery: &models.CallbackQuery{Data: "x"}}, "callback_query"}, + {"chat_member", &models.Update{ChatMember: &models.ChatMemberUpdated{}}, "chat_member"}, + {"my_chat_member", &models.Update{MyChatMember: &models.ChatMemberUpdated{}}, "my_chat_member"}, + {"unknown", &models.Update{}, ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tb.detectType(tc.u) + if got != tc.want { + t.Errorf("detectType(%s): want '%s', got '%s'", tc.name, tc.want, got) + } + }) + } +} + +func TestTelegramBotDetectTypeCommandListMismatch(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.commands": "/start", + }) + got := tb.detectType(&models.Update{Message: &models.Message{Text: "/help"}}) + if got != "message" { + t.Errorf("when commands list is configured and command not in list, expected 'message', got '%s'", got) + } +} + +func TestSplitCommand(t *testing.T) { + cases := []struct { + in string + wantCmd string + wantArgs string + }{ + {"/start", "/start", ""}, + {"/start arg1 arg2", "/start", "arg1 arg2"}, + {"/start@mybot", "/start", ""}, + {"/start@mybot arg1 arg2", "/start", "arg1 arg2"}, + {"hello", "hello", ""}, + {"", "", ""}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + cmd, args := splitCommand(tc.in) + if cmd != tc.wantCmd || args != tc.wantArgs { + t.Errorf("splitCommand(%q) = (%q,%q), want (%q,%q)", tc.in, cmd, args, tc.wantCmd, tc.wantArgs) + } + }) + } +} + +func TestTelegramBotAccessAllowAll(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{}) + if !tb.allowed(42, "alice", 100, models.ChatTypeGroup) { + t.Errorf("empty allowlists should allow everyone") + } +} + +func TestTelegramBotAccessUserID(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.allowed_users": "42", + }) + if !tb.allowed(42, "alice", 100, models.ChatTypeGroup) { + t.Errorf("user 42 should pass") + } + if tb.allowed(43, "alice", 100, models.ChatTypeGroup) { + t.Errorf("user 43 should be blocked") + } +} + +func TestTelegramBotAccessUsername(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.allowed_users": "@alice", + }) + if !tb.allowed(0, "Alice", 100, models.ChatTypeGroup) { + t.Errorf("username Alice should match @alice case-insensitively") + } + if tb.allowed(0, "bob", 100, models.ChatTypeGroup) { + t.Errorf("username bob should be blocked") + } +} + +func TestTelegramBotAccessChats(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.allowed_chats": "-100", + }) + if !tb.allowed(1, "x", -100, models.ChatTypeGroup) { + t.Errorf("chat -100 should pass") + } + if tb.allowed(1, "x", 200, models.ChatTypeGroup) { + t.Errorf("chat 200 should be blocked") + } +} + +func TestTelegramBotAccessUserAndChatCombined(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.allowed_users": "42", + "telegrambot.allowed_chats": "-100", + }) + if !tb.allowed(42, "x", -100, models.ChatTypeGroup) { + t.Errorf("user 42 in chat -100 should pass") + } + if tb.allowed(42, "x", 200, models.ChatTypeGroup) { + t.Errorf("user 42 in chat 200 should be blocked (chat mismatch)") + } + if tb.allowed(43, "x", -100, models.ChatTypeGroup) { + t.Errorf("user 43 in chat -100 should be blocked (user mismatch)") + } +} + +// Allowed user in private chat must pass even when the private chat ID is not +// in allowed_chats. Private DMs from listed users bypass the chat allowlist. +func TestTelegramBotAccessAllowedUserPrivateChat(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.allowed_users": "42", + "telegrambot.allowed_chats": "-100", + }) + if !tb.allowed(42, "x", 999, models.ChatTypePrivate) { + t.Errorf("allowed user in private chat should pass even if chat not in allowed_chats") + } + if tb.allowed(43, "x", 999, models.ChatTypePrivate) { + t.Errorf("non-listed user in private chat must still be rejected") + } +} + +// Private-chat bypass must NOT trigger when allowed_users is empty (chat list +// is the only filter, so private chat ID still has to be in allowed_chats). +func TestTelegramBotAccessPrivateChatNoUserList(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.allowed_chats": "-100", + }) + if tb.allowed(42, "x", 999, models.ChatTypePrivate) { + t.Errorf("private chat ID not in allowed_chats must be rejected when no user list configured") + } +} + +func TestTelegramBotAccessNoUserPresent(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.allowed_users": "42", + "telegrambot.allowed_chats": "-100", + }) + if !tb.allowed(0, "", -100, models.ChatTypeChannel) { + t.Errorf("no-sender event in allowed chat should pass") + } + if tb.allowed(0, "", 200, models.ChatTypeChannel) { + t.Errorf("no-sender event in disallowed chat should be blocked") + } +} + +func TestTelegramBotExtractIdentityMessage(t *testing.T) { + u := &models.Update{ + Message: &models.Message{ + From: &models.User{ID: 42, Username: "alice"}, + Chat: models.Chat{ID: -100, Type: models.ChatTypeGroup}, + }, + } + uid, name, cid, ct := extractIdentity(u) + if uid != 42 || name != "alice" || cid != -100 || ct != models.ChatTypeGroup { + t.Errorf("message: got (%d,%s,%d,%s), want (42,alice,-100,group)", uid, name, cid, ct) + } +} + +func TestTelegramBotExtractIdentityCallback(t *testing.T) { + u := &models.Update{ + CallbackQuery: &models.CallbackQuery{ + From: models.User{ID: 7, Username: "bob"}, + }, + } + uid, name, cid, _ := extractIdentity(u) + if uid != 7 || name != "bob" || cid != 0 { + t.Errorf("callback (no message): got (%d,%s,%d), want (7,bob,0)", uid, name, cid) + } +} + +func TestTelegramBotExtractIdentityCallbackWithMessage(t *testing.T) { + u := &models.Update{ + CallbackQuery: &models.CallbackQuery{ + From: models.User{ID: 7, Username: "bob"}, + Message: models.MaybeInaccessibleMessage{ + Type: models.MaybeInaccessibleMessageTypeMessage, + Message: &models.Message{ID: 42, Chat: models.Chat{ID: -100, Type: models.ChatTypeSupergroup}}, + }, + }, + } + uid, name, cid, ct := extractIdentity(u) + if uid != 7 || name != "bob" || cid != -100 || ct != models.ChatTypeSupergroup { + t.Errorf("callback w/ message: got (%d,%s,%d,%s), want (7,bob,-100,supergroup)", uid, name, cid, ct) + } +} + +func TestTelegramBotExtractIdentityCallbackInaccessible(t *testing.T) { + u := &models.Update{ + CallbackQuery: &models.CallbackQuery{ + From: models.User{ID: 7, Username: "bob"}, + Message: models.MaybeInaccessibleMessage{ + Type: models.MaybeInaccessibleMessageTypeInaccessibleMessage, + InaccessibleMessage: &models.InaccessibleMessage{MessageID: 99, Chat: models.Chat{ID: -200, Type: models.ChatTypeGroup}}, + }, + }, + } + uid, _, cid, ct := extractIdentity(u) + if uid != 7 || cid != -200 || ct != models.ChatTypeGroup { + t.Errorf("callback inaccessible: got (%d,%d,%s), want (7,-200,group)", uid, cid, ct) + } +} + +func TestTelegramBotExtractIdentityChannelPost(t *testing.T) { + u := &models.Update{ + ChannelPost: &models.Message{ + Chat: models.Chat{ID: -200, Type: models.ChatTypeChannel}, + }, + } + uid, name, cid, ct := extractIdentity(u) + if uid != 0 || name != "" || cid != -200 || ct != models.ChatTypeChannel { + t.Errorf("channel post: got (%d,%s,%d,%s), want (0,,-200,channel)", uid, name, cid, ct) + } +} + +func TestTelegramBotExtractIdentityChatMember(t *testing.T) { + u := &models.Update{ + ChatMember: &models.ChatMemberUpdated{ + From: models.User{ID: 9, Username: "carol"}, + Chat: models.Chat{ID: -300, Type: models.ChatTypeSupergroup}, + }, + } + uid, name, cid, ct := extractIdentity(u) + if uid != 9 || name != "carol" || cid != -300 || ct != models.ChatTypeSupergroup { + t.Errorf("chat_member: got (%d,%s,%d,%s), want (9,carol,-300,supergroup)", uid, name, cid, ct) + } +} + +func TestFillExtraFromUser(t *testing.T) { + extra := map[string]interface{}{} + u := &models.User{ + ID: 42, Username: "alice", FirstName: "Alice", LastName: "Smith", + LanguageCode: "en", IsBot: false, IsPremium: true, + } + fillExtraFromUser(extra, u) + if extra["user_id"] != "42" || extra["user_username"] != "alice" || + extra["user_firstname"] != "Alice" || extra["user_lastname"] != "Smith" || + extra["user_language"] != "en" || extra["user_isbot"] != "false" || + extra["user_ispremium"] != "true" { + t.Errorf("unexpected extra: %v", extra) + } +} + +func TestFillExtraFromUserNil(t *testing.T) { + extra := map[string]interface{}{} + fillExtraFromUser(extra, nil) + if len(extra) != 0 { + t.Errorf("expected no keys for nil user: %v", extra) + } +} + +func TestFillExtraFromChat(t *testing.T) { + extra := map[string]interface{}{} + c := models.Chat{ID: -100, Type: models.ChatTypeGroup, Title: "Tst", Username: "grp"} + fillExtraFromChat(extra, c) + if extra["chat_id"] != "-100" || extra["chat_type"] != "group" || + extra["chat_title"] != "Tst" || extra["chat_username"] != "grp" { + t.Errorf("unexpected extra: %v", extra) + } +} + +func TestFillExtraFromMessageText(t *testing.T) { + extra := map[string]interface{}{} + m := &models.Message{ID: 7, Date: 1700000000, Text: "hello"} + fillExtraFromMessage(extra, m, false) + if extra["msg_id"] != "7" || extra["msg_timestamp"] != "1700000000" || + extra["text"] != "hello" || extra["msg_edited"] != "false" || + extra["msg_hasmedia"] != "false" { + t.Errorf("unexpected extra: %v", extra) + } +} + +func TestFillExtraFromMessagePhoto(t *testing.T) { + extra := map[string]interface{}{} + m := &models.Message{ + ID: 8, Date: 1700000001, + Photo: []models.PhotoSize{ + {FileID: "small", FileUniqueID: "us", Width: 90, Height: 90, FileSize: 100}, + {FileID: "big", FileUniqueID: "ub", Width: 800, Height: 600, FileSize: 5000}, + }, + Caption: "nice pic", + } + fillExtraFromMessage(extra, m, false) + if extra["msg_hasmedia"] != "true" { + t.Errorf("expected msg_hasmedia true: %v", extra) + } + if extra["msg_mediatype"] != "photo" { + t.Errorf("expected msg_mediatype photo, got %v", extra["msg_mediatype"]) + } + if extra["msg_file_id"] != "big" { + t.Errorf("expected largest photo FileID 'big', got %v", extra["msg_file_id"]) + } + if extra["msg_mediasize"] != "5000" { + t.Errorf("expected size 5000, got %v", extra["msg_mediasize"]) + } + if extra["msg_caption"] != "nice pic" { + t.Errorf("expected caption, got %v", extra["msg_caption"]) + } +} + +func TestFillExtraFromMessageDocument(t *testing.T) { + extra := map[string]interface{}{} + m := &models.Message{ + ID: 9, Date: 1700000002, + Document: &models.Document{ + FileID: "doc1", FileUniqueID: "udoc1", + FileName: "report.pdf", MimeType: "application/pdf", FileSize: 2048, + }, + } + fillExtraFromMessage(extra, m, true) + if extra["msg_edited"] != "true" { + t.Errorf("expected msg_edited true") + } + if extra["msg_mediatype"] != "document" || + extra["msg_medianame"] != "report.pdf" || + extra["msg_mediaext"] != ".pdf" || + extra["msg_mediasize"] != "2048" { + t.Errorf("unexpected doc extras: %v", extra) + } +} + +func TestOnUpdatePropagatesMessage(t *testing.T) { + tb, ch := newTestTelegramBot(t, map[string]string{}) + tb.onUpdate(context.Background(), nil, &models.Update{ + ID: 1, + Message: &models.Message{ + ID: 10, Date: 1700000000, Text: "hi", + From: &models.User{ID: 42, Username: "alice"}, + Chat: models.Chat{ID: -100, Type: models.ChatTypeGroup}, + }, + }) + select { + case msg := <-ch: + if msg.GetMessage() != "hi" { + t.Errorf("expected main 'hi', got %v", msg.GetMessage()) + } + x := msg.GetExtra() + if x["type"] != "message" { + t.Errorf("expected type=message, got %v", x["type"]) + } + if x["user_id"] != "42" || x["chat_id"] != "-100" { + t.Errorf("missing identity keys: %v", x) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for propagated message") + } +} + +func TestOnUpdatePropagatesCommand(t *testing.T) { + tb, ch := newTestTelegramBot(t, map[string]string{ + "telegrambot.commands": "/start", + }) + tb.onUpdate(context.Background(), nil, &models.Update{ + Message: &models.Message{ + ID: 11, Date: 1700000000, Text: "/start hello world", + From: &models.User{ID: 1, Username: "u"}, + Chat: models.Chat{ID: 1, Type: models.ChatTypePrivate}, + }, + }) + select { + case msg := <-ch: + x := msg.GetExtra() + if x["type"] != "command" { + t.Errorf("expected type=command, got %v", x["type"]) + } + if x["command"] != "/start" { + t.Errorf("expected command=/start, got %v", x["command"]) + } + if x["command_args"] != "hello world" { + t.Errorf("expected command_args='hello world', got %v", x["command_args"]) + } + case <-time.After(time.Second): + t.Fatal("timed out") + } +} + +func TestOnUpdateDropsDisabledEvent(t *testing.T) { + tb, ch := newTestTelegramBot(t, map[string]string{ + "telegrambot.events": "command", + }) + tb.onUpdate(context.Background(), nil, &models.Update{ + Message: &models.Message{ + Text: "hello", + From: &models.User{ID: 1, Username: "u"}, + Chat: models.Chat{ID: 1}, + }, + }) + select { + case msg := <-ch: + t.Errorf("expected no propagation, got: %v", msg.GetExtra()) + case <-time.After(100 * time.Millisecond): + } +} + +func TestOnUpdateDropsDisallowedUser(t *testing.T) { + tb, ch := newTestTelegramBot(t, map[string]string{ + "telegrambot.allowed_users": "999", + }) + tb.onUpdate(context.Background(), nil, &models.Update{ + Message: &models.Message{ + Text: "hello", + From: &models.User{ID: 1, Username: "u"}, + Chat: models.Chat{ID: 1}, + }, + }) + select { + case msg := <-ch: + t.Errorf("expected drop, got: %v", msg.GetExtra()) + case <-time.After(100 * time.Millisecond): + } +} + +func TestOnUpdatePropagatesCallbackQuery(t *testing.T) { + tb, ch := newTestTelegramBot(t, map[string]string{}) + tb.onUpdate(context.Background(), nil, &models.Update{ + CallbackQuery: &models.CallbackQuery{ + ID: "cbq1", + From: models.User{ID: 5, Username: "dave"}, + Data: "action:yes", + ChatInstance: "instx", + }, + }) + select { + case msg := <-ch: + x := msg.GetExtra() + if x["type"] != "callback_query" { + t.Errorf("expected type=callback_query, got %v", x["type"]) + } + if msg.GetMessage() != "action:yes" { + t.Errorf("expected main 'action:yes', got %v", msg.GetMessage()) + } + if x["callback_id"] != "cbq1" { + t.Errorf("expected callback_id cbq1, got %v", x["callback_id"]) + } + case <-time.After(time.Second): + t.Fatal("timed out") + } +} + +func TestTelegramBotStartPollingWithInvalidToken(t *testing.T) { + tb, _ := newTestTelegramBot(t, map[string]string{ + "telegrambot.token": "definitely-not-a-real-token", + }) + tb.Start() + time.Sleep(50 * time.Millisecond) + tb.Stop() +} diff --git a/filters/telegrambot.go b/filters/telegrambot.go new file mode 100644 index 0000000..b1170ab --- /dev/null +++ b/filters/telegrambot.go @@ -0,0 +1,655 @@ +package filters + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" + "github.com/evilsocket/islazy/log" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/Matrix86/driplane/data" +) + +// TelegramBot is a Filter to perform Bot API actions on top of the telegrambot feeder. +type TelegramBot struct { + Base + + action string + + toChat *template.Template + text *template.Template + caption *template.Template + parseMode *template.Template + messageID *template.Template + callbackID *template.Template + fileID *template.Template + filename *template.Template + media *template.Template + mediaType *template.Template + replyMarkup *template.Template + + showAlert bool + + params map[string]string +} + +var validTelegramBotActions = map[string]bool{ + "send_message": true, + "edit_message": true, + "delete_message": true, + "answer_callback": true, + "send_media": true, + "download_file": true, +} + +// NewTelegramBotFilter is the registered method to instantiate a TelegramBot filter. +func NewTelegramBotFilter(p map[string]string) (Filter, error) { + f := &TelegramBot{ + params: p, + action: "send_message", + } + f.cbFilter = f.DoFilter + + if v, ok := f.params["action"]; ok { + f.action = v + } + if !validTelegramBotActions[f.action] { + return nil, fmt.Errorf("telegrambot: action '%s' is not valid", f.action) + } + + if v, ok := f.params["show_alert"]; ok && v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + return nil, fmt.Errorf("telegrambot: invalid show_alert value %q: %w", v, err) + } + f.showAlert = b + } + + if err := f.compileTemplates(); err != nil { + return nil, err + } + if err := f.validateRequired(); err != nil { + return nil, err + } + return f, nil +} + +// compileTemplates parses every recognised string param as a text/template with sprig funcs. +func (f *TelegramBot) compileTemplates() error { + type entry struct { + key string + dst **template.Template + } + entries := []entry{ + {"to_chat", &f.toChat}, + {"text", &f.text}, + {"caption", &f.caption}, + {"parse_mode", &f.parseMode}, + {"message_id", &f.messageID}, + {"callback_id", &f.callbackID}, + {"file_id", &f.fileID}, + {"filename", &f.filename}, + {"media", &f.media}, + {"media_type", &f.mediaType}, + {"reply_markup", &f.replyMarkup}, + } + for _, e := range entries { + v, ok := f.params[e.key] + if !ok || v == "" { + continue + } + tpl, err := template.New("telegrambot:" + e.key).Funcs(sprig.FuncMap()).Parse(v) + if err != nil { + return fmt.Errorf("telegrambot: invalid template for %q: %w", e.key, err) + } + *e.dst = tpl + } + return nil +} + +// validateRequired asserts mandatory params are present per action. +func (f *TelegramBot) validateRequired() error { + switch f.action { + case "send_message": + if f.text == nil { + return fmt.Errorf("telegrambot: 'text' is mandatory for action 'send_message'") + } + case "edit_message": + if f.text == nil { + return fmt.Errorf("telegrambot: 'text' is mandatory for action 'edit_message'") + } + if f.messageID == nil { + return fmt.Errorf("telegrambot: 'message_id' is mandatory for action 'edit_message'") + } + case "delete_message": + if f.messageID == nil { + return fmt.Errorf("telegrambot: 'message_id' is mandatory for action 'delete_message'") + } + case "answer_callback": + if f.text == nil { + return fmt.Errorf("telegrambot: 'text' is mandatory for action 'answer_callback'") + } + case "send_media": + if f.media == nil { + return fmt.Errorf("telegrambot: 'media' is mandatory for action 'send_media'") + } + case "download_file": + if f.filename == nil { + return fmt.Errorf("telegrambot: 'filename' is mandatory for action 'download_file'") + } + } + return nil +} + +// resolveBot fetches the *bot.Bot exported by the telegrambot feeder. +func (f *TelegramBot) resolveBot(msg *data.Message) (*bot.Bot, bool) { + target := msg.GetTarget("_telegrambot_api") + if target == nil { + log.Error("[%s::%s] telegrambot filter: missing _telegrambot_api extra", f.Rule(), f.Name()) + return nil, false + } + b, ok := target.(*bot.Bot) + if !ok || b == nil { + log.Error("[%s::%s] telegrambot filter: _telegrambot_api has wrong type %T", f.Rule(), f.Name(), target) + return nil, false + } + return b, true +} + +// resolveChatID renders the to_chat template if set, otherwise falls back to the +// incoming chat_id extra. Returns false on any rendering / parsing error. +func (f *TelegramBot) resolveChatID(msg *data.Message) (int64, bool) { + if f.toChat != nil { + s, err := msg.ApplyPlaceholder(f.toChat) + if err != nil { + log.Error("[%s::%s] telegrambot: render to_chat: %s", f.Rule(), f.Name(), err) + return 0, false + } + s = strings.TrimSpace(s) + if s == "" { + log.Error("[%s::%s] telegrambot: to_chat rendered empty", f.Rule(), f.Name()) + return 0, false + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + log.Error("[%s::%s] telegrambot: parse to_chat: %s", f.Rule(), f.Name(), err) + return 0, false + } + return n, true + } + raw := msg.GetTarget("chat_id") + if raw == nil { + log.Error("[%s::%s] telegrambot: no chat_id available (set to_chat or rely on incoming chat_id)", f.Rule(), f.Name()) + return 0, false + } + s, ok := raw.(string) + if !ok { + log.Error("[%s::%s] telegrambot: incoming chat_id has wrong type %T", f.Rule(), f.Name(), raw) + return 0, false + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + log.Error("[%s::%s] telegrambot: parse incoming chat_id: %s", f.Rule(), f.Name(), err) + return 0, false + } + return n, true +} + +// mapParseMode maps user-facing strings to library constants. Defaults to plain +// (empty parse_mode) for empty or unknown values. +func mapParseMode(s string) models.ParseMode { + switch strings.ToLower(strings.TrimSpace(s)) { + case "": + return models.ParseMode("") + case "html": + return models.ParseModeHTML + case "markdown": + return models.ParseModeMarkdownV1 + case "markdownv2": + return models.ParseModeMarkdown + default: + return models.ParseMode("") + } +} + +// resolveParseMode returns the configured parse_mode (default HTML). +func (f *TelegramBot) resolveParseMode(msg *data.Message) (models.ParseMode, bool) { + if f.parseMode == nil { + return models.ParseModeHTML, true + } + s, err := msg.ApplyPlaceholder(f.parseMode) + if err != nil { + log.Error("[%s::%s] telegrambot: render parse_mode: %s", f.Rule(), f.Name(), err) + return models.ParseMode(""), false + } + return mapParseMode(s), true +} + +// resolveReplyMarkup parses the reply_markup template (if set) into models.ReplyMarkup. +// Returns (nil, true) when reply_markup is unset or renders to whitespace. +// +// JSON shape determines the variant: +// - {"inline_keyboard": [...]} → InlineKeyboardMarkup +// - {"keyboard": [...]} → ReplyKeyboardMarkup +// - {"remove_keyboard": true, ...} → ReplyKeyboardRemove +// - {"force_reply": true, ...} → ForceReply +func (f *TelegramBot) resolveReplyMarkup(msg *data.Message) (models.ReplyMarkup, bool) { + if f.replyMarkup == nil { + return nil, true + } + raw, err := msg.ApplyPlaceholder(f.replyMarkup) + if err != nil { + log.Error("[%s::%s] telegrambot: render reply_markup: %s", f.Rule(), f.Name(), err) + return nil, false + } + if strings.TrimSpace(raw) == "" { + return nil, true + } + + var probe map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &probe); err != nil { + log.Error("[%s::%s] telegrambot: invalid reply_markup JSON: %s", f.Rule(), f.Name(), err) + return nil, false + } + + var out models.ReplyMarkup + switch { + case len(probe["inline_keyboard"]) > 0: + var kb models.InlineKeyboardMarkup + err = json.Unmarshal([]byte(raw), &kb) + out = kb + case len(probe["keyboard"]) > 0: + var kb models.ReplyKeyboardMarkup + err = json.Unmarshal([]byte(raw), &kb) + out = kb + case len(probe["remove_keyboard"]) > 0: + var kb models.ReplyKeyboardRemove + err = json.Unmarshal([]byte(raw), &kb) + out = kb + case len(probe["force_reply"]) > 0: + var kb models.ForceReply + err = json.Unmarshal([]byte(raw), &kb) + out = kb + default: + log.Error("[%s::%s] telegrambot: reply_markup must contain one of inline_keyboard/keyboard/remove_keyboard/force_reply", f.Rule(), f.Name()) + return nil, false + } + if err != nil { + log.Error("[%s::%s] telegrambot: invalid reply_markup JSON: %s", f.Rule(), f.Name(), err) + return nil, false + } + return out, true +} + +// DoFilter dispatches by action. +func (f *TelegramBot) DoFilter(msg *data.Message) (bool, error) { + b, ok := f.resolveBot(msg) + if !ok { + return false, nil + } + + ctx := context.Background() + switch f.action { + case "send_message": + return f.doSendMessage(ctx, b, msg), nil + case "edit_message": + return f.doEditMessage(ctx, b, msg), nil + case "delete_message": + return f.doDeleteMessage(ctx, b, msg), nil + case "answer_callback": + return f.doAnswerCallback(ctx, b, msg), nil + case "send_media": + return f.doSendMedia(ctx, b, msg), nil + case "download_file": + return f.doDownloadFile(ctx, b, msg), nil + } + return false, nil +} + +// doSendMessage performs sendMessage and enriches the message with sent_* extras. +func (f *TelegramBot) doSendMessage(ctx context.Context, b *bot.Bot, msg *data.Message) bool { + chatID, ok := f.resolveChatID(msg) + if !ok { + return false + } + + text, err := msg.ApplyPlaceholder(f.text) + if err != nil { + log.Error("[%s::%s] telegrambot: render text: %s", f.Rule(), f.Name(), err) + return false + } + pm, ok := f.resolveParseMode(msg) + if !ok { + return false + } + rm, ok := f.resolveReplyMarkup(msg) + if !ok { + return false + } + + sent, err := b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: chatID, + Text: text, + ParseMode: pm, + ReplyMarkup: rm, + }) + if err != nil { + log.Error("[%s::%s] telegrambot: sendMessage failed: %s", f.Rule(), f.Name(), err) + return false + } + + msg.SetTarget("main", text) + msg.SetTarget("sent_message_id", strconv.Itoa(sent.ID)) + msg.SetTarget("sent_chat_id", strconv.FormatInt(sent.Chat.ID, 10)) + msg.SetTarget("sent_text", text) + msg.SetTarget("sent_date", strconv.Itoa(sent.Date)) + return true +} + +// doEditMessage performs editMessageText and enriches the message with edited_* extras. +func (f *TelegramBot) doEditMessage(ctx context.Context, b *bot.Bot, msg *data.Message) bool { + chatID, ok := f.resolveChatID(msg) + if !ok { + return false + } + + msgIDStr, err := msg.ApplyPlaceholder(f.messageID) + if err != nil { + log.Error("[%s::%s] telegrambot: render message_id: %s", f.Rule(), f.Name(), err) + return false + } + msgID, err := strconv.Atoi(strings.TrimSpace(msgIDStr)) + if err != nil { + log.Error("[%s::%s] telegrambot: parse message_id: %s", f.Rule(), f.Name(), err) + return false + } + + text, err := msg.ApplyPlaceholder(f.text) + if err != nil { + log.Error("[%s::%s] telegrambot: render text: %s", f.Rule(), f.Name(), err) + return false + } + pm, ok := f.resolveParseMode(msg) + if !ok { + return false + } + rm, ok := f.resolveReplyMarkup(msg) + if !ok { + return false + } + + if _, err := b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: text, + ParseMode: pm, + ReplyMarkup: rm, + }); err != nil { + log.Error("[%s::%s] telegrambot: editMessageText failed: %s", f.Rule(), f.Name(), err) + return false + } + + msg.SetTarget("edit_success", "true") + msg.SetTarget("edited_message_id", strconv.Itoa(msgID)) + msg.SetTarget("edited_chat_id", strconv.FormatInt(chatID, 10)) + return true +} + +func (f *TelegramBot) doDeleteMessage(ctx context.Context, b *bot.Bot, msg *data.Message) bool { + chatID, ok := f.resolveChatID(msg) + if !ok { return false } + + msgIDStr, err := msg.ApplyPlaceholder(f.messageID) + if err != nil { + log.Error("[%s::%s] telegrambot: render message_id: %s", f.Rule(), f.Name(), err) + return false + } + msgID, err := strconv.Atoi(strings.TrimSpace(msgIDStr)) + if err != nil { + log.Error("[%s::%s] telegrambot: parse message_id: %s", f.Rule(), f.Name(), err) + return false + } + + if _, err := b.DeleteMessage(ctx, &bot.DeleteMessageParams{ChatID: chatID, MessageID: msgID}); err != nil { + log.Error("[%s::%s] telegrambot: deleteMessage failed: %s", f.Rule(), f.Name(), err) + return false + } + msg.SetTarget("delete_success", "true") + return true +} + +func (f *TelegramBot) doAnswerCallback(ctx context.Context, b *bot.Bot, msg *data.Message) bool { + var cbID string + if f.callbackID != nil { + s, err := msg.ApplyPlaceholder(f.callbackID) + if err != nil { + log.Error("[%s::%s] telegrambot: render callback_id: %s", f.Rule(), f.Name(), err) + return false + } + cbID = strings.TrimSpace(s) + } else if raw := msg.GetTarget("callback_id"); raw != nil { + if s, ok := raw.(string); ok { + cbID = s + } + } + if cbID == "" { + log.Error("[%s::%s] telegrambot: no callback_id available (set callback_id or rely on incoming extra)", f.Rule(), f.Name()) + return false + } + + text, err := msg.ApplyPlaceholder(f.text) + if err != nil { + log.Error("[%s::%s] telegrambot: render text: %s", f.Rule(), f.Name(), err) + return false + } + + if _, err := b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cbID, + Text: text, + ShowAlert: f.showAlert, + }); err != nil { + log.Error("[%s::%s] telegrambot: answerCallbackQuery failed: %s", f.Rule(), f.Name(), err) + return false + } + msg.SetTarget("callback_answered", "true") + return true +} + +// resolveMediaSource decides whether the rendered media value is a URL, a local path, +// or a file_id, and returns the matching models.InputFile + an optional file handle to close. +func (f *TelegramBot) resolveMediaSource(rendered string) (models.InputFile, func(), bool) { + s := strings.TrimSpace(rendered) + if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") { + return &models.InputFileString{Data: s}, func() {}, true + } + if info, err := os.Stat(s); err == nil && !info.IsDir() { + fh, err := os.Open(s) + if err != nil { + log.Error("[%s::%s] telegrambot: open media file: %s", f.Rule(), f.Name(), err) + return nil, nil, false + } + return &models.InputFileUpload{Filename: filepath.Base(s), Data: fh}, func() { fh.Close() }, true + } + return &models.InputFileString{Data: s}, func() {}, true +} + +func (f *TelegramBot) doSendMedia(ctx context.Context, b *bot.Bot, msg *data.Message) bool { + chatID, ok := f.resolveChatID(msg) + if !ok { + return false + } + + mediaStr, err := msg.ApplyPlaceholder(f.media) + if err != nil { + log.Error("[%s::%s] telegrambot: render media: %s", f.Rule(), f.Name(), err) + return false + } + mediaStr = strings.TrimSpace(mediaStr) + if mediaStr == "" { + log.Error("[%s::%s] telegrambot: media rendered empty", f.Rule(), f.Name()) + return false + } + + mediaType := "document" + if f.mediaType != nil { + mt, err := msg.ApplyPlaceholder(f.mediaType) + if err != nil { + log.Error("[%s::%s] telegrambot: render media_type: %s", f.Rule(), f.Name(), err) + return false + } + mediaType = strings.ToLower(strings.TrimSpace(mt)) + } + + caption := "" + if f.caption != nil { + c, err := msg.ApplyPlaceholder(f.caption) + if err != nil { + log.Error("[%s::%s] telegrambot: render caption: %s", f.Rule(), f.Name(), err) + return false + } + caption = c + } + pm, ok := f.resolveParseMode(msg) + if !ok { + return false + } + rm, ok := f.resolveReplyMarkup(msg) + if !ok { + return false + } + + // Validate media_type up-front so unknown types don't open file handles. + switch mediaType { + case "photo", "document", "video", "audio", "voice", "animation", "sticker": + default: + log.Error("[%s::%s] telegrambot: unknown media_type %q", f.Rule(), f.Name(), mediaType) + return false + } + + src, closeFn, ok := f.resolveMediaSource(mediaStr) + if !ok { + return false + } + defer closeFn() + + var sent *models.Message + var apiErr error + switch mediaType { + case "photo": + sent, apiErr = b.SendPhoto(ctx, &bot.SendPhotoParams{ChatID: chatID, Photo: src, Caption: caption, ParseMode: pm, ReplyMarkup: rm}) + case "document": + sent, apiErr = b.SendDocument(ctx, &bot.SendDocumentParams{ChatID: chatID, Document: src, Caption: caption, ParseMode: pm, ReplyMarkup: rm}) + case "video": + sent, apiErr = b.SendVideo(ctx, &bot.SendVideoParams{ChatID: chatID, Video: src, Caption: caption, ParseMode: pm, ReplyMarkup: rm}) + case "audio": + sent, apiErr = b.SendAudio(ctx, &bot.SendAudioParams{ChatID: chatID, Audio: src, Caption: caption, ParseMode: pm, ReplyMarkup: rm}) + case "voice": + sent, apiErr = b.SendVoice(ctx, &bot.SendVoiceParams{ChatID: chatID, Voice: src, Caption: caption, ParseMode: pm, ReplyMarkup: rm}) + case "animation": + sent, apiErr = b.SendAnimation(ctx, &bot.SendAnimationParams{ChatID: chatID, Animation: src, Caption: caption, ParseMode: pm, ReplyMarkup: rm}) + case "sticker": + sent, apiErr = b.SendSticker(ctx, &bot.SendStickerParams{ChatID: chatID, Sticker: src, ReplyMarkup: rm}) + } + if apiErr != nil { + log.Error("[%s::%s] telegrambot: send %s failed: %s", f.Rule(), f.Name(), mediaType, apiErr) + return false + } + + msg.SetTarget("main", caption) + msg.SetTarget("sent_message_id", strconv.Itoa(sent.ID)) + msg.SetTarget("sent_chat_id", strconv.FormatInt(sent.Chat.ID, 10)) + msg.SetTarget("sent_text", caption) + msg.SetTarget("sent_date", strconv.Itoa(sent.Date)) + return true +} + +func (f *TelegramBot) doDownloadFile(ctx context.Context, b *bot.Bot, msg *data.Message) bool { + var fileID string + if f.fileID != nil { + s, err := msg.ApplyPlaceholder(f.fileID) + if err != nil { + log.Error("[%s::%s] telegrambot: render file_id: %s", f.Rule(), f.Name(), err) + return false + } + fileID = strings.TrimSpace(s) + } else if raw := msg.GetTarget("msg_file_id"); raw != nil { + if s, ok := raw.(string); ok { + fileID = s + } + } + if fileID == "" { + log.Error("[%s::%s] telegrambot: no file_id available (set file_id or rely on msg_file_id extra)", f.Rule(), f.Name()) + return false + } + + file, err := b.GetFile(ctx, &bot.GetFileParams{FileID: fileID}) + if err != nil { + log.Error("[%s::%s] telegrambot: getFile failed: %s", f.Rule(), f.Name(), err) + return false + } + + // Make file_path basename available for filename templates that want to reuse it. + msg.SetTarget("msg_filename", path.Base(file.FilePath)) + + out, err := msg.ApplyPlaceholder(f.filename) + if err != nil { + log.Error("[%s::%s] telegrambot: render filename: %s", f.Rule(), f.Name(), err) + return false + } + if dir := filepath.Dir(out); dir != "" { + if err := os.MkdirAll(dir, 0700); err != nil { + log.Error("[%s::%s] telegrambot: mkdir %s: %s", f.Rule(), f.Name(), dir, err) + return false + } + } + + link := b.FileDownloadLink(file) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil) + if err != nil { + log.Error("[%s::%s] telegrambot: build download request: %s", f.Rule(), f.Name(), err) + return false + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Error("[%s::%s] telegrambot: download GET: %s", f.Rule(), f.Name(), err) + return false + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Error("[%s::%s] telegrambot: download non-2xx: %s", f.Rule(), f.Name(), resp.Status) + return false + } + + fh, err := os.Create(out) + if err != nil { + log.Error("[%s::%s] telegrambot: create %s: %s", f.Rule(), f.Name(), out, err) + return false + } + if _, err := io.Copy(fh, resp.Body); err != nil { + fh.Close() + log.Error("[%s::%s] telegrambot: write %s: %s", f.Rule(), f.Name(), out, err) + return false + } + fh.Close() + + msg.SetTarget("downloaded_path", out) + return true +} + +// OnEvent is called when an event occurs. +func (f *TelegramBot) OnEvent(event *data.Event) {} + +func init() { + register("telegrambot", NewTelegramBotFilter) +} diff --git a/filters/telegrambot_test.go b/filters/telegrambot_test.go new file mode 100644 index 0000000..be4600d --- /dev/null +++ b/filters/telegrambot_test.go @@ -0,0 +1,915 @@ +package filters + +import ( + "bytes" + "encoding/json" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/Matrix86/driplane/data" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +type mockHandlerFn func(t *testing.T, w http.ResponseWriter, r *http.Request) + +type recordedRequest struct { + method string + body []byte + headers http.Header +} + +type mockBotAPI struct { + t *testing.T + handlers map[string]mockHandlerFn + rawHandlers map[string]mockHandlerFn + files map[string][]byte + requests []recordedRequest +} + +func newMockBotAPI(t *testing.T) *mockBotAPI { + return &mockBotAPI{t: t, handlers: map[string]mockHandlerFn{}, rawHandlers: map[string]mockHandlerFn{}, files: map[string][]byte{}} +} + +func (m *mockBotAPI) handle(method string, fn mockHandlerFn) { m.handlers[method] = fn } + +// handleRaw registers a handler that receives the ORIGINAL request body (no +// multipart-to-JSON conversion). Use it when a test needs to inspect +// multipart/form-data parts directly (e.g. local file uploads). +func (m *mockBotAPI) handleRaw(method string, fn mockHandlerFn) { m.rawHandlers[method] = fn } + +func (m *mockBotAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/file/") { + rest := strings.TrimPrefix(r.URL.Path, "/file/") + idx := strings.Index(rest, "/") + if idx < 0 { + http.NotFound(w, r) + return + } + relPath := rest[idx+1:] + if data, ok := m.files[relPath]; ok { + w.Write(data) + return + } + http.NotFound(w, r) + return + } + rest := strings.TrimPrefix(r.URL.Path, "/") + idx := strings.Index(rest, "/") + if idx < 0 { + http.NotFound(w, r) + return + } + methodName := rest[idx+1:] + bodyBytes, _ := io.ReadAll(r.Body) + r.Body.Close() + // Raw handlers see the original body untouched (multipart preserved). + if h, ok := m.rawHandlers[methodName]; ok { + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + m.requests = append(m.requests, recordedRequest{method: methodName, body: bodyBytes, headers: r.Header.Clone()}) + h(m.t, w, r) + return + } + // Convert multipart body to a JSON object so test handlers can decode it + // uniformly. The go-telegram/bot client posts as multipart/form-data with + // each field already JSON-encoded (strings have outer quotes stripped). + jsonBody := bodyBytes + ct := r.Header.Get("Content-Type") + if mt, params, err := mime.ParseMediaType(ct); err == nil && strings.HasPrefix(mt, "multipart/") { + if jb, err := multipartToJSON(bodyBytes, params["boundary"]); err == nil { + jsonBody = jb + } + } + r.Body = io.NopCloser(bytes.NewReader(jsonBody)) + m.requests = append(m.requests, recordedRequest{method: methodName, body: jsonBody, headers: r.Header.Clone()}) + if h, ok := m.handlers[methodName]; ok { + h(m.t, w, r) + return + } + http.Error(w, "no handler for "+methodName, http.StatusNotFound) +} + +// multipartToJSON parses multipart/form-data into a JSON object. +// Each field is decoded heuristically: numbers as numbers, valid JSON +// values (objects/arrays/booleans) as their parsed forms, everything else as +// a string. This matches how the go-telegram/bot library serialises params. +func multipartToJSON(body []byte, boundary string) ([]byte, error) { + mr := multipart.NewReader(bytes.NewReader(body), boundary) + out := map[string]interface{}{} + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + name := part.FormName() + raw, _ := io.ReadAll(part) + s := string(raw) + // Try integer. + if n, err := strconv.ParseInt(s, 10, 64); err == nil { + out[name] = float64(n) + continue + } + // Try float. + if f, err := strconv.ParseFloat(s, 64); err == nil { + out[name] = f + continue + } + // Try parsing as JSON value (object/array/bool/null). + trimmed := strings.TrimSpace(s) + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") || trimmed == "true" || trimmed == "false" || trimmed == "null" { + var v interface{} + if err := json.Unmarshal([]byte(trimmed), &v); err == nil { + out[name] = v + continue + } + } + out[name] = s + } + return json.Marshal(out) +} + +func newMockedBot(t *testing.T, mock *mockBotAPI) (*bot.Bot, *httptest.Server) { + srv := httptest.NewServer(mock) + t.Cleanup(srv.Close) + b, err := bot.New("test:token", bot.WithServerURL(srv.URL), bot.WithSkipGetMe()) + if err != nil { + t.Fatalf("bot.New: %v", err) + } + return b, srv +} + +func newTestMessage(b *bot.Bot, main string, extras map[string]interface{}) *data.Message { + if extras == nil { + extras = map[string]interface{}{} + } + msg := data.NewMessageWithExtra(main, extras) + msg.SetTarget("_telegrambot_api", b) + return msg +} + +func newTestFilter(t *testing.T, params map[string]string) *TelegramBot { + f, err := NewTelegramBotFilter(params) + if err != nil { + t.Fatalf("NewTelegramBotFilter: %v", err) + } + tg, ok := f.(*TelegramBot) + if !ok { + t.Fatalf("filter is not *TelegramBot: %T", f) + } + return tg +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload string) { + w.Header().Set("Content-Type", "application/json") + if _, err := io.WriteString(w, payload); err != nil { + t.Fatalf("write response: %v", err) + } +} + +func TestTelegramBotFilterRegistered(t *testing.T) { + if _, ok := filterFactories["telegrambotfilter"]; !ok { + t.Fatalf("telegrambot filter not registered (expected key 'telegrambotfilter' in filterFactories)") + } +} + +func TestTelegramBotFilterDefaultActionIsSendMessage(t *testing.T) { + f, err := NewTelegramBotFilter(map[string]string{"text": "hi", "to_chat": "1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tg, ok := f.(*TelegramBot) + if !ok { + t.Fatalf("expected *TelegramBot, got %T", f) + } + if tg.action != "send_message" { + t.Fatalf("default action: want send_message, got %q", tg.action) + } +} + +func TestTelegramBotFilterRejectsUnknownAction(t *testing.T) { + _, err := NewTelegramBotFilter(map[string]string{"action": "no_such_action"}) + if err == nil { + t.Fatalf("expected error for unknown action, got nil") + } +} + +func TestTelegramBotFilterRequiredParamsPerAction(t *testing.T) { + cases := []struct { + name string + params map[string]string + wantErr bool + }{ + {"send_message_missing_text", map[string]string{"action": "send_message"}, true}, + {"send_message_ok", map[string]string{"action": "send_message", "text": "hi"}, false}, + + {"edit_message_missing_text", map[string]string{"action": "edit_message", "message_id": "1"}, true}, + {"edit_message_missing_id", map[string]string{"action": "edit_message", "text": "hi"}, true}, + {"edit_message_ok", map[string]string{"action": "edit_message", "text": "hi", "message_id": "1"}, false}, + + {"delete_message_missing_id", map[string]string{"action": "delete_message"}, true}, + {"delete_message_ok", map[string]string{"action": "delete_message", "message_id": "1"}, false}, + + {"answer_callback_missing_text", map[string]string{"action": "answer_callback"}, true}, + {"answer_callback_ok", map[string]string{"action": "answer_callback", "text": "hi"}, false}, + + {"send_media_missing_media", map[string]string{"action": "send_media"}, true}, + {"send_media_ok", map[string]string{"action": "send_media", "media": "/tmp/x.png"}, false}, + + {"download_file_missing_filename", map[string]string{"action": "download_file"}, true}, + {"download_file_ok", map[string]string{"action": "download_file", "filename": "/tmp/x.bin"}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := NewTelegramBotFilter(c.params) + if c.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !c.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestTelegramBotFilterInvalidTemplate(t *testing.T) { + _, err := NewTelegramBotFilter(map[string]string{ + "action": "send_message", + "text": "{{ .broken", + }) + if err == nil { + t.Fatalf("expected error for malformed template, got nil") + } +} + +func TestTelegramBotResolveBotMissing(t *testing.T) { + f := newTestFilter(t, map[string]string{"action": "send_message", "text": "hi", "to_chat": "1"}) + msg := data.NewMessageWithExtra("x", map[string]interface{}{}) + b, ok := f.resolveBot(msg) + if ok { + t.Fatalf("expected ok=false when _telegrambot_api missing, got bot=%v", b) + } +} + +func TestTelegramBotResolveBotWrongType(t *testing.T) { + f := newTestFilter(t, map[string]string{"action": "send_message", "text": "hi", "to_chat": "1"}) + msg := data.NewMessageWithExtra("x", map[string]interface{}{}) + msg.SetTarget("_telegrambot_api", "not a bot") + _, ok := f.resolveBot(msg) + if ok { + t.Fatalf("expected ok=false for wrong-type bot extra") + } +} + +func TestTelegramBotResolveBotOK(t *testing.T) { + f := newTestFilter(t, map[string]string{"action": "send_message", "text": "hi", "to_chat": "1"}) + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + msg := newTestMessage(b, "x", nil) + got, ok := f.resolveBot(msg) + if !ok || got != b { + t.Fatalf("expected resolved bot, got ok=%v bot=%v", ok, got) + } +} + +func TestTelegramBotResolveChatID_Explicit(t *testing.T) { + f := newTestFilter(t, map[string]string{"action": "send_message", "text": "hi", "to_chat": "12345"}) + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + msg := newTestMessage(b, "x", nil) + chatID, ok := f.resolveChatID(msg) + if !ok || chatID != int64(12345) { + t.Fatalf("want 12345 ok=true, got %d ok=%v", chatID, ok) + } +} + +func TestTelegramBotResolveChatID_FromExtra(t *testing.T) { + f := newTestFilter(t, map[string]string{"action": "send_message", "text": "hi"}) + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + msg := newTestMessage(b, "x", map[string]interface{}{"chat_id": "77"}) + chatID, ok := f.resolveChatID(msg) + if !ok || chatID != 77 { + t.Fatalf("want 77, got %d ok=%v", chatID, ok) + } +} + +func TestTelegramBotResolveChatID_Missing(t *testing.T) { + f := newTestFilter(t, map[string]string{"action": "send_message", "text": "hi"}) + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + msg := newTestMessage(b, "x", nil) + _, ok := f.resolveChatID(msg) + if ok { + t.Fatalf("expected ok=false when no chat_id available") + } +} + +func TestTelegramBotResolveParseMode(t *testing.T) { + cases := map[string]models.ParseMode{ + "": models.ParseMode(""), + "html": models.ParseModeHTML, + "HTML": models.ParseModeHTML, + "markdown": models.ParseModeMarkdownV1, + "MarkdownV2": models.ParseModeMarkdown, + "garbage": models.ParseMode(""), // unknown → plain + } + for in, want := range cases { + got := mapParseMode(in) + if got != want { + t.Fatalf("mapParseMode(%q) = %q, want %q", in, got, want) + } + } +} + +func TestTelegramBotParseReplyMarkup_Empty(t *testing.T) { + f := newTestFilter(t, map[string]string{"action": "send_message", "text": "hi", "to_chat": "1"}) + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + msg := newTestMessage(b, "x", nil) + mk, ok := f.resolveReplyMarkup(msg) + if !ok { + t.Fatalf("expected ok=true for unset reply_markup") + } + if mk != nil { + t.Fatalf("expected nil markup when unset, got %T", mk) + } +} + +func TestTelegramBotParseReplyMarkup_JSON(t *testing.T) { + f := newTestFilter(t, map[string]string{ + "action": "send_message", + "text": "hi", + "to_chat": "1", + "reply_markup": `{"inline_keyboard":[[{"text":"OK","callback_data":"ok"}]]}`, + }) + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + msg := newTestMessage(b, "x", nil) + mk, ok := f.resolveReplyMarkup(msg) + if !ok { + t.Fatalf("expected ok=true for valid JSON") + } + kb, isKB := mk.(models.InlineKeyboardMarkup) + if !isKB { + t.Fatalf("expected InlineKeyboardMarkup, got %T", mk) + } + if len(kb.InlineKeyboard) != 1 || len(kb.InlineKeyboard[0]) != 1 { + t.Fatalf("unexpected keyboard shape: %#v", kb) + } + if kb.InlineKeyboard[0][0].Text != "OK" { + t.Fatalf("button text mismatch: %s", kb.InlineKeyboard[0][0].Text) + } +} + +func TestTelegramBotParseReplyMarkup_Invalid(t *testing.T) { + f := newTestFilter(t, map[string]string{ + "action": "send_message", + "text": "hi", + "to_chat": "1", + "reply_markup": `{not json`, + }) + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + msg := newTestMessage(b, "x", nil) + _, ok := f.resolveReplyMarkup(msg) + if ok { + t.Fatalf("expected ok=false for invalid JSON") + } +} + +func TestTelegramBotParseReplyMarkup_ReplyKeyboard(t *testing.T) { + f := newTestFilter(t, map[string]string{ + "action": "send_message", + "text": "hi", + "to_chat": "1", + "reply_markup": `{"keyboard":[[{"text":"Yes"},{"text":"No"}]],"resize_keyboard":true,"one_time_keyboard":true}`, + }) + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + msg := newTestMessage(b, "x", nil) + mk, ok := f.resolveReplyMarkup(msg) + if !ok { + t.Fatalf("expected ok=true for valid reply keyboard JSON") + } + kb, isKB := mk.(models.ReplyKeyboardMarkup) + if !isKB { + t.Fatalf("expected ReplyKeyboardMarkup, got %T", mk) + } + if len(kb.Keyboard) != 1 || len(kb.Keyboard[0]) != 2 { + t.Fatalf("unexpected keyboard shape: %#v", kb) + } + if kb.Keyboard[0][0].Text != "Yes" || kb.Keyboard[0][1].Text != "No" { + t.Fatalf("button text mismatch: %#v", kb.Keyboard) + } + if !kb.ResizeKeyboard || !kb.OneTimeKeyboard { + t.Fatalf("flags lost: resize=%v onetime=%v", kb.ResizeKeyboard, kb.OneTimeKeyboard) + } +} + +func TestTelegramBotParseReplyMarkup_RemoveKeyboard(t *testing.T) { + f := newTestFilter(t, map[string]string{ + "action": "send_message", + "text": "hi", + "to_chat": "1", + "reply_markup": `{"remove_keyboard":true}`, + }) + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + msg := newTestMessage(b, "x", nil) + mk, ok := f.resolveReplyMarkup(msg) + if !ok { + t.Fatalf("expected ok=true for valid remove_keyboard JSON") + } + rm, isRM := mk.(models.ReplyKeyboardRemove) + if !isRM { + t.Fatalf("expected ReplyKeyboardRemove, got %T", mk) + } + if !rm.RemoveKeyboard { + t.Fatalf("RemoveKeyboard should be true") + } +} + +func TestTelegramBotParseReplyMarkup_ForceReply(t *testing.T) { + f := newTestFilter(t, map[string]string{ + "action": "send_message", + "text": "hi", + "to_chat": "1", + "reply_markup": `{"force_reply":true,"input_field_placeholder":"type here"}`, + }) + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + msg := newTestMessage(b, "x", nil) + mk, ok := f.resolveReplyMarkup(msg) + if !ok { + t.Fatalf("expected ok=true for valid force_reply JSON") + } + fr, isFR := mk.(models.ForceReply) + if !isFR { + t.Fatalf("expected ForceReply, got %T", mk) + } + if !fr.ForceReply || fr.InputFieldPlaceholder != "type here" { + t.Fatalf("ForceReply fields wrong: %#v", fr) + } +} + +func TestTelegramBotParseReplyMarkup_UnknownShape(t *testing.T) { + f := newTestFilter(t, map[string]string{ + "action": "send_message", + "text": "hi", + "to_chat": "1", + "reply_markup": `{"foo":"bar"}`, + }) + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + msg := newTestMessage(b, "x", nil) + _, ok := f.resolveReplyMarkup(msg) + if ok { + t.Fatalf("expected ok=false for JSON with no recognised key") + } +} + +func TestTelegramBotSendMessage_Success(t *testing.T) { + mock := newMockBotAPI(t) + mock.handle("sendMessage", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + // Verify body + body, _ := io.ReadAll(r.Body) + var got map[string]interface{} + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("body json: %v", err) + } + if got["chat_id"].(float64) != 99 { + t.Fatalf("chat_id: %v", got["chat_id"]) + } + if got["text"] != "hello alice" { + t.Fatalf("text: %v", got["text"]) + } + if got["parse_mode"] != "HTML" { + t.Fatalf("parse_mode: %v", got["parse_mode"]) + } + writeJSON(t, w, `{"ok":true,"result":{"message_id":42,"chat":{"id":99,"type":"private"},"date":1234567890,"text":"hello alice"}}`) + }) + b, _ := newMockedBot(t, mock) + + f := newTestFilter(t, map[string]string{ + "action": "send_message", + "text": "hello {{ .user_username }}", + "to_chat": "99", + }) + msg := newTestMessage(b, "incoming", map[string]interface{}{"user_username": "alice"}) + ok, err := f.DoFilter(msg) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatalf("expected ok=true on success") + } + + if msg.GetMessage() != "hello alice" { + t.Fatalf("main: %s", msg.GetMessage()) + } + if msg.GetTarget("sent_message_id") != "42" { + t.Fatalf("sent_message_id: %v", msg.GetTarget("sent_message_id")) + } + if msg.GetTarget("sent_chat_id") != "99" { + t.Fatalf("sent_chat_id: %v", msg.GetTarget("sent_chat_id")) + } + if msg.GetTarget("sent_text") != "hello alice" { + t.Fatalf("sent_text: %v", msg.GetTarget("sent_text")) + } + if msg.GetTarget("sent_date") != "1234567890" { + t.Fatalf("sent_date: %v", msg.GetTarget("sent_date")) + } + // incoming extras preserved + if msg.GetTarget("user_username") != "alice" { + t.Fatalf("incoming extras lost") + } +} + +func TestTelegramBotSendMessage_FallbackChatID(t *testing.T) { + mock := newMockBotAPI(t) + mock.handle("sendMessage", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var got map[string]interface{} + json.Unmarshal(body, &got) + if got["chat_id"].(float64) != 7 { + t.Fatalf("expected chat_id=7, got %v", got["chat_id"]) + } + writeJSON(t, w, `{"ok":true,"result":{"message_id":1,"chat":{"id":7,"type":"private"},"date":1,"text":"hi"}}`) + }) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{"action": "send_message", "text": "hi"}) + msg := newTestMessage(b, "x", map[string]interface{}{"chat_id": "7"}) + ok, _ := f.DoFilter(msg) + if !ok { + t.Fatalf("want ok=true") + } +} + +func TestTelegramBotSendMessage_NoChatID(t *testing.T) { + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{"action": "send_message", "text": "hi"}) + msg := newTestMessage(b, "x", nil) + ok, err := f.DoFilter(msg) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatalf("want ok=false when no chat_id available") + } +} + +func TestTelegramBotSendMessage_APIError(t *testing.T) { + mock := newMockBotAPI(t) + mock.handle("sendMessage", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + writeJSON(t, w, `{"ok":false,"description":"chat not found","error_code":400}`) + }) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{"action": "send_message", "text": "hi", "to_chat": "1"}) + msg := newTestMessage(b, "x", nil) + ok, err := f.DoFilter(msg) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatalf("want ok=false on API error") + } +} + +func TestTelegramBotSendMessage_MissingBot(t *testing.T) { + f := newTestFilter(t, map[string]string{"action": "send_message", "text": "hi", "to_chat": "1"}) + msg := data.NewMessageWithExtra("x", map[string]interface{}{}) // no _telegrambot_api + ok, err := f.DoFilter(msg) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatalf("want ok=false") + } +} + +func TestTelegramBotSendMessage_WithReplyMarkup(t *testing.T) { + mock := newMockBotAPI(t) + mock.handle("sendMessage", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var got map[string]interface{} + json.Unmarshal(body, &got) + rm, ok := got["reply_markup"].(map[string]interface{}) + if !ok { + t.Fatalf("reply_markup missing or wrong shape: %v", got["reply_markup"]) + } + kb, ok := rm["inline_keyboard"].([]interface{}) + if !ok || len(kb) != 1 { + t.Fatalf("inline_keyboard malformed: %v", rm) + } + writeJSON(t, w, `{"ok":true,"result":{"message_id":1,"chat":{"id":1,"type":"private"},"date":1,"text":"hi"}}`) + }) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{ + "action": "send_message", + "text": "hi", + "to_chat": "1", + "reply_markup": `{"inline_keyboard":[[{"text":"yes","callback_data":"y"}]]}`, + }) + msg := newTestMessage(b, "x", nil) + ok, _ := f.DoFilter(msg) + if !ok { + t.Fatalf("want ok=true") + } +} + +func TestTelegramBotEditMessage_Success(t *testing.T) { + mock := newMockBotAPI(t) + mock.handle("editMessageText", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var got map[string]interface{} + json.Unmarshal(body, &got) + if got["chat_id"].(float64) != 5 { t.Fatalf("chat_id: %v", got["chat_id"]) } + if got["message_id"].(float64) != 42 { t.Fatalf("message_id: %v", got["message_id"]) } + if got["text"] != "edited" { t.Fatalf("text: %v", got["text"]) } + writeJSON(t, w, `{"ok":true,"result":{"message_id":42,"chat":{"id":5,"type":"private"},"date":1,"text":"edited"}}`) + }) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{ + "action": "edit_message", + "text": "edited", + "to_chat": "5", + "message_id": "42", + }) + msg := newTestMessage(b, "x", nil) + ok, _ := f.DoFilter(msg) + if !ok { t.Fatalf("want ok=true") } + if msg.GetTarget("edit_success") != "true" { t.Fatalf("edit_success: %v", msg.GetTarget("edit_success")) } + if msg.GetTarget("edited_message_id") != "42" { t.Fatalf("edited_message_id: %v", msg.GetTarget("edited_message_id")) } + if msg.GetTarget("edited_chat_id") != "5" { t.Fatalf("edited_chat_id: %v", msg.GetTarget("edited_chat_id")) } +} + +func TestTelegramBotEditMessage_BadMessageID(t *testing.T) { + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{ + "action": "edit_message", + "text": "x", + "to_chat": "1", + "message_id": "not-a-number", + }) + msg := newTestMessage(b, "x", nil) + ok, _ := f.DoFilter(msg) + if ok { t.Fatalf("want ok=false on unparseable message_id") } +} + +func TestTelegramBotEditMessage_APIError(t *testing.T) { + mock := newMockBotAPI(t) + mock.handle("editMessageText", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + writeJSON(t, w, `{"ok":false,"description":"message not found","error_code":400}`) + }) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{"action": "edit_message", "text": "x", "to_chat": "1", "message_id": "1"}) + msg := newTestMessage(b, "x", nil) + ok, _ := f.DoFilter(msg) + if ok { t.Fatalf("want ok=false on API error") } +} + +func TestTelegramBotDeleteMessage_Success(t *testing.T) { + mock := newMockBotAPI(t) + mock.handle("deleteMessage", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var got map[string]interface{} + json.Unmarshal(body, &got) + if got["chat_id"].(float64) != 5 || got["message_id"].(float64) != 9 { + t.Fatalf("unexpected body: %v", got) + } + writeJSON(t, w, `{"ok":true,"result":true}`) + }) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{ + "action": "delete_message", + "to_chat": "5", + "message_id": "9", + }) + msg := newTestMessage(b, "x", nil) + ok, _ := f.DoFilter(msg) + if !ok { t.Fatalf("want ok=true") } + if msg.GetTarget("delete_success") != "true" { t.Fatalf("delete_success: %v", msg.GetTarget("delete_success")) } +} + +func TestTelegramBotDeleteMessage_APIError(t *testing.T) { + mock := newMockBotAPI(t) + mock.handle("deleteMessage", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + writeJSON(t, w, `{"ok":false,"description":"message not found","error_code":400}`) + }) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{"action": "delete_message", "to_chat": "1", "message_id": "1"}) + msg := newTestMessage(b, "x", nil) + ok, _ := f.DoFilter(msg) + if ok { t.Fatalf("want ok=false") } +} + +func TestTelegramBotAnswerCallback_FromExtra(t *testing.T) { + mock := newMockBotAPI(t) + mock.handle("answerCallbackQuery", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var got map[string]interface{} + json.Unmarshal(body, &got) + if got["callback_query_id"] != "cbq-1" || got["text"] != "ack" { + t.Fatalf("unexpected body: %v", got) + } + writeJSON(t, w, `{"ok":true,"result":true}`) + }) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{"action": "answer_callback", "text": "ack"}) + msg := newTestMessage(b, "x", map[string]interface{}{"callback_id": "cbq-1"}) + ok, _ := f.DoFilter(msg) + if !ok { t.Fatalf("want ok=true") } + if msg.GetTarget("callback_answered") != "true" { t.Fatalf("callback_answered missing") } +} + +func TestTelegramBotAnswerCallback_ExplicitID(t *testing.T) { + mock := newMockBotAPI(t) + mock.handle("answerCallbackQuery", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var got map[string]interface{} + json.Unmarshal(body, &got) + if got["callback_query_id"] != "explicit-id" { t.Fatalf("got: %v", got) } + writeJSON(t, w, `{"ok":true,"result":true}`) + }) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{"action": "answer_callback", "text": "ok", "callback_id": "explicit-id"}) + msg := newTestMessage(b, "x", map[string]interface{}{"callback_id": "from-extra"}) + ok, _ := f.DoFilter(msg) + if !ok { t.Fatalf("want ok=true") } +} + +func TestTelegramBotAnswerCallback_NoID(t *testing.T) { + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{"action": "answer_callback", "text": "ok"}) + msg := newTestMessage(b, "x", nil) + ok, _ := f.DoFilter(msg) + if ok { t.Fatalf("want ok=false when no callback_id available") } +} + +func TestTelegramBotSendMedia_URL(t *testing.T) { + mock := newMockBotAPI(t) + mock.handle("sendDocument", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var got map[string]interface{} + json.Unmarshal(body, &got) + if got["document"] != "https://example.com/x.pdf" { t.Fatalf("document: %v", got["document"]) } + if got["chat_id"].(float64) != 1 { t.Fatalf("chat_id: %v", got["chat_id"]) } + writeJSON(t, w, `{"ok":true,"result":{"message_id":11,"chat":{"id":1,"type":"private"},"date":1}}`) + }) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{"action": "send_media", "media": "https://example.com/x.pdf", "to_chat": "1"}) + msg := newTestMessage(b, "x", nil) + ok, _ := f.DoFilter(msg) + if !ok { t.Fatalf("want ok=true") } +} + +func TestTelegramBotSendMedia_FileID(t *testing.T) { + mock := newMockBotAPI(t) + mock.handle("sendPhoto", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var got map[string]interface{} + json.Unmarshal(body, &got) + if got["photo"] != "abcde-fileid" { t.Fatalf("photo: %v", got["photo"]) } + writeJSON(t, w, `{"ok":true,"result":{"message_id":1,"chat":{"id":1,"type":"private"},"date":1}}`) + }) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{ + "action": "send_media", "media": "abcde-fileid", "media_type": "photo", "to_chat": "1", + }) + msg := newTestMessage(b, "x", nil) + ok, _ := f.DoFilter(msg) + if !ok { t.Fatalf("want ok=true") } +} + +func TestTelegramBotSendMedia_LocalUpload(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "hello.txt") + if err := os.WriteFile(path, []byte("hello-bytes"), 0600); err != nil { t.Fatalf("%v", err) } + + mock := newMockBotAPI(t) + mock.handleRaw("sendDocument", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + ct := r.Header.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(ct) + if err != nil { t.Fatalf("content-type: %v", err) } + if mediaType != "multipart/form-data" { t.Fatalf("expected multipart/form-data, got %s", mediaType) } + mr := multipart.NewReader(r.Body, params["boundary"]) + sawFile := false + for { + part, err := mr.NextPart() + if err != nil { break } + if part.FormName() == "document" { + buf, _ := io.ReadAll(part) + if string(buf) != "hello-bytes" { t.Fatalf("unexpected file content: %q", buf) } + if part.FileName() != "hello.txt" { t.Fatalf("filename: %s", part.FileName()) } + sawFile = true + } + } + if !sawFile { t.Fatalf("multipart did not contain document part") } + writeJSON(t, w, `{"ok":true,"result":{"message_id":1,"chat":{"id":1,"type":"private"},"date":1}}`) + }) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{"action": "send_media", "media": path, "to_chat": "1"}) + msg := newTestMessage(b, "x", nil) + ok, _ := f.DoFilter(msg) + if !ok { t.Fatalf("want ok=true") } +} + +func TestTelegramBotSendMedia_UnknownMediaType(t *testing.T) { + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{ + "action": "send_media", "media": "x", "media_type": "hologram", "to_chat": "1", + }) + msg := newTestMessage(b, "x", nil) + ok, _ := f.DoFilter(msg) + if ok { t.Fatalf("want ok=false for unknown media_type") } +} + +func TestTelegramBotDownloadFile_FromExtra(t *testing.T) { + tmpDir := t.TempDir() + out := filepath.Join(tmpDir, "downloads", "saved.bin") + + mock := newMockBotAPI(t) + mock.handle("getFile", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var got map[string]interface{} + json.Unmarshal(body, &got) + if got["file_id"] != "FID-1" { t.Fatalf("file_id: %v", got["file_id"]) } + writeJSON(t, w, `{"ok":true,"result":{"file_id":"FID-1","file_unique_id":"u","file_size":11,"file_path":"docs/hello.bin"}}`) + }) + mock.files["docs/hello.bin"] = []byte("hello-bytes") + b, _ := newMockedBot(t, mock) + + f := newTestFilter(t, map[string]string{"action": "download_file", "filename": out}) + msg := newTestMessage(b, "x", map[string]interface{}{"msg_file_id": "FID-1"}) + ok, _ := f.DoFilter(msg) + if !ok { t.Fatalf("want ok=true") } + + got, err := os.ReadFile(out) + if err != nil { t.Fatalf("read: %v", err) } + if string(got) != "hello-bytes" { t.Fatalf("file content: %q", got) } + if msg.GetTarget("downloaded_path") != out { t.Fatalf("downloaded_path: %v", msg.GetTarget("downloaded_path")) } +} + +func TestTelegramBotDownloadFile_ExplicitID(t *testing.T) { + tmpDir := t.TempDir() + out := filepath.Join(tmpDir, "out.bin") + + mock := newMockBotAPI(t) + mock.handle("getFile", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var got map[string]interface{} + json.Unmarshal(body, &got) + if got["file_id"] != "explicit" { t.Fatalf("file_id: %v", got["file_id"]) } + writeJSON(t, w, `{"ok":true,"result":{"file_id":"explicit","file_unique_id":"u","file_size":4,"file_path":"a/b.bin"}}`) + }) + mock.files["a/b.bin"] = []byte("DATA") + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{"action": "download_file", "filename": out, "file_id": "explicit"}) + msg := newTestMessage(b, "x", map[string]interface{}{"msg_file_id": "from-extra"}) + ok, _ := f.DoFilter(msg) + if !ok { t.Fatalf("want ok=true") } +} + +func TestTelegramBotDownloadFile_GetFileError(t *testing.T) { + tmpDir := t.TempDir() + out := filepath.Join(tmpDir, "out.bin") + mock := newMockBotAPI(t) + mock.handle("getFile", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + writeJSON(t, w, `{"ok":false,"description":"file not found","error_code":400}`) + }) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{"action": "download_file", "filename": out}) + msg := newTestMessage(b, "x", map[string]interface{}{"msg_file_id": "x"}) + ok, _ := f.DoFilter(msg) + if ok { t.Fatalf("want ok=false") } +} + +func TestTelegramBotDownloadFile_NoFileID(t *testing.T) { + tmpDir := t.TempDir() + out := filepath.Join(tmpDir, "out.bin") + mock := newMockBotAPI(t) + b, _ := newMockedBot(t, mock) + f := newTestFilter(t, map[string]string{"action": "download_file", "filename": out}) + msg := newTestMessage(b, "x", nil) + ok, _ := f.DoFilter(msg) + if ok { t.Fatalf("want ok=false when no file_id available") } +} diff --git a/go.mod b/go.mod index 0a7eb46..d91fc46 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 github.com/mmcdole/gofeed v1.3.0 + github.com/mozilla-ai/any-llm-go v0.8.0 github.com/robertkrimen/otto v0.5.1 github.com/slack-go/slack v0.19.0 github.com/stretchr/testify v1.11.1 @@ -58,8 +59,8 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect + github.com/go-telegram/bot v1.20.0 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/goccy/go-json v0.10.5 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect @@ -74,8 +75,6 @@ require ( github.com/minio/crc64nvme v1.1.1 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/mozilla-ai/any-llm-go v0.8.0 // indirect github.com/nlnwa/whatwg-url v0.6.2 // indirect github.com/ogen-go/ogen v1.20.1 // indirect github.com/ollama/ollama v0.15.4 // indirect @@ -109,12 +108,10 @@ require ( golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/sync v0.19.0 // indirect google.golang.org/genai v1.45.0 // indirect - nhooyr.io/websocket v1.8.17 // indirect rsc.io/qr v0.2.0 // indirect ) require ( - cloud.google.com/go/compute v1.33.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Masterminds/sprig/v3 v3.3.0 @@ -147,7 +144,6 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/minio-go/v7 v7.0.98 // indirect - github.com/minio/sha256-simd v1.0.1 // indirect github.com/mmcdole/goxpp v1.1.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -155,11 +151,9 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xuri/excelize/v2 v2.10.1 - go.opencensus.io v0.24.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect diff --git a/go.sum b/go.sum index 3f2a80c..630047e 100644 --- a/go.sum +++ b/go.sum @@ -1,31 +1,13 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.118.1 h1:b8RATMcrK9A4BH0rj8yQupPXp+aP+cJ0l6H7V9osV1E= cloud.google.com/go v0.118.1/go.mod h1:CFO4UPEPi8oV21xoezZCrd3d81K4fFkDTEJu4R8K+9M= -cloud.google.com/go/auth v0.14.1 h1:AwoJbzUdxA/whv1qj3TLKwh3XX5sikny2fc40wUl+h0= -cloud.google.com/go/auth v0.14.1/go.mod h1:4JHUxlGXisL0AW8kXPtUF6ztuOksyfUQNFjfsOCXkPM= cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= -cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= -cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= -cloud.google.com/go/compute v1.24.0/go.mod h1:kw1/T+h/+tK2LJK0wiPPx1intgdAM3j/g3hFDlscY40= -cloud.google.com/go/compute v1.33.0 h1:abGcwWokP7/bBpvRjUKlgchrZXYgRpwcKZIlNUHWf6Y= -cloud.google.com/go/compute v1.33.0/go.mod h1:Z8NErRhrWA3RmVWczlAPJjZcRTlqZB1pcpD0MaIc1ug= -cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= @@ -39,14 +21,8 @@ github.com/Matrix86/go-twitter/v2 v2.0.0-20230329204613-0cf6043057eb/go.mod h1:/ github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= -github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ= github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= -github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= -github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= -github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8= -github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/alecthomas/participle v0.7.1 h1:2bN7reTw//5f0cugJcTOnY/NYZcWQOaajW+BwZB5xWs= @@ -63,8 +39,6 @@ github.com/antchfx/jsonquery v1.3.6/go.mod h1:fGzSGJn9Y826Qd3pC8Wx45avuUwpkePsAC github.com/antchfx/xmlquery v1.5.0 h1:uAi+mO40ZWfyU6mlUBxRVvL6uBNZ6LMU4M3+mQIBV4c= github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZmkP1SuNc= github.com/antchfx/xpath v1.3.2/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= -github.com/antchfx/xpath v1.3.3 h1:tmuPQa1Uye0Ym1Zn65vxPgfltWb/Lxu2jeqIGteJSRs= -github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI= github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= @@ -83,28 +57,12 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= -github.com/cloudflare/circl v1.4.0 h1:BV7h5MgrktNzytKmWjpOtdYrf0lkkbF8YMlBGPhJQrY= -github.com/cloudflare/circl v1.4.0/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= -github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= -github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= -github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= -github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= -github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= -github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= -github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= -github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -120,8 +78,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elastic/go-elasticsearch/v7 v7.17.10 h1:TCQ8i4PmIJuBunvBS6bwT2ybzVFxxUhhltAs3Gyu1yo= github.com/elastic/go-elasticsearch/v7 v7.17.10/go.mod h1:OJ4wdbtDNk5g503kvlHLyErCgQwwzmDtaFC4XyOxXA4= -github.com/elazarl/goproxy v1.4.0 h1:4GyuSbFa+s26+3rmYNSuUVsx+HgPrV1bk1jXI0l9wjM= -github.com/elazarl/goproxy v1.4.0/go.mod h1:X/5W/t+gzDyLfHW4DrMdpjqYjpXsURlBt9lpBDxZZZQ= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= @@ -133,24 +91,16 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTe github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/evilsocket/islazy v1.11.0 h1:B5w6uuS6ki6iDG+aH/RFeoMb8ijQh/pGabewqp2UeJ0= github.com/evilsocket/islazy v1.11.0/go.mod h1:muYH4x5MB5YRdkxnrOtrXLIBX6LySj1uFIqys94LKdo= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= @@ -159,8 +109,6 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= -github.com/go-faster/jx v1.1.0 h1:ZsW3wD+snOdmTDy9eIVgQdjUpXRRV4rqW8NS3t+20bg= -github.com/go-faster/jx v1.1.0/go.mod h1:vKDNikrKoyUmpzaJ0OkIkRQClNHFX/nF3dnTJZb3skg= github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= @@ -170,103 +118,61 @@ github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.13.2 h1:7O7xvsK7K+rZPKW6AQR1YyNhfywkv7B8/FsP3ki6Zv0= -github.com/go-git/go-git/v5 v5.13.2/go.mod h1:hWdW5P4YZRjmpGHwRH2v3zkWcNl6HeXaXQEMGb3NJ9A= github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= -github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= -github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc= +github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI= -github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA= github.com/gocolly/colly/v2 v2.3.0 h1:HSFh0ckbgVd2CSGRE+Y/iA4goUhGROJwyQDCMXGFBWM= github.com/gocolly/colly/v2 v2.3.0/go.mod h1:Qp54s/kQbwCQvFVx8KzKCSTXVJ1wWT4QeAKEu33x1q8= -github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= -github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= -github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= -github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= -github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/enterprise-certificate-proxy v0.3.13 h1:hSPAhW3NX+7HNlTsmrvU0jL75cIzxFktheceg95Nq14= github.com/googleapis/enterprise-certificate-proxy v0.3.13/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.12.1 h1:9F8GV9r9ztXyAi00gsMQHNoF51xPZm8uj1dpYt2ZETM= -github.com/googleapis/gax-go/v2 v2.12.1/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc= -github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= -github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk= github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0= github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= -github.com/gotd/td v0.120.0 h1:XeiafJM82/9SaB+ZMjMm/dnUx5+avINwVZOEsnV0zMo= -github.com/gotd/td v0.120.0/go.mod h1:BCc2jFj1l5zP9Trk4J7nxeqW0KBGl6K95eXMgszkbOI= github.com/gotd/td v0.140.0 h1:trNBzTnhNtNwHsFp5qwKnNxQRAZJ6/BRE+uH3Lojauk= github.com/gotd/td v0.140.0/go.mod h1:0ZkRxG7N+5ooG7/zdRXcnGautGPM6IKmyPQvdsAeF20= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= @@ -280,19 +186,11 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= -github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= -github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= -github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= -github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= @@ -304,8 +202,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06 h1:kacRlPN7EN++tVpGUorNGPn/4DnB7/DfTY82AOn6ccU= -github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8= github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZycmTpoUtQK3PD60UYBwjaCUHUP7cML494ao9/O8+Q= @@ -316,24 +212,16 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY= -github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.0.86 h1:DcgQ0AUjLJzRH6y/HrxiZ8CXarA70PAIufXHodP4s+k= -github.com/minio/minio-go/v7 v7.0.86/go.mod h1:VbfO4hYwUu3Of9WqGLBZ8vl3Hxnxo4ngxK4hzQDf4x4= github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= -github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= -github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/mmcdole/gofeed v1.2.1 h1:tPbFN+mfOLcM1kDF1x2c/N68ChbdBatkppdzf/vDe1s= -github.com/mmcdole/gofeed v1.2.1/go.mod h1:2wVInNpgmC85q16QTTuwbuKxtKkHLCDDtf0dCmnrNr4= github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8= @@ -343,76 +231,47 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/mozilla-ai/any-llm-go v0.8.0 h1:QNM2yeMaFp3TnIX7+pJ1oxakfA2bbQtyH7pchQfSe+E= github.com/mozilla-ai/any-llm-go v0.8.0/go.mod h1:hfidShiFrygKCzyMTMJWAUv6S5q7ZP/1qWK3Azc6RLU= github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q= github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk= -github.com/ogen-go/ogen v1.10.0 h1:x3ukRtq/pdn/k8+pYBtqWceVASiSmgK9M5lrH89Q+04= -github.com/ogen-go/ogen v1.10.0/go.mod h1:WExXrswerPzGWD0NpzBFsz+5eQIbP7HAtZUmpV8dqqI= github.com/ogen-go/ogen v1.20.1 h1:AFpIeI2rS37TNIMRQTHhAkThICQpa1p+Pceu7HP7xsA= github.com/ogen-go/ogen v1.20.1/go.mod h1:eXQeqzIfw9qUjXdpqNtkX+XCvhlWNymqU1bm7S7y8iU= github.com/ollama/ollama v0.15.4 h1:y841GH5lsi5j5BTFyX/E+UOC3Yiw+JBfdjBVRGw+I0M= github.com/ollama/ollama v0.15.4/go.mod h1:4Yn3jw2hZ4VqyJ1XciYawDRE8bzv4RT3JiVZR1kCfwE= -github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= -github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= -github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= -github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= -github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= -github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= -github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= -github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= -github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= -github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= -github.com/robertkrimen/otto v0.3.0 h1:5RI+8860NSxvXywDY9ddF5HcPw0puRsd8EgbXV0oqRE= -github.com/robertkrimen/otto v0.3.0/go.mod h1:uW9yN1CYflmUQYvAMS0m+ZiNo3dMzRUDQJX0jWbzgxw= github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0= github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= -github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= -github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= -github.com/slack-go/slack v0.12.4 h1:4iLT2opw+/QptmQxBNA7S8pNfSIvtn0NDGu7Jq0emi4= -github.com/slack-go/slack v0.12.4/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= -github.com/slack-go/slack v0.16.0 h1:khp/WCFv+Hb/B/AJaAwvcxKun0hM6grN0bUZ8xG60P8= -github.com/slack-go/slack v0.16.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= github.com/slack-go/slack v0.19.0 h1:J8lL/nGTsIUX53HU8YxZeI3PDkA+sxZsFrI2Dew7h44= github.com/slack-go/slack v0.19.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= @@ -423,13 +282,9 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= @@ -452,51 +307,25 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -github.com/xuri/efp v0.0.0-20241211021726-c4e992084aa6 h1:8m6DWBG+dlFNbx5ynvrE7NgI+Y7OlZVMVTpayoW+rCc= -github.com/xuri/efp v0.0.0-20241211021726-c4e992084aa6/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/excelize/v2 v2.8.0 h1:Vd4Qy809fupgp1v7X+nCS/MioeQmYVVzi495UCTqB7U= -github.com/xuri/excelize/v2 v2.8.0/go.mod h1:6iA2edBTKxKbZAa7X5bDhcCg51xdOn1Ar5sfoXRGrQg= -github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE= -github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE= github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= -github.com/xuri/nfp v0.0.0-20230819163627-dc951e3ffe1a/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= -github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4= -github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= -github.com/xuri/nfp v0.0.0-20250111060730-82a408b9aa71 h1:hOh7aVDrvGJRxzXrQbDY8E+02oaI//5cHL+97oYpEPw= -github.com/xuri/nfp v0.0.0-20250111060730-82a408b9aa71/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0 h1:doUP+ExOpH3spVTLS0FcWGLnQrPct/hD/bCPbDRUEAU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0/go.mod h1:rdENBZMT2OE6Ne/KLwpiXudnAsbdrdBaqBvTN8M8BgA= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y= -go.opentelemetry.io/otel v1.23.1 h1:Za4UzOqJYS+MUczKI320AtqZHZb7EqxO00jAHE0jmQY= -go.opentelemetry.io/otel v1.23.1/go.mod h1:Td0134eafDLcTS4y+zQ26GE8u3dEuRBiBCTUIRHaikA= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel/metric v1.23.1 h1:PQJmqJ9u2QaJLBOELl1cxIdPcpbwzbkjfEyelTl2rlo= -go.opentelemetry.io/otel/metric v1.23.1/go.mod h1:mpG2QPlAfnK8yNhNJAxDZruU9Y1/HubbC+KyH8FaCWI= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/trace v1.23.1 h1:4LrmmEd8AU2rFvU1zegmvqW7+kWarxtNOPyeL6HmYY8= -go.opentelemetry.io/otel/trace v1.23.1/go.mod h1:4IpnpJFwr1mo/6HL8XIPJaE9y0+u1KcVmuW7dwFSVrI= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= +go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= +go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8= +go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -505,14 +334,11 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= @@ -520,38 +346,20 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20230116083435-1de6713980de h1:DBWn//IJw30uYCgERoxCg84hWtA97F4wMiKOIh00Uf0= -golang.org/x/exp v0.0.0-20230116083435-1de6713980de/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/exp v0.0.0-20250215185904-eff6e970281f h1:oFMYAjX0867ZD2jcNiLBrI9BdpmEkvPyi5YrBGXbamg= -golang.org/x/exp v0.0.0-20250215185904-eff6e970281f/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= -golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo= -golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= -golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -562,19 +370,10 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= -golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= -golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= -golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -583,15 +382,10 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -607,8 +401,6 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -621,8 +413,6 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -636,83 +426,38 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= -golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= -golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.221.0 h1:qzaJfLhDsbMeFee8zBRdt/Nc+xmOuafD/dbdgGfutOU= -google.golang.org/api v0.221.0/go.mod h1:7sOU2+TL4TxUTdbi0gWgAIg7tH5qBXxoyhtL+9x3biQ= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genai v1.45.0 h1:s80ZpS42XW0zu/ogiOtenCio17nJ7reEFJjoCftukpA= google.golang.org/genai v1.45.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20240205150955-31a09d347014 h1:g/4bk7P6TPMkAUbUhquq98xey1slwvuVJPosdBqYJlU= -google.golang.org/genproto v0.0.0-20240205150955-31a09d347014/go.mod h1:xEgQu1e4stdSSsxPDK8Azkrk/ECl5HvdPf6nbZrTS5M= -google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 h1:Pw6WnI9W/LIdRxqK7T6XGugGbHIRl5Q7q3BssH6xk4s= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= -google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014 h1:x9PwdEgd11LgK+orcck69WVRo7DezSO4VUMPI4xpc8A= -google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014/go.mod h1:rbHMSEDyoYX62nRVLOCc4Qt1HbsdytAYoVwgjiOhF3I= -google.golang.org/genproto/googleapis/api v0.0.0-20250124145028-65684f501c47 h1:5iw9XJTD4thFidQmFVvx0wi4g5yOHk76rNRUxz1ZG5g= -google.golang.org/genproto/googleapis/api v0.0.0-20250124145028-65684f501c47/go.mod h1:AfA77qWLcidQWywD0YgqfpJzf50w2VjzBml3TybHeJU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b h1:FQtJ1MxbXoIIrZHZ33M+w5+dAP9o86rgpjoKr/ZmT7k= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.61.1 h1:kLAiWrZs7YeDM6MumDe7m3y4aM6wacLzM1Y/wiLP9XY= -google.golang.org/grpc v1.61.1/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= -google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= -google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= @@ -737,12 +482,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= -pault.ag/go/debian v0.18.0 h1:nr0iiyOU5QlG1VPnhZLNhnCcHx58kukvBJp+dvaM6CQ= -pault.ag/go/debian v0.18.0/go.mod h1:JFl0XWRCv9hWBrB5MDDZjA5GSEs1X3zcFK/9kCNIUmE= pault.ag/go/debian v0.19.0 h1:RUxCjScMbnlqFH5I+qsmyjZH8fXXtQ05rlkMJop3tjo= pault.ag/go/debian v0.19.0/go.mod h1:1LMojDAazlJ7cA5Ne6H2ZHD4hh3o8NRiW+MpvQRji2o= pault.ag/go/topsort v0.1.1 h1:L0QnhUly6LmTv0e3DEzbN2q6/FGgAcQvaEw65S53Bg4= diff --git a/src_docs/content/doc/feeders/telegrambot.md b/src_docs/content/doc/feeders/telegrambot.md new file mode 100644 index 0000000..783ffcf --- /dev/null +++ b/src_docs/content/doc/feeders/telegrambot.md @@ -0,0 +1,99 @@ +--- +title: "TelegramBot" +date: 2026-04-23T00:00:00+00:00 +draft: false +--- + +## TelegramBot + +This feeder consumes updates from a bot registered with [@BotFather](https://t.me/BotFather) using the [Telegram Bot API](https://core.telegram.org/bots/api). +Unlike the `telegram` feeder (MTProto user client), `telegrambot` is suited for interactive bots that expose commands or inline keyboards. + +Based on [go-telegram/bot](https://github.com/go-telegram/bot). + +### Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| **token** | _STRING_ | empty | Bot token issued by @BotFather. Required. | +| **mode** | _STRING_ | `polling` | `polling` or `webhook`. | +| **addr** | _STRING_ | `:3000` | Listen address (webhook mode only). | +| **webhook_url** | _STRING_ | empty | Public URL registered with Telegram (required if `mode=webhook`). | +| **webhook_secret** | _STRING_ | empty | Telegram `secret_token` for incoming webhook verification. | +| **delete_webhook_on_stop** | _BOOL_ | `false` | Unregister the webhook from Telegram on shutdown. | +| **commands** | _STRING_ | empty | Comma-separated command list (e.g. `/start,/help`). Empty = auto-detect any `/xxx`. | +| **allowed_users** | _STRING_ | empty | Comma-separated user IDs or `@usernames`. Empty = all. Usernames matched case-insensitively. When set, the sender MUST be in the list — non-listed users are rejected regardless of chat origin. | +| **allowed_chats** | _STRING_ | empty | Comma-separated chat IDs. Empty = all. When `allowed_users` is also set, private DMs from listed users bypass this list (so listed users can always DM the bot). | +| **events** | _STRING_ | all | Comma-separated subset of `message,command,callback_query,edited_message,channel_post,edited_channel_post,chat_member,my_chat_member`. | +| **debug** | _BOOL_ | `false` | Enable library debug logging. | + +{{< notice info "Polling example" >}} +`tgRule => | ...` +{{< /notice >}} + +{{< notice info "Webhook example" >}} +`tgRule => | ...` +{{< /notice >}} + +### Output + +Every propagated message carries a `type` extra field identifying the update kind. + +Common fields (when applicable): + +| Name | Description | +| --- | --- | +| type | Event kind. | +| update_id | Telegram update ID. | +| user_id, user_username, user_firstname, user_lastname, user_language, user_isbot, user_ispremium | Sender info. | +| chat_id, chat_type, chat_title, chat_username | Chat info. | + +#### Event `message` / `command` + +| Name | Description | +| --- | --- | +| text | Message text (also the `main` field). | +| msg_id, msg_timestamp, msg_date, msg_time | Timing. | +| msg_edited | Always `false` for this event. | +| msg_caption | Caption, if present. | +| msg_reply_to_id | Replied-to message ID, if any. | +| msg_forward_from | Forwarded-from user ID (only when forward origin is a user). | +| msg_forward_from_chat | Forwarded-from chat/channel ID. | +| msg_hasmedia | `true` when media attached. | +| msg_mediatype | `photo`, `document`, `video`, `audio`, `voice`, `sticker`, `animation`. | +| msg_medianame, msg_mediaext, msg_mediasize | Media metadata. | +| msg_file_id, msg_file_unique_id | Bot API file identifiers. | +| command | Command (e.g. `/start`). Only on `type=command`. | +| command_args | Text after the command. Only on `type=command`. | + +#### Event `edited_message` / `edited_channel_post` + +Same fields as above with `msg_edited=true` and an additional `msg_edit_date` field. + +#### Event `channel_post` / `edited_channel_post` + +Message fields as above. `user_*` may be absent (channel posts can lack a sender). + +#### Event `callback_query` + +| Name | Description | +| --- | --- | +| callback_id | Callback query ID. | +| callback_data | Button payload (also the `main` field). | +| callback_chatinstance | Telegram chat instance identifier. | +| chat_id, chat_type, chat_title, chat_username | Chat the keyboard message lives in (also populated when the original message is no longer accessible). Absent only for `inline_message_id`-only callbacks. | +| msg_id | ID of the message that carried the inline keyboard (useful with `edit_message`). | + +#### Event `chat_member` / `my_chat_member` + +| Name | Description | +| --- | --- | +| member_old_status | Previous member status. | +| member_new_status | New member status. | +| member_user_id, member_user_username | Affected user. | + +### Examples + +{{< notice info "React to /start command from authorized users only" >}} +`tgRule => | text(target="command", pattern="/start") | log(msg="start received from {{ .user_username }}")` +{{< /notice >}} diff --git a/src_docs/content/doc/filters/telegrambot.md b/src_docs/content/doc/filters/telegrambot.md new file mode 100644 index 0000000..2e3c3da --- /dev/null +++ b/src_docs/content/doc/filters/telegrambot.md @@ -0,0 +1,93 @@ +--- +title: "TelegramBot" +date: 2026-04-28T00:00:00+00:00 +draft: false +--- + +## TelegramBot + +This filter performs Bot API actions (send/edit/delete a message, answer callback queries, send media, download attachments) on top of the [`telegrambot`](../../feeders/telegrambot/) feeder. It uses the `*bot.Bot` instance the feeder publishes via the `_telegrambot_api` extra, so a single bot connection serves both directions of traffic. + +Note: it can be used only in a rule fed by the `telegrambot` feeder. + +Based on [go-telegram/bot](https://github.com/go-telegram/bot). + +### Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| **action** | _STRING_ | `send_message` | One of `send_message`, `edit_message`, `delete_message`, `answer_callback`, `send_media`, `download_file`. | +| **to_chat** | _STRING_ | empty | Target chat ID. Optional: when omitted, falls back to the incoming `chat_id` extra (reply-to-sender). Templated. | +| **text** | _STRING_ | empty | Message text or callback notification text. Templated. Required for `send_message`, `edit_message`, `answer_callback`. | +| **caption** | _STRING_ | empty | Caption for `send_media`. Templated. | +| **parse_mode** | _STRING_ | `HTML` | One of `HTML`, `Markdown`, `MarkdownV2`, or empty (plain). Templated. | +| **message_id** | _STRING_ | empty | Required for `edit_message` and `delete_message`. Templated. | +| **callback_id** | _STRING_ | empty | `answer_callback`: optional explicit callback query ID. Falls back to incoming `callback_id` extra. Templated. | +| **show_alert** | _BOOL_ | `false` | `answer_callback`: when `true`, Telegram shows the answer as a modal alert dialog instead of a top-screen toast. Useful when the toast is too brief to notice. | +| **file_id** | _STRING_ | empty | `download_file`: optional explicit file ID. Falls back to incoming `msg_file_id` extra. Templated. | +| **filename** | _STRING_ | empty | `download_file`: output path on disk. Required. Templated. The basename of the Telegram-side `file_path` is exposed as `{{ .msg_filename }}` so it can be reused in the path. | +| **media** | _STRING_ | empty | `send_media`: source. URL (`http(s)://...`) → Telegram fetches it; existing local path → uploaded; otherwise treated as a `file_id` to re-send. Templated. Required. | +| **media_type** | _STRING_ | `document` | `send_media`: one of `photo`, `document`, `video`, `audio`, `voice`, `animation`, `sticker`. Templated. | +| **reply_markup** | _STRING_ | empty | Raw Bot API JSON. Variant auto-detected by top-level key: `inline_keyboard` → InlineKeyboardMarkup, `keyboard` → ReplyKeyboardMarkup, `remove_keyboard` → ReplyKeyboardRemove, `force_reply` → ForceReply. Templated, then parsed. | + +All string parameters support [templates](../../rules/templates/). + +### Required parameters per action + +| Action | Required | +|---|---| +| `send_message` | `text` | +| `edit_message` | `text`, `message_id` | +| `delete_message` | `message_id` | +| `answer_callback` | `text` | +| `send_media` | `media` | +| `download_file` | `filename` | + +### Output + +On success the filter mutates the incoming message and propagates it. Original extras are preserved. + +| Action | Extras added | +|---|---| +| `send_message` | `main` overwritten with rendered text; `sent_message_id`, `sent_chat_id`, `sent_text`, `sent_date`. | +| `edit_message` | `edit_success="true"`, `edited_message_id`, `edited_chat_id`. | +| `delete_message` | `delete_success="true"`. | +| `answer_callback` | `callback_answered="true"`. | +| `send_media` | `main` overwritten with rendered caption; `sent_message_id`, `sent_chat_id`, `sent_text` (caption), `sent_date`. | +| `download_file` | `downloaded_path` (rendered filename), `msg_filename` (basename of Telegram `file_path`). | + +On any Bot API error, missing required extra, or template/JSON failure the filter logs the error and returns `false`, stopping rule propagation. The pipeline keeps running. + +### Examples + +{{< notice info "Echo `/start` command" >}} +`tgRule => | text(target="command", pattern="/start") | telegrambot(text="welcome {{ .user_username }}!")` +{{< /notice >}} + +{{< notice info "Reply with inline keyboard" >}} +`telegrambot(text="Pick one", reply_markup="{\"inline_keyboard\":[[{\"text\":\"yes\",\"callback_data\":\"y\"},{\"text\":\"no\",\"callback_data\":\"n\"}]]}")` +{{< /notice >}} + +{{< notice info "Reply keyboard (clickable buttons under input field)" >}} +`telegrambot(text="Pick one", reply_markup="{\"keyboard\":[[{\"text\":\"Yes\"},{\"text\":\"No\"}]],\"resize_keyboard\":true,\"one_time_keyboard\":true}")` +{{< /notice >}} + +{{< notice info "Remove a previously-sent reply keyboard" >}} +`telegrambot(text="thanks", reply_markup="{\"remove_keyboard\":true}")` +{{< /notice >}} + +{{< notice info "Answer a callback query" >}} +`text(target="type", pattern="callback_query") | telegrambot(action="answer_callback", text="got it")` +{{< /notice >}} + +{{< notice info "Send a photo from a URL" >}} +`telegrambot(action="send_media", media_type="photo", media="https://example.com/img.jpg", caption="hi")` +{{< /notice >}} + +{{< notice info "Edit a previously-sent message" >}} +`telegrambot(text="Working...") | telegrambot(action="edit_message", text="Done!", message_id="{{ .sent_message_id }}", to_chat="{{ .sent_chat_id }}")` +{{< /notice >}} + +{{< notice info "Download an attachment" >}} +`text(target="msg_hasmedia", pattern="true") | telegrambot(action="download_file", filename="/data/{{ .chat_id }}/{{ .msg_filename }}")` +{{< /notice >}}