This commit is contained in:
2025-10-13 19:35:49 -04:00
parent 1941eeb75c
commit 95b51ed655
6 changed files with 265 additions and 137 deletions

View File

@@ -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>`

13
go.mod Normal file
View File

@@ -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
)

12
go.sum Normal file
View File

@@ -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=

238
main.go Normal file
View File

@@ -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{})
}

128
main.py
View File

@@ -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)

View File

@@ -1,2 +0,0 @@
discord
python-dotenv