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.

· 7 min read

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.

Built as part of

View the project →