From 95b51ed655240f3c39f7ab67f009355f5a336e96 Mon Sep 17 00:00:00 2001 From: amrfti Date: Mon, 13 Oct 2025 19:35:49 -0400 Subject: [PATCH] rewrite --- README.md | 9 +- go.mod | 13 +++ go.sum | 12 +++ main.go | 238 +++++++++++++++++++++++++++++++++++++++++++++++ main.py | 128 ------------------------- requirements.txt | 2 - 6 files changed, 265 insertions(+), 137 deletions(-) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go delete mode 100644 main.py delete mode 100644 requirements.txt diff --git a/README.md b/README.md index 61f138a..d121310 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,5 @@ of its tracking elements. ## Setup 1. Clone the repository -2. Create a file named `.env` in the same directory as the bot -3. Add the following contents to the file -``` -DISCORD_BOT_TOKEN=your_bot_token_here -``` -4. Install the dependencies with `pip install -r requirements.txt` -5. Run the bot with `python main.py` +2. Build the program with `go build .` +3. Run the program `./linkbomber -token ` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..67569e6 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module git.kloet.net/amrfti/linkbomber + +go 1.25.1 + +require ( + github.com/bwmarrin/discordgo v0.29.0 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 +) + +require ( + github.com/gorilla/websocket v1.4.2 // indirect + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8e8eb37 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= +github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3a15cd1 --- /dev/null +++ b/main.go @@ -0,0 +1,238 @@ +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) + 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 + } + } + parsedURL.RawQuery = filtered.Encode() + return parsedURL.String() // Always return the cleaned URL if pattern matches + } + } + } + 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{}) +} diff --git a/main.py b/main.py deleted file mode 100644 index 2cdb1a3..0000000 --- a/main.py +++ /dev/null @@ -1,128 +0,0 @@ -import discord -import json -import re -import os -from urllib.parse import urlparse, parse_qs, urlencode, urlunparse -from discord import app_commands -from dotenv import load_dotenv - -load_dotenv() - - -def load_json(filename): - try: - with open(filename, "r", encoding="utf-8") as f: - return json.load(f) - except FileNotFoundError: - return {} - - -def save_json(data, filename): - with open(filename, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) - - -rules = load_json("url_rules.json") -server_settings = load_json("server_settings.json") - -intents = discord.Intents.default() -intents.message_content = True -client = discord.Client(intents=intents) -tree = app_commands.CommandTree(client) - - -def clean_url(url): - parsed_url = urlparse(url) - providers = [p for p in rules["providers"].items() if p[0] != "globalRules"] + [ - ("globalRules", rules["providers"].get("globalRules", {})) - ] - - for _, data in providers: - if re.match(data.get("urlPattern", ""), url): - query_params = parse_qs(parsed_url.query, keep_blank_values=True) - filtered_params = { - k: v - for k, v in query_params.items() - if not any(re.fullmatch(rule, k) for rule in data.get("rules", [])) - } - cleaned_query = urlencode(filtered_params, doseq=True) - return ( - urlunparse(( - parsed_url.scheme, - parsed_url.netloc, - parsed_url.path, - parsed_url.params, - cleaned_query, - parsed_url.fragment, - )) - if cleaned_query != parsed_url.query - else None - ) - return None - - -@client.event -async def on_ready(): - await tree.sync() - - -@client.event -async def on_message(message): - if message.author == client.user or server_settings.get( - str(message.guild.id), {} - ).get("disabled", False): - return - - for word in message.content.split(): - if word.startswith(("http://", "https://")) and (cleaned := clean_url(word)): - await message.channel.send(f"Cleaned link: <{cleaned}>") - break - - -@tree.command( - name="toggle", - description="Toggle automatic link cleaning for this server (requires Manage Messages permission).", -) -async def toggle(interaction: discord.Interaction): - if not interaction.user.guild_permissions.manage_messages: - await interaction.response.send_message( - "You need 'Manage Messages' permission to use this command.", ephemeral=True - ) - return - - guild_id = str(interaction.guild.id) - server_settings[guild_id] = { - "disabled": not server_settings.get(guild_id, {}).get("disabled", False) - } - save_json(server_settings, "server_settings.json") - await interaction.response.send_message( - f"Link cleaning {'disabled' if server_settings[guild_id]['disabled'] else 'enabled'}." - ) - - -@tree.command( - name="bomb", description="Cleans the first link found in the last 20 messages." -) -async def bomb(interaction: discord.Interaction): - async for msg in interaction.channel.history(limit=20): - for word in msg.content.split(): - if word.startswith(("http://", "https://")) and ( - cleaned := clean_url(word) - ): - await interaction.response.send_message(f"Cleaned link: <{cleaned}>") - return - await interaction.response.send_message("No links found.") - - -@tree.command( - name="about", - description="Explains the purpose of the bot and its privacy features.", -) -async def about(interaction: discord.Interaction): - await interaction.response.send_message( - "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." - ) - - -TOKEN = os.getenv("DISCORD_BOT_TOKEN") -client.run(TOKEN) diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 062c9a8..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -discord -python-dotenv