local ADDON_NAME = "MailboxCourier" local DEFAULTS = { recipient = "", subject = "Auto mail", interval = 1.2, dryRun = false, autoConfirmUnknownRecipient = true, skipSoulbound = true, skipQuest = true, skipContainers = true, blacklist = { [6948] = true, -- Hearthstone [7005] = true, -- Skinning Knife }, } local state = { running = false, mailboxOpen = false, queue = {}, batch = {}, batchCount = 0, sentItems = 0, sentMails = 0, nextAt = 0, waitingForSuccess = false, lastConfirmAt = 0, lastError = "", } local frame = CreateFrame("Frame") local tooltip = CreateFrame("GameTooltip", "MailboxCourierTooltip", UIParent, "GameTooltipTemplate") tooltip:SetOwner(UIParent, "ANCHOR_NONE") local function copyDefaults(src, dst) dst = dst or {} for key, value in pairs(src) do if type(value) == "table" then dst[key] = copyDefaults(value, dst[key]) elseif dst[key] == nil then dst[key] = value end end return dst end local function db() MailboxCourierDB = copyDefaults(DEFAULTS, MailboxCourierDB or {}) return MailboxCourierDB end local function trim(text) return tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", "") end local function log(message) DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99[" .. ADDON_NAME .. "]|r " .. tostring(message)) end local function parseItemId(itemLink) if not itemLink then return nil end local itemId = itemLink:match("item:(%d+)") return itemId and tonumber(itemId) or nil end local function clearSendMailAttachments() ClearCursor() if ClearSendMail then ClearSendMail() return end for index = 1, 12 do if HasSendMailItem and HasSendMailItem(index) then ClickSendMailItemButton(index) ClearCursor() end end end local function tooltipHasText(bag, slot, needles) tooltip:ClearLines() tooltip:SetBagItem(bag, slot) for i = 1, tooltip:NumLines() do local line = _G["MailboxCourierTooltipTextLeft" .. i] local text = line and line:GetText() if text then for _, needle in ipairs(needles) do if needle and needle ~= "" and text:find(needle, 1, true) then return true end end end end return false end local function isSoulbound(bag, slot) return tooltipHasText(bag, slot, { ITEM_SOULBOUND, "Soulbound", "灵魂绑定", "靈魂綁定", }) end local function isQuestItem(bag, slot) return tooltipHasText(bag, slot, { ITEM_BIND_QUEST, "Quest Item", "任务物品", "任務物品", }) end local function isContainerItem(itemId, itemLink) local name, link, quality, itemLevel, reqLevel, itemType, itemSubType, stackCount, equipLoc = GetItemInfo(itemId or itemLink or "") if equipLoc == "INVTYPE_BAG" then return true end if itemType == ITEM_CLASS_CONTAINER or itemType == "Container" or itemType == "容器" or itemType == "容器" then return true end return false end local function getContainerItemInfoCompat(bag, slot) local texture, count, locked, quality, readable, lootable, itemLink if C_Container and C_Container.GetContainerItemInfo then local info = C_Container.GetContainerItemInfo(bag, slot) if not info then return nil end texture = info.iconFileID count = info.stackCount locked = info.isLocked quality = info.quality readable = info.isReadable lootable = info.hasLoot itemLink = info.hyperlink else texture, count, locked, quality, readable, lootable, itemLink = GetContainerItemInfo(bag, slot) if not itemLink and GetContainerItemLink then itemLink = GetContainerItemLink(bag, slot) end end if not itemLink then return nil end return { texture = texture, count = count or 1, locked = locked, quality = quality, readable = readable, lootable = lootable, itemLink = itemLink, itemId = parseItemId(itemLink), } end local function getContainerNumSlotsCompat(bag) if C_Container and C_Container.GetContainerNumSlots then return C_Container.GetContainerNumSlots(bag) or 0 end return GetContainerNumSlots(bag) or 0 end local function shouldSkipItem(bag, slot, info) local cfg = db() local itemId = info.itemId if info.locked then return true, "locked" end if itemId and cfg.blacklist[itemId] then return true, "blacklisted" end if cfg.skipSoulbound and isSoulbound(bag, slot) then return true, "soulbound" end if cfg.skipQuest and isQuestItem(bag, slot) then return true, "quest" end if cfg.skipContainers and isContainerItem(itemId, info.itemLink) then return true, "container" end return false, nil end local function scanBags() local queue = {} local skipped = {} for bag = 0, 4 do for slot = 1, getContainerNumSlotsCompat(bag) do local info = getContainerItemInfoCompat(bag, slot) if info then local skip, reason = shouldSkipItem(bag, slot, info) if skip then skipped[reason] = (skipped[reason] or 0) + 1 else table.insert(queue, { bag = bag, slot = slot, itemId = info.itemId, itemLink = info.itemLink, count = info.count or 1, }) end end end end return queue, skipped end local function stop(reason) state.running = false state.waitingForSuccess = false state.queue = {} state.batch = {} state.batchCount = 0 state.nextAt = 0 clearSendMailAttachments() if reason and reason ~= "" then log(reason) end end local function popNextUnlockedItem() while #state.queue > 0 do local item = table.remove(state.queue, 1) local info = getContainerItemInfoCompat(item.bag, item.slot) if info and not info.locked and parseItemId(info.itemLink) == item.itemId then local skip = shouldSkipItem(item.bag, item.slot, info) if not skip then return item end end end return nil end local function attachNextBatch() clearSendMailAttachments() state.batch = {} state.batchCount = 0 for attachment = 1, 12 do local item = popNextUnlockedItem() if not item then break end ClearCursor() PickupContainerItem(item.bag, item.slot) if CursorHasItem and not CursorHasItem() then log("无法拿起物品,跳过: " .. tostring(item.itemLink)) else ClickSendMailItemButton(attachment) ClearCursor() table.insert(state.batch, item) state.batchCount = state.batchCount + 1 end end return state.batchCount > 0 end local function sendCurrentBatch() local cfg = db() local recipient = trim(cfg.recipient) if recipient == "" then stop("没有收件人。先输入 /mc to 角色名") return end local subject = trim(cfg.subject) if subject == "" then subject = "Auto mail" end subject = subject .. " " .. (state.sentMails + 1) if cfg.dryRun then state.sentItems = state.sentItems + state.batchCount state.sentMails = state.sentMails + 1 log("dry-run: 模拟发送第 " .. state.sentMails .. " 封,附件 " .. state.batchCount .. " 件") clearSendMailAttachments() state.nextAt = GetTime() + cfg.interval return end state.waitingForSuccess = true state.nextAt = GetTime() + 8 SendMail(recipient, subject, "") log("已提交第 " .. (state.sentMails + 1) .. " 封,附件 " .. state.batchCount .. " 件") end local function maybeConfirmMailPopup() local cfg = db() if not cfg.autoConfirmUnknownRecipient then return false end if not state.running or not state.waitingForSuccess then return false end if GetTime() - state.lastConfirmAt < 0.5 then return false end local recipient = trim(cfg.recipient) for i = 1, 4 do local popup = _G["StaticPopup" .. i] if popup and popup:IsShown() then local which = tostring(popup.which or "") local textFrame = _G["StaticPopup" .. i .. "Text"] local text = tostring(textFrame and textFrame:GetText() or "") local lowerText = text:lower() local looksLikeMailPopup = which:find("MAIL", 1, true) or lowerText:find("mail", 1, true) or lowerText:find("recipient", 1, true) or lowerText:find("send", 1, true) or (recipient ~= "" and text:find(recipient, 1, true)) if looksLikeMailPopup then local button = _G["StaticPopup" .. i .. "Button1"] if button and (not button.IsEnabled or button:IsEnabled()) then state.lastConfirmAt = GetTime() button:Click() log("Auto-confirmed mail warning popup.") return true end end end end return false end local function process() if not state.running then return end if state.waitingForSuccess then maybeConfirmMailPopup() end if GetTime() < state.nextAt then return end if state.waitingForSuccess then stop("等待邮件发送成功超时,已停止。最后错误: " .. tostring(state.lastError)) return end if not state.mailboxOpen then stop("邮箱没有打开,已停止。") return end if attachNextBatch() then sendCurrentBatch() else stop("邮寄完成,共发送 " .. state.sentMails .. " 封," .. state.sentItems .. " 件物品。") end end local function beginSend() local cfg = db() local recipient = trim(cfg.recipient) if recipient == "" then log("没有收件人。用法: /mc to 角色名") return end if not state.mailboxOpen then log("邮箱还没打开。请先和邮箱交互。") return end state.queue = {} state.batch = {} state.batchCount = 0 state.sentItems = 0 state.sentMails = 0 state.lastError = "" state.waitingForSuccess = false local skipped state.queue, skipped = scanBags() if #state.queue == 0 then log("没有找到可邮寄物品。") return end state.running = true state.nextAt = GetTime() + 0.2 log("开始邮寄给 " .. recipient .. ",待发送 " .. #state.queue .. " 个背包格。") local parts = {} for reason, count in pairs(skipped or {}) do table.insert(parts, reason .. "=" .. count) end if #parts > 0 then log("已跳过: " .. table.concat(parts, ", ")) end end local function showStatus() local cfg = db() log("收件人: " .. (trim(cfg.recipient) ~= "" and cfg.recipient or "未设置")) log("主题: " .. cfg.subject .. ";间隔: " .. tostring(cfg.interval) .. " 秒;dry-run: " .. tostring(cfg.dryRun)) log("命令: /mc to 角色名, /mc send, /mc stop, /mc dryrun on|off, /mc black add|del itemID") end local function setRecipient(name) name = trim(name) if name == "" then log("用法: /mc to 角色名") return end db().recipient = name log("收件人已设置为: " .. name) end local function setSubject(subject) subject = trim(subject) if subject == "" then log("用法: /mc subject 邮件主题") return end db().subject = subject log("邮件主题已设置为: " .. subject) end local function setDryRun(value) value = trim(value):lower() if value == "on" or value == "1" or value == "true" then db().dryRun = true elseif value == "off" or value == "0" or value == "false" then db().dryRun = false else log("用法: /mc dryrun on|off") return end log("dry-run: " .. tostring(db().dryRun)) end local function setAutoConfirm(value) value = trim(value):lower() if value == "on" or value == "1" or value == "true" then db().autoConfirmUnknownRecipient = true elseif value == "off" or value == "0" or value == "false" then db().autoConfirmUnknownRecipient = false else log("Usage: /mc autoconfirm on|off") return end log("auto-confirm unknown recipient: " .. tostring(db().autoConfirmUnknownRecipient)) end local function updateBlacklist(rest) local action, idText = trim(rest):match("^(%S+)%s+(%S+)$") local itemId = tonumber(idText or "") if not action or not itemId then log("用法: /mc black add itemID 或 /mc black del itemID") return end action = action:lower() if action == "add" then db().blacklist[itemId] = true log("已加入黑名单 itemID: " .. itemId) elseif action == "del" or action == "remove" then db().blacklist[itemId] = nil log("已移出黑名单 itemID: " .. itemId) else log("用法: /mc black add itemID 或 /mc black del itemID") end end SLASH_MAILBOXCOURIER1 = "/mc" SLASH_MAILBOXCOURIER2 = "/mailboxcourier" SlashCmdList["MAILBOXCOURIER"] = function(msg) local command, rest = trim(msg):match("^(%S*)%s*(.-)$") command = (command or ""):lower() rest = rest or "" if command == "to" then setRecipient(rest) elseif command == "subject" then setSubject(rest) elseif command == "send" then beginSend() elseif command == "stop" then stop("已手动停止。") elseif command == "dryrun" then setDryRun(rest) elseif command == "autoconfirm" then setAutoConfirm(rest) elseif command == "black" or command == "blacklist" then updateBlacklist(rest) else showStatus() end end frame:RegisterEvent("ADDON_LOADED") frame:RegisterEvent("MAIL_SHOW") frame:RegisterEvent("MAIL_CLOSED") frame:RegisterEvent("MAIL_SEND_SUCCESS") frame:RegisterEvent("UI_ERROR_MESSAGE") frame:SetScript("OnEvent", function(_, event, arg1, arg2) if event == "ADDON_LOADED" and arg1 == ADDON_NAME then db() log("已加载。输入 /mc 查看命令。") elseif event == "MAIL_SHOW" then state.mailboxOpen = true log("邮箱已打开。") elseif event == "MAIL_CLOSED" then state.mailboxOpen = false if state.running then stop("邮箱关闭,已停止。") end elseif event == "MAIL_SEND_SUCCESS" then state.sentItems = state.sentItems + state.batchCount state.sentMails = state.sentMails + 1 state.waitingForSuccess = false state.batch = {} state.batchCount = 0 clearSendMailAttachments() state.nextAt = GetTime() + db().interval log("第 " .. state.sentMails .. " 封发送成功,累计 " .. state.sentItems .. " 件。") elseif event == "UI_ERROR_MESSAGE" then state.lastError = tostring(arg2 or arg1 or "") if state.running and state.lastError ~= "" then log("错误: " .. state.lastError) end end end) frame:SetScript("OnUpdate", process)