rewrite
This commit is contained in:
@@ -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
13
go.mod
Normal 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
12
go.sum
Normal 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
238
main.go
Normal 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
128
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)
|
||||
@@ -1,2 +0,0 @@
|
||||
discord
|
||||
python-dotenv
|
||||
Reference in New Issue
Block a user