// (c) Jisin0 package plugins import ( "context" "fmt" "regexp" "strconv" "strings" "time" "github.com/Jisin0/Go-Filter-Bot/database" "github.com/Jisin0/Go-Filter-Bot/utils" "github.com/Jisin0/Go-Filter-Bot/utils/autodelete" "github.com/Jisin0/Go-Filter-Bot/utils/customfilters" "github.com/Jisin0/Go-Filter-Bot/utils/shortener" "github.com/PaulSonOfLars/gotgbot/v2" "github.com/PaulSonOfLars/gotgbot/v2/ext" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo/options" ) // Regex expressions for callbacks and filtering var ( buttonRegex *regexp.Regexp = regexp.MustCompile(`\[([^\[]+?)\]\((buttonurl|url|alert):(?:/{0,2})(.+?)\)`) parseRegex *regexp.Regexp = regexp.MustCompile(`^"([^"]+)"`) cbstopRegex *regexp.Regexp = regexp.MustCompile(`stopf\((.+)\)`) cbalertRegex *regexp.Regexp = regexp.MustCompile(`alert\((.+)\)`) cbfileRegex *regexp.Regexp = regexp.MustCompile(`file\((.+)\)`) usernameRegex *regexp.Regexp = regexp.MustCompile(`(?i)@[a-z0-9_]+`) urlRegex *regexp.Regexp = regexp.MustCompile(`(?i)https?://\S+|t\.me/\S+|www\.\S+`) ) // cleanCaption removes usernames, URLs/links, and cleans up extra spaces/symbols left behind func cleanCaption(raw string) string { if raw == "" { return "" } s := usernameRegex.ReplaceAllString(raw, "") s = urlRegex.ReplaceAllString(s, "") // Clean up orphaned separators like "& -", " -", " - ", etc. s = regexp.MustCompile(`[\s\-\&\|]+\s*-\s*`).ReplaceAllString(s, " - ") s = regexp.MustCompile(`^\s*[\-\&\|\:]+\s*`).ReplaceAllString(s, "") s = regexp.MustCompile(`\s{2,}`).ReplaceAllString(s, " ") return strings.TrimSpace(s) } var DB *database.Database = database.NewDatabase() const ( lenUniqueID = 15 // length of unique id string for filters globalNumber int64 = 101 // Number used as chat id for global filters. maxKeyLength = 20 // maximum length for a keyword of a filter filterSplitCount = 3 // number of subtrings into which input of /filter command should be split minButtonParseParams = 4 // Parse of a button should yield atleast this many values alertCacheDuration = 3000 // number of seconds an alert should be cache by the client ) const ( cbStopParamCount = 3 // number of parameters required for cbstop maxButttons = 5 // maximum number of buttons to scan for ) // ── Filter result cache ─────────────────────────────────────────────────────── // filterCacheEntry holds cached search results with a TTL expiry. type filterCacheEntry struct { results []*database.Filter expiry time.Time } const filterCacheTTL = 10 * time.Minute // searchWithCache wraps DB search calls with in-memory caching. func searchWithCache(chatID int64, fields []string, message string, multiFilter bool) []*database.Filter { var cacheKey string useNew := len(fields) <= 15 if useNew { cacheKey = database.FilterCacheKey(chatID, fields) } else { cacheKey = database.FilterCacheKey(chatID, []string{message}) } if cached, ok := database.GetCachedFilterResults(cacheKey); ok { return cached } var results []*database.Filter if useNew { results = DB.SearchMfilterNew(chatID, fields, multiFilter) } else { results = DB.SearchMfilterClassic(chatID, message) } database.SetCachedFilterResults(cacheKey, results) return results } // ── Manual filter handler ───────────────────────────────────────────────────── func sendSearchResults(results []*database.Filter, bot *gotgbot.Bot, update *gotgbot.Message, chatID int64, messageID int64) { if len(results) == 0 { return } s := database.GetBotSettings() // Just limit to 10 for now if len(results) > 10 { results = results[:10] } if s.SearchAsButton { var rows [][]gotgbot.InlineKeyboardButton for _, f := range results { btnText := strings.SplitN(f.Content, "\n", 2)[0] btnText = usernameRegex.ReplaceAllString(btnText, "") btnText = strings.TrimSpace(btnText) if btnText == "" { btnText = f.Text } if len(btnText) > 60 { btnText = btnText[:57] + "..." } rows = append(rows, []gotgbot.InlineKeyboardButton{{ Text: "📁 " + btnText, CallbackData: fmt.Sprintf("file(%s)", f.ID), }}) } text := fmt.Sprintf("Here are the results found (%d):\nClick the buttons below to get the file.", len(results)) m, err := update.Reply(bot, text, &gotgbot.SendMessageOpts{ ParseMode: gotgbot.ParseModeHTML, ReplyMarkup: gotgbot.InlineKeyboardMarkup{InlineKeyboard: rows}, ReplyParameters: &gotgbot.ReplyParameters{MessageId: messageID}, }) if err == nil && m != nil && s.AutoDelete > 0 { autodelete.InsertAutodel(autodelete.AutodelData{ChatID: chatID, MessageID: m.MessageId}, s.AutoDelete*60) } } else { botUser := bot.User.Username text := fmt.Sprintf("Here are the results found (%d):\n\n", len(results)) for i, f := range results { btnText := strings.SplitN(f.Content, "\n", 2)[0] btnText = usernameRegex.ReplaceAllString(btnText, "") btnText = strings.TrimSpace(btnText) if btnText == "" { btnText = f.Text } link := fmt.Sprintf("https://t.me/%s?start=file_%s", botUser, f.ID) text += fmt.Sprintf("%d. %s\n", i+1, link, btnText) } text += "\nClick the link above to get the file in PM." m, err := update.Reply(bot, text, &gotgbot.SendMessageOpts{ ParseMode: gotgbot.ParseModeHTML, ReplyParameters: &gotgbot.ReplyParameters{MessageId: messageID}, LinkPreviewOptions: &gotgbot.LinkPreviewOptions{IsDisabled: true}, }) if err == nil && m != nil && s.AutoDelete > 0 { autodelete.InsertAutodel(autodelete.AutodelData{ChatID: chatID, MessageID: m.MessageId}, s.AutoDelete*60) } } } // MFilter handles automatic replies for manual filters. func MFilter(bot *gotgbot.Bot, ctx *ext.Context) error { update := ctx.Message chatType := update.Chat.Type // Group/supergroup search is now handled by Filter() (VJ-style with "Searching..." indicator) // MFilter only handles PM search via connected group if chatType != gotgbot.ChatTypePrivate { return nil } chatID, ok := DB.GetConnection(update.Chat.Id) if !ok { return nil } message := update.Text if update.Caption != "" { message = update.Caption } fields := strings.Fields(message) s := database.GetBotSettings() results := searchWithCache(chatID, fields, message, s.MultiFilter) if len(results) > 0 { sendSearchResults(results, bot, update, chatID, update.MessageId) } return nil } // Function to handle filter and gfilter commands // //nolint:errcheck // too many func NewFilter(bot *gotgbot.Bot, ctx *ext.Context) error { var ( c int64 update = ctx.Message ) // I didnt wanna create a whole new function for gfilter so ... if strings.HasPrefix(update.Text, "/gfilter") { if !utils.IsAdmin(ctx.EffectiveUser.Id) { update.Reply(bot, "Thats an Admin-only Command :(", &gotgbot.SendMessageOpts{}) return nil } c = globalNumber } else { // Verifying and getting connections for private chats var v bool c, v = customfilters.Verify(bot, ctx) if !v { return nil } else if c == 0 { c = ctx.Message.Chat.Id } } args := strings.SplitN(update.Text, " ", filterSplitCount) if len(args) < 2 && (update.ReplyToMessage == nil && len(args) < filterSplitCount) { update.Reply( bot, "Not Enough Parameters :(\n\nExample:- /filter hi hello", &gotgbot.SendMessageOpts{ParseMode: gotgbot.ParseModeHTML}, ) return nil } parse := ParseQuotes(strings.SplitN(update.Text, " ", 2)[1]) key := strings.ToLower(parse[0]) if len(key) > maxKeyLength { update.Reply(bot, fmt.Sprintf("Sorry The Length of the Key Can't be More than %d Characters !\nInput Key: %s", maxKeyLength, key), &gotgbot.SendMessageOpts{ParseMode: gotgbot.ParseModeHTML}) } e := DB.Mcol.FindOne(context.TODO(), bson.D{{Key: "group_id", Value: c}, {Key: "text", Value: key}}) if e.Err() == nil { update.Reply( bot, fmt.Sprintf("It Looks Like Another Filter For %v Has Already Been Saved In This Chat, If You Want To Stop It First Use The Button Below", key), &gotgbot.SendMessageOpts{ParseMode: gotgbot.ParseModeHTML, ReplyMarkup: gotgbot.InlineKeyboardMarkup{InlineKeyboard: [][]gotgbot.InlineKeyboardButton{ {{Text: "Stop Filter", CallbackData: fmt.Sprintf("stopf(%v|%v|y)", key, "local")}}, }}}, ) return nil } var ( text = parse[1] button [][]map[string]string ) if update.ReplyToMessage != nil { text += update.ReplyToMessage.OriginalHTML() text += update.ReplyToMessage.OriginalCaptionHTML() if update.ReplyToMessage.ReplyMarkup != nil { // Buttons are converted to maps to save to the database button = buttonToMap(ctx.Message.ReplyToMessage.ReplyMarkup.InlineKeyboard) } } uniqueID := utils.RandString(lenUniqueID) text, button, alert := parseButtons(text, uniqueID, button) // Finding media if any var ( fileID string mediaType string ) if msg := update.ReplyToMessage; msg != nil { switch { case msg.Document != nil: fileID = msg.Document.FileId mediaType = "document" case msg.Video != nil: fileID = msg.Video.FileId mediaType = "video" case msg.Audio != nil: fileID = msg.Audio.FileId mediaType = "audio" case msg.Sticker != nil: fileID = msg.Sticker.FileId mediaType = "sticker" case msg.Animation != nil: fileID = msg.Animation.FileId mediaType = "animation" case msg.Photo != nil: fileID = msg.Photo[len(msg.Photo)-1].FileId mediaType = "photo" } } f := &database.Filter{ ID: uniqueID, ChatID: c, Text: key, Content: text, FileID: fileID, Markup: button, Alerts: alert, Length: len(key), MediaType: mediaType, } DB.SaveMfilter(f) _, err := update.Reply( bot, fmt.Sprintf("Successfully Saved A Manual Filter For %v !", key), &gotgbot.SendMessageOpts{ParseMode: gotgbot.ParseModeHTML}, ) if err != nil { fmt.Println(err) } return nil } func CbStop(bot *gotgbot.Bot, ctx *ext.Context) error { // Function to handle callbacks from confirm buttons when stopping a filter update := ctx.CallbackQuery c, v := customfilters.Verify(bot, ctx) if !v { return nil } else if c == 0 { c = update.Message.GetChat().Id } // Making sure the callback data is valid args := strings.Split(cbstopRegex.FindStringSubmatch(update.Data)[1], "|") if len(args) < cbStopParamCount { update.Answer(bot, &gotgbot.AnswerCallbackQueryOpts{Text: "Bad Button :(", ShowAlert: true}) return nil } var ( key = args[0] ftype = args[1] erase = args[2] ) if ftype == "local" { if erase == "y" { DB.DeleteMfilter(c, key) update.Message.EditText(bot, fmt.Sprintf("Manual Filter For %v Was Deleted Successfully !", key), &gotgbot.EditMessageTextOpts{ParseMode: gotgbot.ParseModeHTML}) } else if erase == "n" { update.Message.EditText(bot, fmt.Sprintf(`Are You Sure You Want To Permanently Delete The Manual Filter For %v ?\nClick The "Yes I'm Sure" Button To Confirm `, key), &gotgbot.EditMessageTextOpts{ReplyMarkup: gotgbot.InlineKeyboardMarkup{InlineKeyboard: [][]gotgbot.InlineKeyboardButton{{{Text: "Ignore", CallbackData: "close"}, {Text: "Yes I'm Sure", CallbackData: fmt.Sprintf("stopf(%v|local|y)", key)}}}}, ParseMode: gotgbot.ParseModeHTML}) } } else if ftype == "global" { DB.StopGfilter(c, key) update.Message.EditText(bot, fmt.Sprintf("Global Filter For %v Has Been Stopped Successfully !", key), &gotgbot.EditMessageTextOpts{ParseMode: gotgbot.ParseModeHTML}) } return nil } // Function to handle the stop command // //nolint:errcheck // too many func StopMfilter(bot *gotgbot.Bot, ctx *ext.Context) error { c, v := customfilters.Verify(bot, ctx) if !v { return nil } else if c == 0 { c = ctx.Message.Chat.Id } var ( update = ctx.Message split = strings.SplitN(update.Text, " ", 2) key string ) switch { case len(split) < 2 && update.Chat.Type == gotgbot.ChatTypePrivate: m := utils.Ask(bot, "Ok Now Send Me The Name OF The Filter You Would Like To Stop ...", ctx.EffectiveChat, ctx.EffectiveUser) if m == nil { return nil } key = m.Text case len(split) < 2: update.Reply(bot, "Whoops looks like you forgot to mention a filter to stop !", &gotgbot.SendMessageOpts{}) default: key = split[1] } // Checking if theres a local/global filter for the key _, k := DB.GetMfilter(c, key) _, ok := DB.GetMfilter(globalNumber, key) // If there isnt local or global if !k && !ok { update.Reply(bot, fmt.Sprintf("I Couldnt Find Any Filter For %v To Stop !", key), &gotgbot.SendMessageOpts{ParseMode: gotgbot.ParseModeHTML}) return nil } // Both local and global switch { case k && ok: markup := gotgbot.InlineKeyboardMarkup{InlineKeyboard: [][]gotgbot.InlineKeyboardButton{{{Text: "Local", CallbackData: fmt.Sprintf("stopf(%v|local|y)", key)}, {Text: "Global", CallbackData: fmt.Sprintf("stopf(%v|global|y)", key)}}}} _, err := update.Reply(bot, "Please Select If You Would Like To Stop The Manual Filter (which you saved) Or Global Filter (saved by owners) For "+key+"", &gotgbot.SendMessageOpts{ParseMode: gotgbot.ParseModeHTML, ReplyMarkup: markup}) if err != nil { fmt.Println(err) } case k: // Only local markup := gotgbot.InlineKeyboardMarkup{InlineKeyboard: [][]gotgbot.InlineKeyboardButton{{{Text: "CONFIRM", CallbackData: fmt.Sprintf("stopf(%v|local|y)", key)}}}} _, err := update.Reply(bot, "Please Press The Button Below To Confirm Deletion Of Manual Filter For "+key+"", &gotgbot.SendMessageOpts{ParseMode: gotgbot.ParseModeHTML, ReplyMarkup: markup}) if err != nil { fmt.Println(err) } case ok: // Only global markup := gotgbot.InlineKeyboardMarkup{InlineKeyboard: [][]gotgbot.InlineKeyboardButton{{{Text: "CONFIRM", CallbackData: fmt.Sprintf("stopf(%v|global|y)", key)}}}} _, err := update.Reply(bot, "Please Press The Button Below To Stop Global Filter For "+key+"", &gotgbot.SendMessageOpts{ParseMode: gotgbot.ParseModeHTML, ReplyMarkup: markup}) if err != nil { fmt.Println(err) } } return nil } func AllMfilters(bot *gotgbot.Bot, ctx *ext.Context) error { // Function to handle the /filters command update := ctx.Message c := update.Chat.Id if update.Chat.Type == gotgbot.ChatTypePrivate { if i, k := DB.GetConnection(update.From.Id); k { c = i } } text := DB.StringMfilter(c) _, err := update.Reply(bot, "Lɪsᴛ ᴏғ ғɪʟᴛᴇʀs ғᴏʀ ᴛʜɪs ᴄʜᴀᴛ :\n"+text, &gotgbot.SendMessageOpts{ParseMode: gotgbot.ParseModeHTML}) if err != nil { fmt.Printf("allmfilters: %v\n", err) } return nil } // Function to handle callbacks from alert button in saved filters // //nolint:errcheck // unnecessary func CbAlert(bot *gotgbot.Bot, ctx *ext.Context) error { update := ctx.CallbackQuery args := strings.Split(cbalertRegex.FindStringSubmatch(update.Data)[1], "|") if len(args) < 2 { update.Answer(bot, &gotgbot.AnswerCallbackQueryOpts{Text: "Bad Request !", ShowAlert: true}) } else { uniqueID := args[0] index, err := strconv.Atoi(args[1]) if err != nil { update.Answer(bot, &gotgbot.AnswerCallbackQueryOpts{Text: "Bad Request !", ShowAlert: true}) } else { text := DB.GetAlert(uniqueID, index) update.Answer(bot, &gotgbot.AnswerCallbackQueryOpts{Text: text, ShowAlert: true, CacheTime: alertCacheDuration}) } } return nil } // SendFileByID finds a filter by ID and sends it. func SendFileByID(fileID string, bot *gotgbot.Bot, update *gotgbot.Message) { res := DB.Mcol.FindOne(context.TODO(), bson.D{{Key: "_id", Value: fileID}}) if res.Err() != nil { fmt.Printf("SendFileByID: file %q not found in DB: %v\n", fileID, res.Err()) update.Reply(bot, "❌ File not found. It may have been deleted or the link is invalid.", &gotgbot.SendMessageOpts{ //nolint:errcheck ReplyParameters: &gotgbot.ReplyParameters{MessageId: update.MessageId, AllowSendingWithoutReply: true}, }) return } var f database.Filter if err := res.Decode(&f); err != nil { fmt.Printf("SendFileByID: decode error for %q: %v\n", fileID, err) return } if err := sendFilter(&f, bot, update.Chat.Id, update.MessageId, update.Chat.Type); err != nil { update.Reply(bot, "❌ Failed to send file. Please try again or contact admin.", &gotgbot.SendMessageOpts{ //nolint:errcheck ReplyParameters: &gotgbot.ReplyParameters{MessageId: update.MessageId, AllowSendingWithoutReply: true}, }) } } // CbSendFile handles inline button clicks to send a specific file. //nolint:errcheck func CbSendFile(bot *gotgbot.Bot, ctx *ext.Context) error { update := ctx.CallbackQuery if !CheckForceSub(bot, ctx) { update.Answer(bot, &gotgbot.AnswerCallbackQueryOpts{Text: "⚠️ Please join the required channel(s) first!", ShowAlert: true}) return nil } match := cbfileRegex.FindStringSubmatch(update.Data) if len(match) < 2 { update.Answer(bot, &gotgbot.AnswerCallbackQueryOpts{Text: "Invalid file button!", ShowAlert: true}) return nil } fileDocID := match[1] fmt.Printf("CbSendFile: lookup _id=%q\n", fileDocID) res := DB.Mcol.FindOne(context.TODO(), bson.D{{Key: "_id", Value: fileDocID}}) if res.Err() != nil { fmt.Printf("CbSendFile: file %q not found: %v\n", fileDocID, res.Err()) update.Answer(bot, &gotgbot.AnswerCallbackQueryOpts{Text: "❌ File not found. It may have been removed.", ShowAlert: true}) return nil } var f database.Filter if err := res.Decode(&f); err != nil { fmt.Printf("CbSendFile: decode error: %v\n", err) update.Answer(bot, &gotgbot.AnswerCallbackQueryOpts{Text: "❌ Error loading file.", ShowAlert: true}) return nil } chatID := update.Message.GetChat().Id msgID := update.Message.GetMessageId() chatType := update.Message.GetChat().Type if err := sendFilter(&f, bot, chatID, msgID, chatType); err != nil { update.Answer(bot, &gotgbot.AnswerCallbackQueryOpts{Text: "❌ Failed to send file: " + err.Error(), ShowAlert: true}) return nil } update.Answer(bot, &gotgbot.AnswerCallbackQueryOpts{Text: "✅ Sent! Check below or your PM."}) return nil } func buttonToMap(btn [][]gotgbot.InlineKeyboardButton) [][]map[string]string { // Convert a button into a map var ( totalButtons [][]map[string]string rowButtons []map[string]string ) for _, i := range btn { rowButtons = []map[string]string{} for _, j := range i { b := map[string]string{"Text": j.Text} switch { case j.CallbackData != "": b["CallbackData"] = j.CallbackData case j.Url != "": b["Url"] = j.Url default: continue } rowButtons = append(rowButtons, b) } totalButtons = append(totalButtons, rowButtons) } return totalButtons } func mapToButton(data [][]map[string]string) [][]gotgbot.InlineKeyboardButton { // Convert a map back into button var totalButtons [][]gotgbot.InlineKeyboardButton for _, i := range data { var rowButtons []gotgbot.InlineKeyboardButton for _, b := range i { text := b["Text"] if u, k := b["Url"]; k { rowButtons = append(rowButtons, gotgbot.InlineKeyboardButton{Text: text, Url: u}) } else if c, k := b["CallbackData"]; k { rowButtons = append(rowButtons, gotgbot.InlineKeyboardButton{Text: text, CallbackData: c}) } } totalButtons = append(totalButtons, rowButtons) } return totalButtons } func ParseQuotes(text string) []string { res := parseRegex.FindStringSubmatch(text) if len(res) > 0 { return []string{res[1], strings.Replace(text, "\""+res[1]+"\"", "", 1)} // very hacky workaround by adding quotes //TODO: update regex to include quotes } else { split := strings.SplitN(text, " ", 2) return []string{split[0], strings.Replace(text, split[0], "", 1)} } } func parseButtons(text, uniqueID string, totalButtons [][]map[string]string) (messageText string, buttons [][]map[string]string, alertText []string) { var ( returnText = text rowButtons []map[string]string alert []string ) for _, rows := range strings.Split(text, "\n") { for _, m := range buttonRegex.FindAllStringSubmatch(rows, maxButttons) { if len(m) < minButtonParseParams { continue } if m[2] == "url" || m[2] == "buttonurl" { rowButtons = append(rowButtons, map[string]string{"Text": m[1], "Url": m[3]}) } else if m[2] == "alert" || m[2] == "buttonalert" { alert = append(alert, m[3]) rowButtons = append(rowButtons, map[string]string{"Text": m[1], "CallbackData": fmt.Sprintf("alert(%v|%v)", uniqueID, len(alert)-1)}) } returnText = strings.Replace(returnText, m[0], "", 1) } if len(rowButtons) > 0 { totalButtons = append(totalButtons, rowButtons) } rowButtons = []map[string]string{} } return strings.Trim(returnText, " "), totalButtons, alert } // sendFilter sends the data from filter f to the chatID. // URL buttons are shortened via the configured shortener service (if any). // Returns an error if sending failed (so callers can show user feedback). func sendFilter(f *database.Filter, bot *gotgbot.Bot, chatID, messageID int64, chatType string) error { // Shorten URL buttons if a shortener is configured (read from dynamic settings) markup := f.Markup s := database.GetBotSettings() if s.ShortenerAPI != "" && s.ShortenerURL != "" { markup = shortener.ShortenAll(markup) } content := f.Content // If it's a media filter (indexed file), use the clean filename as caption if f.MediaType != "" { if f.FileName != "" { content = cleanCaption(f.FileName) } else if content != "" { content = cleanCaption(content) } } else { content = cleanCaption(content) } var ( buttons = mapToButton(markup) ikm = gotgbot.InlineKeyboardMarkup{InlineKeyboard: buttons} err error m *gotgbot.Message ) mediaType := f.MediaType if mediaType != "" { fileID := gotgbot.InputFileByID(f.FileID) switch mediaType { case "document": m, err = bot.SendDocument(chatID, fileID, &gotgbot.SendDocumentOpts{Caption: content, ReplyParameters: &gotgbot.ReplyParameters{MessageId: messageID, AllowSendingWithoutReply: true}, ReplyMarkup: ikm, ParseMode: gotgbot.ParseModeHTML}) case "sticker": m, err = bot.SendSticker(chatID, fileID, &gotgbot.SendStickerOpts{ReplyParameters: &gotgbot.ReplyParameters{MessageId: messageID, AllowSendingWithoutReply: true}, ReplyMarkup: ikm}) case "video": m, err = bot.SendVideo(chatID, fileID, &gotgbot.SendVideoOpts{Caption: content, ReplyParameters: &gotgbot.ReplyParameters{MessageId: messageID, AllowSendingWithoutReply: true}, ReplyMarkup: ikm, ParseMode: gotgbot.ParseModeHTML}) case "photo": m, err = bot.SendPhoto(chatID, fileID, &gotgbot.SendPhotoOpts{Caption: content, ReplyParameters: &gotgbot.ReplyParameters{MessageId: messageID, AllowSendingWithoutReply: true}, ReplyMarkup: ikm, ParseMode: gotgbot.ParseModeHTML}) case "audio": m, err = bot.SendAudio(chatID, fileID, &gotgbot.SendAudioOpts{Caption: content, ReplyParameters: &gotgbot.ReplyParameters{MessageId: messageID, AllowSendingWithoutReply: true}, ReplyMarkup: ikm, ParseMode: gotgbot.ParseModeHTML}) case "animation": m, err = bot.SendAnimation(chatID, fileID, &gotgbot.SendAnimationOpts{Caption: content, ReplyParameters: &gotgbot.ReplyParameters{MessageId: messageID, AllowSendingWithoutReply: true}, ReplyMarkup: ikm, ParseMode: gotgbot.ParseModeHTML}) default: fmt.Println("Unknown media type " + mediaType) } } else { m, err = bot.SendMessage(chatID, content, &gotgbot.SendMessageOpts{ReplyParameters: &gotgbot.ReplyParameters{MessageId: messageID, AllowSendingWithoutReply: true}, ReplyMarkup: ikm, ParseMode: gotgbot.ParseModeHTML}) } if err != nil { fmt.Printf("sendFilter: failed to send %q (type=%s, fileID=%s...): %v\n", f.FileName, f.MediaType, safePrefix(f.FileID, 20), err) return err } if m != nil && s.AutoDelete > 0 { // Only auto-delete in groups — PM file sends should not auto-delete if chatType != gotgbot.ChatTypePrivate { autodelete.InsertAutodel(autodelete.AutodelData{ChatID: chatID, MessageID: m.MessageId}, s.AutoDelete*60) //nolint:errcheck } } return nil } // safePrefix returns the first n chars of s, or s itself if shorter. func safePrefix(s string, n int) string { if len(s) <= n { return s } return s[:n] } // ══════════════════════════════════════════════════════════════════════════════ // VJ-FILTER-BOT style search — ported from Python to Go // Credit: Bot code made by Claude // ══════════════════════════════════════════════════════════════════════════════ // vjCleanQuery replicates VJ bot's query cleaning before search. // Removes noise words that hurt search accuracy. func vjCleanQuery(raw string) string { q := regexp.MustCompile(`(?i)\b(please|pls|plz|send|movie|new|latest|bro|bruh|broh|find|link|file|dubbed|full|helo|that|undo|kitti|thar|kittum|anyone)\b`).ReplaceAllString(strings.ToLower(raw), " ") q = regexp.MustCompile(`[-:.]`).ReplaceAllString(q, " ") q = regexp.MustCompile(`\s{2,}`).ReplaceAllString(q, " ") return strings.TrimSpace(q) } // buildVJRegex builds MongoDB regex exactly matching VJ-FILTER-BOT's get_search_results. // Single word: boundary match. Multi-word: words joined by wildcard separator. func buildVJRegex(query string) string { query = strings.TrimSpace(query) if query == "" { return "." } if !strings.Contains(query, " ") { return `(\b|[.\+\-_])` + regexp.QuoteMeta(query) + `(\b|[.\+\-_])` } parts := strings.Fields(query) escaped := make([]string, len(parts)) for i, p := range parts { escaped[i] = regexp.QuoteMeta(p) } return strings.Join(escaped, `.*[\s\.\+\-_]`) } // searchVJ searches file_name AND text fields using VJ-style regex. // This is the core of VJ's high hit ratio. func searchVJ(chatID int64, rawQuery string, limit int64) []*database.Filter { query := vjCleanQuery(rawQuery) if query == "" { return nil } pattern := buildVJRegex(query) filter := bson.D{ {Key: "group_id", Value: chatID}, {Key: "$or", Value: bson.A{ bson.D{{Key: "file_name", Value: bson.M{"$regex": pattern, "$options": "i"}}}, bson.D{{Key: "text", Value: bson.M{"$regex": pattern, "$options": "i"}}}, }}, {Key: "file", Value: bson.M{"$exists": true, "$ne": ""}}, } cur, err := DB.Mcol.Find( context.Background(), filter, options.Find(). SetSort(bson.D{{Key: "$natural", Value: -1}}). SetLimit(limit), ) if err != nil { return nil } defer cur.Close(context.Background()) var results []*database.Filter for cur.Next(context.Background()) { var f database.Filter if err := cur.Decode(&f); err == nil { results = append(results, &f) } } return results } // Filter is the main group message handler using VJ-style search. // Replaces the old MFilter+searchWithCache approach for group messages. // //nolint:errcheck func Filter(bot *gotgbot.Bot, ctx *ext.Context) error { msg := ctx.Message if msg == nil { return nil } query := strings.TrimSpace(msg.Text + msg.Caption) if strings.HasPrefix(query, "/") || strings.HasPrefix(query, "#") || len(query) < 2 || len(query) > 100 { return nil } chatID := msg.Chat.Id messageID := msg.MessageId s := database.GetBotSettings() // Force sub check if len(s.ForceSubChats) > 0 && !CheckForceSub(bot, ctx) { return nil } // "Searching For..." indicator — VJ style searching, _ := msg.Reply(bot, fmt.Sprintf("Searching For %s 🔍", query), &gotgbot.SendMessageOpts{ ParseMode: gotgbot.ParseModeHTML, ReplyParameters: &gotgbot.ReplyParameters{MessageId: messageID, AllowSendingWithoutReply: true}, }, ) t0 := time.Now() results := searchVJ(globalNumber, query, 100) // fetch all, paginate 10/page like VJ searchTime := time.Since(t0).Seconds() if len(results) == 0 { if searching != nil { googleQuery := strings.ReplaceAll(vjCleanQuery(query), " ", "+") + "+movie" searching.EditText(bot, fmt.Sprintf("❌ No results found for: %s\n\n💡 Check spelling and try again.", query), &gotgbot.EditMessageTextOpts{ ParseMode: gotgbot.ParseModeHTML, ReplyMarkup: gotgbot.InlineKeyboardMarkup{InlineKeyboard: [][]gotgbot.InlineKeyboardButton{ {{Text: "🔍 Search on Google", Url: "https://www.google.com/search?q=" + googleQuery}}, }}, }, ) } return nil } // Delete searching indicator and show paginated results if searching != nil { searching.Delete(bot, nil) } sendVJResults(results, bot, msg, query, chatID, messageID, searchTime) return nil } // CbPages handles the no-op page-counter button. // CbPages is defined in vjsearch.go // fileDisplayName returns VJ-style "[size] name" display string. func fileDisplayName(f *database.Filter) string { name := f.FileName if name == "" { name = strings.SplitN(f.Content, "\n", 2)[0] name = usernameRegex.ReplaceAllString(name, "") name = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(name, "") } if name == "" { name = f.Text } name = usernameRegex.ReplaceAllString(name, "") name = regexp.MustCompile(`(?i)https?://\S+|t\.me/\S+|www\.\S+`).ReplaceAllString(name, "") name = strings.TrimSpace(name) prefix := "" if f.FileSize > 0 { prefix = "[" + getFileSize(f.FileSize) + "] " } full := prefix + name if len(full) > 58 { full = full[:55] + "..." } return full } // getFileSize returns human-readable file size. func getFileSize(size int64) string { switch { case size >= 1024*1024*1024: return fmt.Sprintf("%.1f GB", float64(size)/(1024*1024*1024)) case size >= 1024*1024: return fmt.Sprintf("%.1f MB", float64(size)/(1024*1024)) case size >= 1024: return fmt.Sprintf("%.1f KB", float64(size)/1024) default: return fmt.Sprintf("%d B", size) } }