package main import ( "encoding/json" "flag" "fmt" "io" "log" "net/url" "os" "regexp" "strings" "sync" "github.com/bwmarrin/discordgo" ) var ( rules map[string]any serverSettings map[string]any settingsMutex sync.RWMutex ) func loadJSON(filename string) map[string]any { file, err := os.Open(filename) if err != nil { return map[string]any{} } defer file.Close() data, err := io.ReadAll(file) if err != nil { return map[string]any{} } var result map[string]any err = json.Unmarshal(data, &result) if err != nil { return map[string]any{} } return result } func saveJSON(data map[string]any, filename string) { jsonData, _ := json.MarshalIndent(data, "", " ") os.WriteFile(filename, jsonData, 0644) } func cleanURL(rawURL string) string { parsedURL, err := url.Parse(rawURL) if err != nil { return "" } rulesMap := rules["providers"].(map[string]any) providers := make([]map[string]any, 0, len(rulesMap)) for key, value := range rulesMap { if key != "globalRules" { providers = append(providers, map[string]any{key: value}) } } if globalRules, ok := rulesMap["globalRules"].(map[string]any); ok { providers = append(providers, map[string]any{"globalRules": globalRules}) } for _, provider := range providers { for _, data := range provider { dataMap := data.(map[string]any) pattern := dataMap["urlPattern"].(string) if matched, _ := regexp.MatchString(pattern, rawURL); matched { // Check exceptions if exceptions, ok := dataMap["exceptions"].([]any); ok { for _, exc := range exceptions { if excMatched, _ := regexp.MatchString(exc.(string), rawURL); excMatched { return "" // Skip cleaning for exceptions } } } // Proceed to filter params queryParams, _ := url.ParseQuery(parsedURL.RawQuery) rulesList := dataMap["rules"].([]any) // Include referralMarketing if present if referralMarketing, ok := dataMap["referralMarketing"].([]any); ok { rulesList = append(rulesList, referralMarketing...) } filtered := make(url.Values) changed := false for k, v := range queryParams { shouldFilter := false for _, rule := range rulesList { if matched, _ := regexp.MatchString(rule.(string), k); matched { shouldFilter = true break } } if !shouldFilter { filtered[k] = v } else { changed = true } } if changed { parsedURL.RawQuery = filtered.Encode() return parsedURL.String() } } } } return "" } func getServerDisabled(guildID string) bool { settingsMutex.RLock() defer settingsMutex.RUnlock() if guildSettings, ok := serverSettings[guildID].(map[string]any); ok { if d, ok := guildSettings["disabled"].(bool); ok { return d } } return false } func setServerDisabled(guildID string, disabled bool, filename string) { settingsMutex.Lock() defer settingsMutex.Unlock() if serverSettings[guildID] == nil { serverSettings[guildID] = map[string]any{} } serverSettings[guildID].(map[string]any)["disabled"] = disabled saveJSON(serverSettings, filename) } func findFirstCleanLink(words []string) string { for _, word := range words { if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") { if cleaned := cleanURL(word); cleaned != "" { return fmt.Sprintf("Cleaned link: <%s>", cleaned) } } } return "" } func onReady(s *discordgo.Session, event *discordgo.Ready) { log.Println("Bot is ready") commands := []*discordgo.ApplicationCommand{ {Name: "toggle", Description: "Toggle automatic link cleaning for this server (requires Manage Messages permission)."}, {Name: "bomb", Description: "Cleans the first link found in the last 20 messages."}, {Name: "about", Description: "Explains the purpose of the bot and its privacy features."}, } for _, cmd := range commands { _, err := s.ApplicationCommandCreate(s.State.User.ID, "", cmd) if err != nil { log.Printf("Cannot create '%s' command: %v", cmd.Name, err) } } } func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) { if m.Author.ID == s.State.User.ID || getServerDisabled(m.GuildID) { return } if msg := findFirstCleanLink(strings.Fields(m.Content)); msg != "" { s.ChannelMessageSend(m.ChannelID, msg) } } func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { if i.Type != discordgo.InteractionApplicationCommand { return } data := i.ApplicationCommandData() switch data.Name { case "toggle": if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageMessages) == 0 { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "You need 'Manage Messages' permission to use this command.", Flags: discordgo.MessageFlagsEphemeral}}) return } guildID := i.GuildID newDisabled := !getServerDisabled(guildID) setServerDisabled(guildID, newDisabled, settingsPath) status := "enabled" if newDisabled { status = "disabled" } s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("Link cleaning %s.", status)}}) case "bomb": messages, err := s.ChannelMessages(i.ChannelID, 20, "", "", "") msg := "No links found." if err == nil { for _, m := range messages { if link := findFirstCleanLink(strings.Fields(m.Content)); link != "" { msg = link break } } } else { msg = "Error retrieving messages." } s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: msg}}) case "about": s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "This bot removes tracking parameters from links to improve privacy. Use `/toggle` to enable/disable auto-cleaning, `/bomb` to clean a recent link manually, and `/about` for info."}}) } } var settingsPath string func main() { rulesPath := flag.String("rules", "url_rules.json", "path to url_rules.json file") settingsPathFlag := flag.String("settings", "server_settings.json", "path to server_settings.json file") token := flag.String("token", "", "Discord bot token") flag.Parse() settingsPath = *settingsPathFlag if *token == "" { log.Fatal("Bot token not provided. Use -token flag or set LINKBOMBER_BOT_TOKEN environment variable") } rules = loadJSON(*rulesPath) serverSettings = loadJSON(settingsPath) session, err := discordgo.New("Bot " + *token) if err != nil { log.Fatal(err) } session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent session.AddHandler(onReady) session.AddHandler(onMessage) session.AddHandler(onInteraction) if err = session.Open(); err != nil { log.Fatal(err) } defer session.Close() log.Println("Linkbomber is running. Press Ctrl+C to exit.") <-make(chan struct{}) }