246 lines
7.1 KiB
Go
246 lines
7.1 KiB
Go
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)
|
|
changed := false
|
|
|
|
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
|
|
} else {
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
if changed {
|
|
parsedURL.RawQuery = filtered.Encode()
|
|
return parsedURL.String()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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 (defaults to working directory)")
|
|
settingsPathFlag := flag.String("settings", "server_settings.json", "path to server_settings.json file (defaults to working directory)")
|
|
token := flag.String("token", "", "Discord bot token")
|
|
flag.Parse()
|
|
|
|
settingsPath = *settingsPathFlag
|
|
|
|
if *token == "" {
|
|
log.Fatal("Bot token not provided. Use -token <DISCORD_BOT_TOKEN>")
|
|
}
|
|
|
|
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{})
|
|
}
|