I Built a WoW Addon to Automate My Portal Business
How selling mage portals for gold in WoW Classic led to writing Lua, fighting the minimap API, and learning why Trade chat is a disaster for pattern matching.
If you’ve played World of Warcraft Classic, you know mages are the taxi service of Azeroth. Stand in a capital city, spam “/yell WTS ports to all cities”, wait for people to type “WTB port org” in chat, invite them, cast the portal, collect gold. Repeat indefinitely.
I was doing this. And I kept missing requests. Someone would yell “WTB port” and by the time I noticed and typed their name into the invite dialog, they’d already moved on or found another mage. So I did what any programmer would do: I spent several hours building a tool to automate a task that costs maybe 5 seconds per attempt.
But honestly it was more than just efficiency. I also wanted to learn WoW addon development. And I genuinely wanted a tool like this to exist. So: all three motivations, all at once.
The result is mage-portals: a WoW Classic addon that watches chat for portal requests and auto-invites the buyer. No more missed sales.
What it does
The addon listens to /say, /yell, and General (/1) by default. When it sees a message containing trigger words (“WTB” or “LF”) combined with portal words (“port”, “ports”, “portal”, “portals”), it fires an invite to that player automatically.
It also tries to detect which portal they want. If someone says “WTB port to uc”, the addon can log that it’s Undercity and optionally whisper them a confirmation. There’s a throttle so the same player doesn’t get spammed with repeat invites. It checks whether you’re in a raid (and whether you’re the leader or assistant), because only certain roles can invite.
There’s a minimap button with a portal icon. Right-click opens the config. Left-click toggles the addon on and off.
The stack
Lua. That’s it. WoW addons are Lua all the way down. There’s no package manager, no build step, no bundler. You write .lua files, reference them in a .toc manifest, drop the folder into Interface/AddOns/, and the game loads it.
Persistence works through SavedVariables. You declare a table name in the .toc file and the game automatically serializes it to disk when you log out. It’s a simple system that mostly stays out of your way.
The Classic Anniversary realm targets interface version 11500. A lot of the API is different from Retail and even slightly different across Classic versions, which turned out to matter when it came to actually sending the invite.
The pattern matching problem
The first version used a simple string.find(msg, "port") check. This works until someone types “I imported that mount from the auction house” in General chat. So it needed real word boundaries.
Lua’s pattern language doesn’t have \b. It has %f[%a], the “frontier pattern,” which matches a position where the previous character is not in the set and the next one is. It’s how you do word boundaries in Lua:
local function MsgHasPortalRequest(msg)
if type(msg) ~= "string" then return false end
local s = msg:lower()
local hasWTB = s:find("%f[%a]wtb%f[%A]") ~= nil
local hasLF = s:find("%f[%a]lf%f[%A]") ~= nil
local hasPort =
(s:find("%f[%a]ports?%f[%A]") ~= nil) or
(s:find("%f[%a]portals?%f[%A]") ~= nil)
return (hasWTB or hasLF) and hasPort
end
%f[%a] before a word means “position where the previous char is not a letter.” %f[%A] after means “position where the next char is not a letter.” Combined, they give you real word boundaries. “airport” doesn’t match because %f[%a]port requires a non-letter before “port,” and there’s an “r” there.
The invite logic also checks whether you can actually invite before doing anything. You can’t invite someone if you’re not the raid leader, if the party is full, or if the addon is disabled:
local function CanSendInvite()
if IsInRaid and IsInRaid() then
if UnitIsGroupLeader and UnitIsGroupLeader("player") then return true end
if UnitIsGroupAssistant and UnitIsGroupAssistant("player") then return true end
return false, "not raid leader/assistant"
end
if IsInGroup and IsInGroup() then
if UnitIsGroupLeader and UnitIsGroupLeader("player") then
if GetNumGroupMembers and GetNumGroupMembers() >= 5 then
return false, "party full"
end
return true
end
return false, "not party leader"
end
return true
end
The API compatibility shim for actually sending the invite was also necessary. Classic exposes InviteUnit as a global; some versions only have C_PartyInfo.InviteUnit. The addon tries both:
local function SendInviteByName(name)
if type(InviteUnit) == "function" then
InviteUnit(name)
return true, "InviteUnit"
end
if C_PartyInfo and type(C_PartyInfo.InviteUnit) == "function" then
C_PartyInfo.InviteUnit(name)
return true, "C_PartyInfo.InviteUnit"
end
return false, "no invite API"
end
Trade chat was a disaster
The addon originally had Trade (/2) listening enabled by default. This lasted about ten minutes before it became clear that was a mistake.
Trade chat is not General chat. People post long trade offers with multiple city names in them: “WTB port or any TBC mats, currently in TB need to get to UC for raid.” The basic portal detector would happily fire an invite on that. The person isn’t buying a portal from you; they’re trading mats, they mentioned a city in passing, and now they have an unexpected group invite from a stranger.
The fix was a separate, stricter filter for Trade channel only. It rejects messages where someone specifies a source city that isn’t Orgrimmar (because if you’re already in Orgrimmar, you need a portal out; if you’re in Thunder Bluff, you need something else entirely). It also catches implicit city pairs: “tb to uc” reads as “I’m in Thunder Bluff going to Undercity,” which means I can’t help.
-- Reject "from <other city>" patterns in Trade.
local fromOtherCity =
fromHas("%f[%a]uc%f[%A]") or fromHas("%f[%a]undercity%f[%A]") or
fromHas("%f[%a]tb%f[%A]") or fromHas("%f[%a]thunderbluff%f[%A]") or
fromHas("%f[%a]shat%f[%A]") or fromHas("%f[%a]shattrath%f[%A]")
if fromOtherCity and not fromOrg then
return false
end
-- Also reject implicit "tb to uc" patterns.
local srcOtherCityTo =
srcBeforeToHas("%f[%a]tb%f[%A]") or srcBeforeToHas("%f[%a]undercity%f[%A]")
-- ... etc.
if srcOtherCityTo then return false end
Trade listening is still there, but it defaults to off. Most mages selling portals are standing in Orgrimmar anyway, and General chat is noisy enough to catch real buyers.
The minimap button
This was the hardest part. Not conceptually hard, just fiddly in a way that took longer than expected.
WoW addons build UI by creating frames and attaching textures to them. A minimap button sounds simple: create a button, parent it to the Minimap frame, place it on the edge. In practice, there are four separate textures (background fill, icon, border ring, hover highlight), each needs to be independently sized and offset, and the whole thing needs to stay draggable while remaining clamped to the screen edge.
The button position is stored as an angle in SavedVariables so it persists between sessions. On load it converts the angle back to x/y coordinates relative to the minimap center:
local rad = angle * math.pi / 180
local radius = math.min(mmw, mmh) / 2 + 10 -- just outside the minimap edge
local x = math.cos(rad) * radius
local y = math.sin(rad) * radius
minimapButton:SetPoint("CENTER", Minimap, "CENTER", x, y)
Getting the offsets right so the icon, background, and border ring all looked centered took several iterations. Each texture has its own pixel offset because the WoW border texture isn’t square and doesn’t align with the button frame the way you’d expect.
Where it is now
It works. I use it when I’m farming portals on my mage. I open the game, turn on the addon, stand in Orgrimmar, and let it run. If someone asks for a port in General or Yell, I get an invite attempt automatically with a chat message telling me who and why.
The code is on GitHub. It’s not on CurseForge yet. It’s a single Lua file; if you play a mage on Classic Anniversary you can drop it in your AddOns folder and it should just work.
What I’d do differently
Design the config schema first. The SavedVariables table grew as I added features: channel toggles, throttle duration, minimap position, whisper behavior, water invites. Each one made sense when I added it, but the result is a flat table with a dozen unrelated keys. A more intentional structure upfront would have been cleaner to maintain.
Write tests before patterns. The pattern matching functions are the most important logic in the addon and the most fragile. I tested them manually by typing strings into the chat box. That worked, but a standalone test file with a table of expected matches and non-matches would have caught regressions much faster and made the Trade channel filter much less painful to get right.
Publish it. The whole point of automation is that other people have the same problem. Other mages are also missing portal requests. The friction to put this on CurseForge is low; I just haven’t done it yet.
Share this post
Built as part of
View the project →