main.go (7265B)
1 package main 2 3 import ( 4 "encoding/json" 5 "flag" 6 "fmt" 7 "io" 8 "log" 9 "net/url" 10 "os" 11 "regexp" 12 "strings" 13 "sync" 14 15 "github.com/bwmarrin/discordgo" 16 ) 17 18 var ( 19 rules map[string]any 20 serverSettings map[string]any 21 settingsMutex sync.RWMutex 22 ) 23 24 func loadJSON(filename string) map[string]any { 25 file, err := os.Open(filename) 26 if err != nil { 27 return map[string]any{} 28 } 29 defer file.Close() 30 31 data, err := io.ReadAll(file) 32 if err != nil { 33 return map[string]any{} 34 } 35 36 var result map[string]any 37 err = json.Unmarshal(data, &result) 38 if err != nil { 39 return map[string]any{} 40 } 41 return result 42 } 43 44 func saveJSON(data map[string]any, filename string) { 45 jsonData, _ := json.MarshalIndent(data, "", " ") 46 os.WriteFile(filename, jsonData, 0644) 47 } 48 49 func cleanURL(rawURL string) string { 50 parsedURL, err := url.Parse(rawURL) 51 if err != nil { 52 return "" 53 } 54 55 rulesMap := rules["providers"].(map[string]any) 56 providers := make([]map[string]any, 0, len(rulesMap)) 57 for key, value := range rulesMap { 58 if key != "globalRules" { 59 providers = append(providers, map[string]any{key: value}) 60 } 61 } 62 if globalRules, ok := rulesMap["globalRules"].(map[string]any); ok { 63 providers = append(providers, map[string]any{"globalRules": globalRules}) 64 } 65 66 for _, provider := range providers { 67 for _, data := range provider { 68 dataMap := data.(map[string]any) 69 pattern := dataMap["urlPattern"].(string) 70 if matched, _ := regexp.MatchString(pattern, rawURL); matched { 71 // Check exceptions 72 if exceptions, ok := dataMap["exceptions"].([]any); ok { 73 for _, exc := range exceptions { 74 if excMatched, _ := regexp.MatchString(exc.(string), rawURL); excMatched { 75 return "" // Skip cleaning for exceptions 76 } 77 } 78 } 79 // Proceed to filter params 80 queryParams, _ := url.ParseQuery(parsedURL.RawQuery) 81 rulesList := dataMap["rules"].([]any) 82 // Include referralMarketing if present 83 if referralMarketing, ok := dataMap["referralMarketing"].([]any); ok { 84 rulesList = append(rulesList, referralMarketing...) 85 } 86 filtered := make(url.Values) 87 changed := false 88 89 for k, v := range queryParams { 90 shouldFilter := false 91 for _, rule := range rulesList { 92 if matched, _ := regexp.MatchString(rule.(string), k); matched { 93 shouldFilter = true 94 break 95 } 96 } 97 if !shouldFilter { 98 filtered[k] = v 99 } else { 100 changed = true 101 } 102 } 103 104 if changed { 105 parsedURL.RawQuery = filtered.Encode() 106 return parsedURL.String() 107 } 108 } 109 } 110 } 111 return "" 112 } 113 114 func getServerDisabled(guildID string) bool { 115 settingsMutex.RLock() 116 defer settingsMutex.RUnlock() 117 if guildSettings, ok := serverSettings[guildID].(map[string]any); ok { 118 if d, ok := guildSettings["disabled"].(bool); ok { 119 return d 120 } 121 } 122 return false 123 } 124 125 func setServerDisabled(guildID string, disabled bool, filename string) { 126 settingsMutex.Lock() 127 defer settingsMutex.Unlock() 128 if serverSettings[guildID] == nil { 129 serverSettings[guildID] = map[string]any{} 130 } 131 serverSettings[guildID].(map[string]any)["disabled"] = disabled 132 saveJSON(serverSettings, filename) 133 } 134 135 func findFirstCleanLink(words []string) string { 136 for _, word := range words { 137 if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") { 138 if cleaned := cleanURL(word); cleaned != "" { 139 return fmt.Sprintf("Cleaned link: <%s>", cleaned) 140 } 141 } 142 } 143 return "" 144 } 145 146 func onReady(s *discordgo.Session, event *discordgo.Ready) { 147 log.Println("Bot is ready") 148 commands := []*discordgo.ApplicationCommand{ 149 {Name: "toggle", Description: "Toggle automatic link cleaning for this server (requires Manage Messages permission)."}, 150 {Name: "bomb", Description: "Cleans the first link found in the last 20 messages."}, 151 {Name: "about", Description: "Explains the purpose of the bot and its privacy features."}, 152 } 153 for _, cmd := range commands { 154 _, err := s.ApplicationCommandCreate(s.State.User.ID, "", cmd) 155 if err != nil { 156 log.Printf("Cannot create '%s' command: %v", cmd.Name, err) 157 } 158 } 159 } 160 161 func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) { 162 if m.Author.ID == s.State.User.ID || getServerDisabled(m.GuildID) { 163 return 164 } 165 if msg := findFirstCleanLink(strings.Fields(m.Content)); msg != "" { 166 s.ChannelMessageSend(m.ChannelID, msg) 167 } 168 } 169 170 func onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { 171 if i.Type != discordgo.InteractionApplicationCommand { 172 return 173 } 174 data := i.ApplicationCommandData() 175 switch data.Name { 176 case "toggle": 177 if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageMessages) == 0 { 178 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}}) 179 return 180 } 181 guildID := i.GuildID 182 newDisabled := !getServerDisabled(guildID) 183 setServerDisabled(guildID, newDisabled, settingsPath) 184 status := "enabled" 185 if newDisabled { 186 status = "disabled" 187 } 188 s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("Link cleaning %s.", status)}}) 189 190 case "bomb": 191 messages, err := s.ChannelMessages(i.ChannelID, 20, "", "", "") 192 msg := "No links found." 193 if err == nil { 194 for _, m := range messages { 195 if link := findFirstCleanLink(strings.Fields(m.Content)); link != "" { 196 msg = link 197 break 198 } 199 } 200 } else { 201 msg = "Error retrieving messages." 202 } 203 s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: msg}}) 204 205 case "about": 206 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."}}) 207 } 208 } 209 210 var settingsPath string 211 212 func main() { 213 rulesPath := flag.String("rules", "url_rules.json", "path to url_rules.json file (defaults to working directory)") 214 settingsPathFlag := flag.String("settings", "server_settings.json", "path to server_settings.json file (defaults to working directory)") 215 token := flag.String("token", "", "Discord bot token") 216 flag.Parse() 217 218 settingsPath = *settingsPathFlag 219 220 if *token == "" { 221 log.Fatal("Bot token not provided. Use -token <DISCORD_BOT_TOKEN>") 222 } 223 224 rules = loadJSON(*rulesPath) 225 serverSettings = loadJSON(settingsPath) 226 227 session, err := discordgo.New("Bot " + *token) 228 if err != nil { 229 log.Fatal(err) 230 } 231 232 session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent 233 234 session.AddHandler(onReady) 235 session.AddHandler(onMessage) 236 session.AddHandler(onInteraction) 237 238 if err = session.Open(); err != nil { 239 log.Fatal(err) 240 } 241 defer session.Close() 242 243 log.Println("Linkbomber is running. Press Ctrl+C to exit.") 244 <-make(chan struct{}) 245 }