linkbomber

discord bot that cleans tracking elements from messages
Download | Log | Files | Refs | README | LICENSE

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 }