commit 26d8f0a0513ff5e9cbe3956b8e739f621dd14576
parent 01a6a55a49e54e6b2664c41abcb16c6ad623b219
Author: amrfti <andrew@kloet.net>
Date: Mon, 13 Oct 2025 19:35:49 -0400
rewrite
Diffstat:
| M | README.md | | | 9 | ++------- |
| A | go.mod | | | 13 | +++++++++++++ |
| A | go.sum | | | 12 | ++++++++++++ |
| A | main.go | | | 238 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| D | main.py | | | 128 | ------------------------------------------------------------------------------- |
| D | requirements.txt | | | 2 | -- |
6 files changed, 265 insertions(+), 137 deletions(-)
diff --git 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 <DISCORD_BOT_TOKEN>`
diff --git a/go.mod 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
@@ -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
@@ -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
@@ -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
@@ -1,2 +0,0 @@
-discord
-python-dotenv