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

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