diff --git a/PR_Scambuster.md b/PR_Scambuster.md new file mode 100644 index 0000000..aefd2bd --- /dev/null +++ b/PR_Scambuster.md @@ -0,0 +1,115 @@ +# Guild Blacklist Support + +## Dependency Notice + +**This PR requires a companion PR in the [Scambuster-Spineshatter](https://github.com/shockedarmor/Scambuster-Spineshatter/tree/guild-wide-listing) repo to function end-to-end.** + +This PR adds the framework-level infrastructure. The Spineshatter PR adds the data pipeline and the guild list itself. Both must be merged and deployed together. + +--- + +## Overview + +Adds guild-based blacklisting to the Scambuster framework. Previously Scambuster could only warn on individual players matched by GUID or name. This change adds a parallel system that fires a warning whenever a player is encountered who is a member of a blacklisted guild, regardless of whether that individual player is on any list. + +Guild blacklisting is realm-scoped by design. A guild blacklisted on Spineshatter will never trigger on any other realm. + +--- + +## What Changed + +### `core.lua` + +**New: `inject_guild_tooltip(tooltip)`** +Hooked into `GameTooltip:OnTooltipSetUnit` on addon load. Fires on every unit tooltip display, completely independent of the scan system and scan toggle settings. If the unit's guild matches any blacklisted guild, injects into the tooltip: +- Red header: `[!] BLACKLISTED GUILD: ` +- Yellow description line +- Clickable `[Evidence]` URL link if one is provided + +This is the primary user-facing warning that players will see. + +**New: `process_guild_data(l)`** +Called during `build_database` for every registered provider. Reads the `guild_data` field from the provider table and loads matching realm entries into `self.provider_guild_table` (in-memory only, rebuilt on every load). Realm scoping is enforced here: only entries whose realm key matches `self.realm_name` are loaded. Entry format uses numeric indices with `guild`, `description`, and `url` fields, consistent with the existing `case_table` format. + +**New: `check_unit_guild(unit_token)`** +Checks a unit's guild against two sources: +- `self.provider_guild_table` — guilds distributed via addon updates by list maintainers +- `self.db.realm.guild_blacklist` — guilds added by the individual player via slash command + +Provider entries take priority if the same guild name appears in both. Respects the same alert lockout period as player alerts to avoid spam. This drives the secondary active chat alert on target, trade, and group scans. + +**New: `raise_guild_alert(unit_token, guild, entry)`** +Fires the chat message and sound alert for a guild hit on active scans. Prints the description and a clickable URL if present. Uses the same `use_system_alert` and `use_alert_sound` settings as player alerts. + +**Updated: `OnEnable()`** +Registers the `GameTooltip:HookScript("OnTooltipSetUnit")` once on addon load. + +**Updated: `check_unit(unit_token, unit_guid, scan_context)`** +Calls `check_unit_guild(unit_token)` at the top of every scan when a unit token is available. Covers mouseover, target, trade, and group scans. + +**Updated: `GROUP_ROSTER_UPDATE()`** +Added a second loop over unit tokens after the existing GUID-based scan loop so group members are also checked against the guild blacklist. + +**New: `slashcommand_guild(input)`** +Registered as `/sbguild`. Manages the player's personal guild blacklist at runtime. See slash command reference below. + +**Updated: `validate_provider()`** +Added `guild_data` to the recognised provider fields so it does not generate spurious warnings during list import. + +**Updated: `build_database()`** +Resets `self.provider_guild_table = {}` on each rebuild and calls `process_guild_data` for every provider after processing player cases. + +### `config.lua` + +**Updated: `defaults.realm`** +Added `guild_blacklist = {}` to the realm-scoped defaults. Using `db.realm` ensures entries are automatically scoped to the realm the player is logged into with no extra key manipulation required. + +**Updated: `defaults.profile`** +Added `guild_blacklist_enabled = true`. Enables guild blacklisting by default. Stored per-profile so different characters can have different preferences. + +**Updated: `SB.options`** +Added a Guild Blacklist tab to the Scambuster AceConfig UI (`/sb`). Contains an enable/disable toggle and a slash command reference card. + +--- + +## Two Sources, One Check + +The guild blacklist operates from two independent sources checked at both tooltip display and scan time: + +| Source | How it gets there | Scope | Persists | +|---|---|---|---| +| Provider guild list | Addon maintainer edits `list.lua`, pushes update | All users on next update | No — rebuilt in memory each load | +| Player personal list | `/sbguild add` slash command | That player's client only | Yes — stored in SavedVariables | + +Because provider entries are in-memory only, removing a guild from `list.lua` takes effect for all users immediately on the next addon update with no stale data left in anyone's SavedVariables. + +--- + +## Slash Command Reference + +``` +/sbguild add | Add a guild to your personal blacklist +/sbguild remove Remove a guild from your personal blacklist +/sbguild list Show all blacklisted guilds (both sources, labelled) +/sbguild on Enable guild blacklisting +/sbguild off Disable guild blacklisting +/sbguild Show help and current status +``` + +Examples: +``` +/sbguild add Blablabla | Mass scam, ninja looted SR run +/sbguild add Sketchy Guild +/sbguild remove Blablabla +/sbguild list +``` + +If no description is provided the entry is stored with "No description specified". Personal entries are visible in `/sbguild list` labelled as `user-added`. Provider entries are labelled with the provider name. + +--- + +## Notes + +- Guild name matching is case and space sensitive. Always use the exact in-game capitalisation. +- `GetGuildInfo()` depends on cached client data. On very first mouseover before the client has received guild info for a player there may be a miss. A second mouseover will catch it. This is a WoW API limitation. +- Guild alerts respect the same alert lockout timer as player alerts (default 15 minutes) to avoid repeated chat warnings for the same guild. The tooltip warning is always shown regardless of lockout. diff --git a/config.lua b/config.lua index bc11fd5..309b454 100644 --- a/config.lua +++ b/config.lua @@ -21,7 +21,8 @@ SB.defaults = { realm = { n_alerts = 0, n_detections = 0, - n_scans = 0 + n_scans = 0, + guild_blacklist = {}, }, -- The profile table is where the user config options are stored. @@ -86,6 +87,9 @@ SB.defaults = { -- Probation list alerts probation_alerts = true, + + -- Guild blacklist + guild_blacklist_enabled = true, }, } @@ -552,6 +556,47 @@ SB.options = { scanning = scan_opts_group, reports = reports_group, alerts = alerts_opts_group, + + guild_blacklist_group = { + type = "group", + order = 5.0, + name = "Guild Blacklist", + handler = SB, + args = { + h1 = { + order = 1.0, + type = "header", + name = "Guild Blacklist", + }, + d1 = { + order = 1.1, + type = "description", + name = "When enabled, Scambuster will warn you whenever you interact with a member of a blacklisted guild. " .. + "Manage guilds with the |cffffcc00/sbguild|r slash command.", + }, + guild_blacklist_enabled = { + order = 1.2, + type = "toggle", + name = "Enable Guild Blacklist", + desc = "If enabled, Scambuster will alert you when you encounter a member of a blacklisted guild.", + get = "opts_getter", + set = "opts_setter", + }, + h2 = { + order = 2.0, + type = "header", + name = "Slash Commands", + }, + d2 = { + order = 2.1, + type = "description", + name = "|cffffcc00/sbguild add | |r - Add a guild to the blacklist\n" .. + "|cffffcc00/sbguild remove |r - Remove a guild from the blacklist\n" .. + "|cffffcc00/sbguild list|r - Show all blacklisted guilds\n" .. + "|cffffcc00/sbguild on|r / |cffffcc00/sbguild off|r - Toggle guild blacklisting", + }, + }, + }, } } diff --git a/core.lua b/core.lua index b71cb49..698866b 100644 --- a/core.lua +++ b/core.lua @@ -47,6 +47,9 @@ local string = string local type = type local tostring = tostring +-- In-memory lockout table for guild alerts (keyed by guild name, not persisted). +local guild_alert_lockout = {} + local function tab_dump(o) if type(o) == 'table' then local s = '{ ' @@ -198,6 +201,7 @@ function SB:OnInitialize() self:RegisterChatCommand("sb", "slashcommand_options") self:RegisterChatCommand("scambuster", "slashcommand_options") self:RegisterChatCommand("cutpurse", "slashcommand_options") + self:RegisterChatCommand("sbguild", "slashcommand_guild") self:RegisterChatCommand("dump_users", "dump_users") self:RegisterChatCommand("dump_incidents", "dump_incidents") self:RegisterChatCommand("dump_name_lookup", "dump_name_lookup") @@ -224,6 +228,12 @@ function SB:OnEnable() self:build_database() self:RegisterEvent("PLAYER_ENTERING_WORLD") + -- Hook the game tooltip to inject guild blacklist warnings directly. + -- This is independent of the scan system and fires on every tooltip display. + GameTooltip:HookScript("OnTooltipSetUnit", function(tooltip) + self:inject_guild_tooltip(tooltip) + end) + -- Welcome message if requested if conf.welcome_message then self:Print('Welcome to version ' .. tostring(version)) @@ -270,6 +280,7 @@ function SB:validate_provider(t) end local valid_fields = { realm_data = true, + guild_data = true, name = true, provider = true, url = true, @@ -307,6 +318,10 @@ function SB:build_database() self.previous_guid_table = {} self.alias_table = {} + -- In-memory table for provider-distributed guild blacklist entries. + -- Rebuilt on every load so removals in provider updates take effect immediately. + self.provider_guild_table = {} + -- Now iterate over the unprocessed case data and build up the db. local pdb = self:get_provider_settings() for _, l in pairs(self.unprocessed_case_data) do @@ -322,6 +337,9 @@ function SB:build_database() self:protected_process_provider(l) end end + -- Guild data is processed regardless of enabled state, + -- consistent with how the provider's realm scoping works. + self:process_guild_data(l) end end end @@ -385,6 +403,28 @@ function SB:process_provider(l) end end +function SB:process_guild_data(l) + -- Loads provider-distributed guild blacklist entries into the in-memory + -- provider_guild_table, scoped to the current realm only. + -- Only guilds whose realm key matches self.realm_name are loaded, + -- so a guild blacklisted on Spineshatter is never active on another realm. + -- Entry format mirrors case_table: numeric index with guild, description, url fields. + if not l.guild_data then return end + for realm, guild_table in pairs(l.guild_data) do + if realm == self.realm_name then + for _, entry in pairs(guild_table) do + if entry.guild then + self.provider_guild_table[entry.guild] = { + description = entry.description or "No description provided", + url = entry.url or false, + provider = l.provider, + } + end + end + end + end +end + function SB:process_players(case_data) -- This function handles parsing of incidents with multiple players. for _, player_info in pairs(case_data.players) do @@ -478,6 +518,92 @@ function SB:reference_incident_to_player(input) end end +--========================================================================================= +-- Guild blacklist functionality. +--========================================================================================= +function SB:inject_guild_tooltip(tooltip) + -- Injects a guild blacklist warning directly into the game tooltip. + -- Fires on every tooltip display independently of the scan system. + if not self:get_opts_db().guild_blacklist_enabled then return end + + local _, unit = tooltip:GetUnit() + if not unit or not UnitIsPlayer(unit) then return end + if UnitIsUnit("player", unit) then return end + + local guild = GetGuildInfo(unit) + if not guild then return end + + local entry = self.provider_guild_table[guild] or self.db.realm.guild_blacklist[guild] + if not entry then return end + + tooltip:AddLine(" ") + tooltip:AddLine("|cffff0000[!] BLACKLISTED GUILD: <" .. guild .. ">|r") + if entry.description then + tooltip:AddLine("|cffffff00" .. entry.description .. "|r") + end + if entry.url then + tooltip:AddLine("|cff149bfd|Hurl:" .. entry.url .. "|h[Evidence]|h|r") + end + tooltip:Show() +end + +function SB:check_unit_guild(unit_token) + -- Checks the given unit's guild against both the provider guild table + -- (distributed via addon updates) and the user's personal guild blacklist + -- (managed via /sbguild). Both are already realm-scoped. + if not UnitIsPlayer(unit_token) then return end + if UnitIsUnit("player", unit_token) then return end + + local conf = self:get_opts_db() + if not conf.guild_blacklist_enabled then return end + + local guild = GetGuildInfo(unit_token) + if not guild then return end + + -- Provider-distributed entries take priority, fall back to user entries. + local entry = self.provider_guild_table[guild] or self.db.realm.guild_blacklist[guild] + if not entry then return end + + -- Apply the same alert lockout period used for player alerts. + local lockout_key = "guild:" .. guild + local timeNow = GetServerTime() + if guild_alert_lockout[lockout_key] then + if timeNow < conf.alert_lockout_seconds + guild_alert_lockout[lockout_key] then + return + end + end + guild_alert_lockout[lockout_key] = timeNow + + self:raise_guild_alert(unit_token, guild, entry) +end + +function SB:raise_guild_alert(unit_token, guild, entry) + -- Fires a chat and/or sound alert for a guild blacklist hit. + local conf = self:get_opts_db() + local name = UnitName(unit_token) or "Unknown" + + if conf.use_alert_sound then + self:play_alert_sound() + end + + if conf.use_system_alert then + local s = string.format( + "|cffffcc00%s|r is a member of blacklisted guild |cffff0000<%s>|r\n", + name, guild + ) + if entry.description then + s = s .. " " .. entry.description .. "\n" + end + if entry.url then + s = s .. " " .. formatURL(entry.url) + end + self:Print(s) + end + + self.db.global.n_alerts = self.db.global.n_alerts + 1 + self.db.realm.n_alerts = self.db.realm.n_alerts + 1 +end + --========================================================================================= -- Unit checking functionality. --========================================================================================= @@ -507,6 +633,10 @@ function SB:check_unit(unit_token, unit_guid, scan_context) -- First check for a guid match. self.db.global.n_scans = self.db.global.n_scans + 1 self.db.realm.n_scans = self.db.realm.n_scans + 1 + + -- Guild blacklist check runs independently of GUID/name matching. + -- Requires a unit_token so we can call GetGuildInfo. + if unit_token then self:check_unit_guild(unit_token) end local conf = self:get_opts_db() unit_guid = unit_guid or UnitGUID(unit_token) local guid_match = false @@ -964,6 +1094,12 @@ function SB:GROUP_ROSTER_UPDATE() -- self:Print(name, guid) self:check_unit(nil, guid, "group") end + -- Guild check per unit token (check_unit skips guild when token is nil). + for i = 1, n do + if UnitExists(unit..i) then + self:check_unit_guild(unit..i) + end + end end function SB:GROUP_INVITE_CONFIRMATION() @@ -1050,6 +1186,115 @@ function SB:slashcommand_options(input, editbox) ACD:Open(addon_name.."_Options") end +function SB:slashcommand_guild(input) + -- /sbguild add | + -- /sbguild remove + -- /sbguild list + -- /sbguild on + -- /sbguild off + local function trim(s) return s:match("^%s*(.-)%s*$") end + + if not input or input == "" then + self:Print("Guild Blacklist commands:") + print(" /sbguild add | ") + print(" /sbguild remove ") + print(" /sbguild list") + print(" /sbguild on / off") + local state = self:get_opts_db().guild_blacklist_enabled and "|cff00ff00ENABLED|r" or "|cffff4444DISABLED|r" + print(" Status: " .. state) + return + end + + local cmd, rest = input:match("^(%S+)%s*(.*)") + if not cmd then return end + cmd = cmd:lower() + + if cmd == "on" then + self:get_opts_db().guild_blacklist_enabled = true + self:Print("Guild blacklist |cff00ff00ENABLED|r.") + return + end + + if cmd == "off" then + self:get_opts_db().guild_blacklist_enabled = false + self:Print("Guild blacklist |cffff4444DISABLED|r.") + return + end + + if cmd == "list" then + local count = 0 + -- Provider-distributed guilds + for name, entry in pairs(self.provider_guild_table) do + count = count + 1 + self:Print(string.format( + "|cffffcc00<%s>|r %s |cffaaaaaa(provider: %s)|r", + name, entry.description or "", entry.provider + )) + end + -- User-added guilds + for name, entry in pairs(self.db.realm.guild_blacklist) do + count = count + 1 + self:Print(string.format( + "|cffffcc00<%s>|r %s |cffaaaaaa(user-added)|r", + name, entry.description or "" + )) + end + if count == 0 then + self:Print("No guilds blacklisted.") + else + self:Print(count .. " guild(s) on blacklist.") + end + return + end + + if cmd == "add" then + if not rest or rest == "" then + self:Print("Usage: /sbguild add | ") + return + end + local guildName, description = rest:match("^(.-)%s*|%s*(.+)$") + if not guildName or not description then + guildName = trim(rest) + description = "No description specified" + else + guildName = trim(guildName) + description = trim(description) + end + if guildName == "" then + self:Print("Guild name cannot be empty.") + return + end + if self.db.realm.guild_blacklist[guildName] then + self:Print("|cffffcc00<" .. guildName .. ">|r is already blacklisted. Use /sbguild remove first to replace it.") + return + end + self.db.realm.guild_blacklist[guildName] = { + description = description, + } + self:Print("|cff00ff00Added:|r <" .. guildName .. "> - " .. description) + return + end + + if cmd == "remove" then + if not rest or rest == "" then + self:Print("Usage: /sbguild remove ") + return + end + local guildName = trim(rest) + if not self.db.realm.guild_blacklist[guildName] then + self:Print("|cffffcc00<" .. guildName .. ">|r not found on blacklist.") + return + end + self.db.realm.guild_blacklist[guildName] = nil + -- Clear lockout so a re-add takes effect immediately. + guild_alert_lockout["guild:" .. guildName] = nil + self:Print("|cffff4444Removed:|r <" .. guildName .. "> from blacklist.") + return + end + + self:Print("Unknown command. Type /sbguild for usage.") +end + function SB:dump_users() print(tab_dump(self.user_table)) end