Files

1117 lines
41 KiB
Lua

-- Freewood-OctoWoW-Mail: eigenes Briefkasten (Logik).
--
-- Posteingang "Alle oeffnen" / "Nur Ausgewaehlte oeffnen": nimmt Gold und Items, ein Schritt nach dem anderen.
-- - loescht und retourniert nie einen Brief; Nachnahme-Briefe und reine Briefe bleiben unberuehrt
-- - ein Taschenplatz bleibt immer frei (Items bleiben sonst im Briefkasten)
-- - nach jedem Schritt wird der Posteingang neu gelesen (die Nummern verschieben sich)
-- Senden Empfaenger mit den letzten 5, mehrere Items (je Item ein Brief, nacheinander), Gold, Nachnahme
-- Mailinglisten Namen mit Items, optional Gold-Ueberschuss und Automatik beim Oeffnen des Briefkastens
--
-- Alle Sendungen laufen ueber dieselbe Warteschlange (Mail.send): ein Brief, dann auf MAIL_SEND_SUCCESS warten,
-- dann Pause, dann der naechste. Sie laufen nie, solange ein Brett-Auftrag einen Brief vorbereitet (BoardBusy).
--
-- Gespeichert: Account-weit Mail.db = { settings, recipients, lists }.
local ADDON, NS = ...
local FW = _G.Freewood
if not FW or not FW:RequireAPI(1, ADDON) then
return
end
local Util = FW.Util
local L = FW.L
local MAX_RECENT = 5
local MAX_LISTS = 30
local MAX_ITEMS_PER_LIST = 60
local MAX_JOBS = 120
local STEP = 0.1 -- Takt des Treibers
local INBOX_GAP = 0.5 -- Pause nach dem Nehmen
local INBOX_TIMEOUT = 2.5 -- so lange auf die Antwort des Servers warten
local GAP_START = 0.4 -- Pause nach jedem gesendeten Brief: Start, wird dynamisch angepasst
local GAP_MIN = 0.25
local GAP_MAX = 4
local MAX_RETRY = 3 -- so oft wird ein vom Server abgelehnter Brief mit langsamerem Tempo wiederholt
local SEND_TIMEOUT = 8
local POSTAGE = 30 -- Kupfer je Brief (Schaetzung fuer den Gold-Ueberschuss)
local AUTO_DELAY = 2.5
local KEEP_FREE = 1 -- so viele Taschenplaetze bleiben beim Oeffnen immer frei
local function GetMeta(field)
local fn = (C_AddOns and C_AddOns.GetAddOnMetadata) or GetAddOnMetadata
if fn then
local ok, value = pcall(fn, ADDON, field)
if ok then
return value
end
end
return nil
end
local Mail = FW:NewModule("Mail", {
version = GetMeta("Version") or "dev",
title = L.MAIL_TITLE,
description = L.MAIL_DESC,
defaults = {
settings = { replace = false, chat = true, cleanup = true, archiveDays = 0, boardAuto = true },
recipients = {},
lists = {},
archive = {}, -- entfernte Briefe, neueste zuerst
sentSubjects = {}, -- Betreffe, die wir selbst ohne Text gesendet haben (Betreff -> Zeit)
},
})
NS.Mail = Mail
FW.Mail = Mail
Mail.open = false -- ein Briefkasten ist offen
Mail.busy = nil -- nil | "inbox" | "send"
Mail.gen = 0 -- zaehlt die Briefkasten-Oeffnungen
Mail.gap = GAP_START -- aktuelle Pause zwischen zwei Briefen (so klein wie moeglich, waechst bei Ablehnung)
local function Now() return GetTime and GetTime() or 0 end
local function After(t, fn)
if C_Timer and C_Timer.After then C_Timer.After(t, fn) else pcall(fn) end
end
local function Trace(msg)
local C = FW.Creator
if C and C.Trace then pcall(C.Trace, "Mail: " .. tostring(msg)) end
end
Mail.Trace = Trace
local function Say(msg)
if Mail.db and Mail.db.settings.chat and FW.Print then FW:Print(msg) end
end
function Mail.Money(c)
if Util.FormatMoney then return Util.FormatMoney(c) end
return tostring(c) .. "c"
end
local function Trim(s)
return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end
-- Charaktername: Leerzeichen weg, erster Buchstabe gross
function Mail.CleanName(s)
s = Trim(s):gsub("%s+", "")
if s == "" then return "" end
return (s:sub(1, 1):upper() .. s:sub(2))
end
local function IsSelf(name)
local me = UnitName and UnitName("player")
return me and name and name:lower() == me:lower()
end
Mail.IsSelf = IsSelf
------------------------------------------------------------------------
-- Taschen
------------------------------------------------------------------------
local function NumSlots(bag)
local fn = (C_Container and C_Container.GetContainerNumSlots) or GetContainerNumSlots
if type(fn) ~= "function" then return 0 end
local ok, n = pcall(fn, bag)
return (ok and tonumber(n)) or 0
end
local function SlotInfo(bag, slot)
if C_Container and C_Container.GetContainerItemInfo then
local ok, info = pcall(C_Container.GetContainerItemInfo, bag, slot)
if ok and type(info) == "table" then
return tonumber(info.itemID), tonumber(info.stackCount) or 1, info.isLocked and true or false
end
end
if GetContainerItemInfo then
local ok, _, count, locked, _, _, _, link, _, _, itemID = pcall(GetContainerItemInfo, bag, slot)
if ok then
local id = tonumber(itemID) or (type(link) == "string" and tonumber(link:match("item:(%d+)"))) or nil
if not id and GetContainerItemLink then
local ok2, l2 = pcall(GetContainerItemLink, bag, slot)
if ok2 and type(l2) == "string" then id = tonumber(l2:match("item:(%d+)")) end
end
return id, tonumber(count) or 1, locked and true or false
end
end
return nil
end
local function BagCount() return tonumber(_G.NUM_BAG_SLOTS) or 4 end
-- Alle Staepel in den normalen Taschen: { bag, slot, id, count, locked }
function Mail.BagStacks()
local out = {}
for bag = 0, BagCount() do
for slot = 1, NumSlots(bag) do
local id, count, locked = SlotInfo(bag, slot)
if id then out[#out + 1] = { bag = bag, slot = slot, id = id, count = count, locked = locked } end
end
end
return out
end
-- freie Plaetze in Taschen ohne Spezialtyp (Koecher, Seelentasche ... zaehlen nicht)
function Mail.FreeSlots()
local fn = (C_Container and C_Container.GetContainerNumFreeSlots) or GetContainerNumFreeSlots
if type(fn) ~= "function" then return 0 end
local n = 0
for bag = 0, BagCount() do
local ok, free, kind = pcall(fn, bag)
if ok and tonumber(free) and (kind == nil or kind == 0) then n = n + free end
end
return n
end
------------------------------------------------------------------------
-- Zusammenspiel mit den Brett-Auftraegen (Creator)
------------------------------------------------------------------------
-- Ein Brett-Auftrag bereitet gerade einen Brief vor oder fragt danach: dann fassen wir den Brief nicht an.
function Mail.BoardBusy()
local C = FW.Creator
if not C then return false end
if C.mailChoice or C.asking then return true end
if C.askQueue and #C.askQueue > 0 then return true end
if C.GuardState then
local ok, st = pcall(C.GuardState)
if ok and st and st ~= "off" then return true end
end
return false
end
-- Items, die ein offener Brett-Auftrag noch verschicken muss: set[itemID] = true
function Mail.Reserved()
local set, n = {}, 0
local C = FW.Creator
if C and C.ActiveList and C.db and C.ObjProgress then
local ok = pcall(function()
for _, id in ipairs(C.ActiveList()) do
local q = C.db.quests[id]
for i, o in ipairs(q and q.objs or {}) do
if o.type == "mail" and o.item then
local have, need = C.ObjProgress(id, i)
if have < need and not set[o.item] then
set[o.item] = true
n = n + 1
end
end
end
end
end)
if not ok then Trace("Reservierung konnte nicht gelesen werden") end
end
return set, n
end
------------------------------------------------------------------------
-- Empfaenger (die letzten 5)
------------------------------------------------------------------------
function Mail:AddRecipient(name)
name = Mail.CleanName(name)
if name == "" or IsSelf(name) then return end
local list = self.db.recipients
for i = #list, 1, -1 do
if list[i]:lower() == name:lower() then table.remove(list, i) end
end
table.insert(list, 1, name)
while #list > MAX_RECENT do table.remove(list) end
FW:Fire("MAIL_RECIPIENTS_CHANGED")
end
function Mail:Recipients()
return self.db.recipients
end
------------------------------------------------------------------------
-- Posteingang
------------------------------------------------------------------------
-- Liste der Briefe: { i, sender, subject, money, cod, days, hasItem, itemName, itemTex, itemCount, sig, key, take, why }
function Mail.ReadInbox()
local list = {}
if not GetInboxNumItems then return list end
local ok, n = pcall(GetInboxNumItems)
n = (ok and tonumber(n)) or 0
local seen = {}
for i = 1, n do
local ok1, _, _, sender, subject, money, cod, days, hasItem, wasRead, wasReturned, _, canReply = pcall(GetInboxHeaderInfo, i)
if ok1 then
local m = {
i = i, sender = sender or "?", subject = subject or "",
money = tonumber(money) or 0, cod = tonumber(cod) or 0, days = tonumber(days),
hasItem = (hasItem and hasItem ~= 0) and true or false, read = wasRead and true or false,
returned = wasReturned and true or false, canReply = not (canReply == false or canReply == 0),
}
if m.hasItem and GetInboxItem then
local ok2, name, tex, count = pcall(GetInboxItem, i)
if ok2 then m.itemName, m.itemTex, m.itemCount = name, tex, tonumber(count) or 1 end
end
m.sig = table.concat({ m.sender, m.subject, m.money, m.cod, tostring(m.itemName), tostring(m.itemCount) }, "|")
seen[m.sig] = (seen[m.sig] or 0) + 1
m.key = m.sig .. "#" .. seen[m.sig]
if m.cod > 0 then
m.take, m.why = false, "cod"
elseif m.money > 0 or m.hasItem then
m.take = true
else
m.take, m.why = false, "letter"
end
list[#list + 1] = m
end
end
return list
end
------------------------------------------------------------------------
-- Brieftext, Archiv, einzelne Briefe
------------------------------------------------------------------------
local MAX_ARCHIVE = 500
Mail.taken = {} -- "Absender|Betreff" -> { money, items = { "Name xN" } }: was aus dem Brief genommen wurde
local function Pair(m) return tostring(m.sender) .. "|" .. tostring(m.subject) end
Mail.Pair = Pair
-- Text eines Briefs (markiert ihn als gelesen). Rueckgabe: Text, istRechnung
function Mail.ReadText(i)
if not GetInboxText then return "", false end
local ok, body, _, _, invoice = pcall(GetInboxText, i)
if not ok then return "", false end
body = type(body) == "string" and body or ""
if invoice and GetInboxInvoiceInfo then
local ok2, kind, item, who, bid = pcall(GetInboxInvoiceInfo, i)
if ok2 and item then
local parts = { tostring(kind or "invoice"), tostring(item) }
if who then parts[#parts + 1] = tostring(who) end
if bid then parts[#parts + 1] = Mail.Money(bid) end
body = (body ~= "" and (body .. "\n") or "") .. table.concat(parts, " - ")
end
end
return body, invoice and true or false
end
-- Nichts mehr angehaengt, kein Gold, keine Nachnahme: dann gehen beim Loeschen keine Werte verloren
function Mail.Deletable(m)
return m ~= nil and not m.hasItem and m.money == 0 and m.cod == 0
end
local function NoteTaken(m, what)
local p = Pair(m)
local t = Mail.taken[p] or { money = 0, items = {} }
Mail.taken[p] = t
if what == "money" then
t.money = t.money + m.money
else
t.items[#t.items + 1] = (m.itemName or "?") .. ((m.itemCount or 1) > 1 and (" x" .. m.itemCount) or "")
end
end
-- Eigene Briefe ohne Text (Betreff steht alles): werden ohne Archiv entfernt
function Mail:NoteSent(subject, body)
if (body or "") ~= "" or not subject or subject == "" then return end
local t = self.db.sentSubjects
local now = time()
t[subject] = now
local n = 0
for k, v in pairs(t) do
n = n + 1
if now - (tonumber(v) or 0) > 30 * 86400 then t[k] = nil end
end
end
function Mail:IsOwnSent(subject, body)
return (body or "") == "" and self.db.sentSubjects[subject] ~= nil
end
function Mail:AddArchive(m, body)
local info = Mail.taken[Pair(m)]
local e = {
t = time(), sender = m.sender, subject = m.subject, body = body or "",
money = info and info.money or 0, items = info and info.items or {},
char = UnitName and UnitName("player") or "?",
}
table.insert(self.db.archive, 1, e)
while #self.db.archive > MAX_ARCHIVE do table.remove(self.db.archive) end
FW:Fire("MAIL_ARCHIVE_CHANGED")
end
-- Archiv-Eintraege nach der eingestellten Zeit loeschen (0 = nie)
function Mail:Purge()
local days = tonumber(self.db.settings.archiveDays) or 0
if days <= 0 then return 0 end
local limit = time() - days * 86400
local a, n = self.db.archive, 0
for i = #a, 1, -1 do
if (a[i].t or 0) < limit then table.remove(a, i) n = n + 1 end
end
if n > 0 then FW:Fire("MAIL_ARCHIVE_CHANGED") end
return n
end
-- Leeren Brief entfernen: erst ins Archiv (ausser unseren eigenen Textlosen), dann loeschen.
-- Rueckgabe: true, "archived"|"own" oder false, Grund
function Mail:RemoveMail(m)
if not Mail.Deletable(m) then return false, "notempty" end
local body = Mail.ReadText(m.i)
if body:find("FW%d+:[a-z0-9]+:%x+:") then return false, "data" end -- Freewood-Daten: bleiben fuer die Annahme
local own = self:IsOwnSent(m.subject, body)
if not own then self:AddArchive(m, body) end
pcall(DeleteInboxItem, m.i)
Mail.taken[Pair(m)] = nil
return true, own and "own" or "archived"
end
-- Kennung der Brett-Briefe (vom Freewood-Briefkasten geschrieben): gruen darstellen
local GREEN = "|cff6cc070"
-- Kennung steht vorn, bei Nachnahme steht "(COD) " davor
local function BoardTagPos(s)
if type(s) ~= "string" then return nil end
local a, b = s:find("[Brett] ", 1, true)
if a and a <= 12 then return a, b end
return nil
end
function Mail.IsBoardSubject(s)
return BoardTagPos(s) ~= nil
end
function Mail.ColorSubject(s)
s = tostring(s or "")
local a, b = BoardTagPos(s)
if a then return s:sub(1, a - 1) .. GREEN .. "[Brett]|r " .. s:sub(b + 1) end
return s
end
-- Nachnahme-Briefe werden NIE geloescht: auch ueber das Blizzard-Fenster wird "Loeschen" zu "An Absender zurueck"
if not Mail.deleteWrapped and _G.DeleteInboxItem and _G.ReturnInboxItem and _G.GetInboxHeaderInfo then
Mail.deleteWrapped = true
local origDelete = _G.DeleteInboxItem
_G.DeleteInboxItem = function(i, ...)
if not Mail.allowDelete then
local ok, _, _, sender, _, _, cod, _, hasItem = pcall(_G.GetInboxHeaderInfo, i)
if ok and tonumber(cod) and cod > 0 then
pcall(_G.ReturnInboxItem, i)
if FW.Print and FW.L.MAIL_COD_RETURNED then FW:Print(FW.L.MAIL_COD_RETURNED) end
return
end
end
return origDelete(i, ...)
end
end
function Mail.ColorBody(text)
text = tostring(text or "")
if text:sub(1, 16) ~= "[Freewood Brett]" then return text end
local out, data, first = {}, false, true
for line in (text .. "\n"):gmatch("(.-)\n") do
if first then
first = false
out[#out + 1] = GREEN .. FW.L.MAIL_BOARD_TAG .. "|r"
elseif not data and not line:find("FW%d+:") then
out[#out + 1] = GREEN .. line .. "|r"
else
data = true
out[#out + 1] = line
end
end
return table.concat(out, "\n")
end
-- Brett-Brief: Ein Brett-Auftrag bereitet einen Brief vor (Creator befuellt das verdeckte Briefformular).
-- Mit ersetztem Briefkasten zeigt unser Fenster den Stand und sendet selbst, sobald alles stimmt.
function Mail.BoardLetter()
local C = FW.Creator
if not (C and C.GuardState and C.MailCheck) then return nil end
local ok, st = pcall(C.GuardState)
if not ok or not st or st == "off" then return nil end
local ok2, list, all = pcall(C.MailCheck)
if not ok2 then list, all = nil, false end
return { state = st, list = list or {}, all = all and true or false }
end
function Mail:BoardSend()
local bl = Mail.BoardLetter()
if not bl or bl.state ~= "armed" or not bl.all then return false end
local b = _G.SendMailMailButton
if not (b and b.Click) then return false end
if b.IsEnabled and not b:IsEnabled() then return false end
self.boardSending = true
Trace("Brett-Brief: Senden ueber den Freewood-Briefkasten")
pcall(b.Click, b)
return true
end
-- Automatisch senden, wenn alles stimmt (Einstellung "boardAuto"), hoechstens 3 Versuche je Oeffnung
function Mail:BoardAuto()
if not (self.open and self.Replacing() and self.db.settings.boardAuto) then return end
if self.boardAutoAt or (self.boardTries or 0) >= 3 then return end
local bl = Mail.BoardLetter()
if not bl or bl.state ~= "armed" or not bl.all then return end
self.boardAutoAt = true
After(0.8, function()
Mail.boardAutoAt = nil
if Mail.open and Mail.Replacing() then
Mail.boardTries = (Mail.boardTries or 0) + 1
Mail:BoardSend()
end
end)
end
-- Brief an den Absender zurueckschicken (Anhang und Gold gehen mit zurueck). Nicht moeglich bei System-/Auktionsbriefen.
function Mail.Returnable(m)
if not m or not m.canReply or m.returned then return false end
local s = tostring(m.sender or "")
if s == "" or s == "?" or Mail.IsSelf(s) then return false end
return true
end
function Mail:ReturnMail(m)
if self.busy then return false, "busy" end
if not Mail.Returnable(m) then return false, "noreturn" end
if not ReturnInboxItem then return false, "noreturn" end
local ok = pcall(ReturnInboxItem, m.i)
if not ok then return false, "noreturn" end
Mail.taken[Pair(m)] = nil
return true
end
function Mail:FindMail(key, pair)
local list = Mail.ReadInbox()
for _, m in ipairs(list) do
if m.key == key then return m end
end
if pair then
for _, m in ipairs(list) do
if Pair(m) == pair then return m end
end
end
end
-- Inhalt eines einzelnen Briefs nehmen (erst Gold, dann Item). Nachnahme zahlt der Spieler mit einem zweiten Klick (UI).
function Mail:TakeOne(m)
if self.busy then return false, "busy" end
if m.cod > 0 then
local B = FW.Brett
if B and B.InspectMail then
local ok, st = pcall(B.InspectMail, m.i)
if ok and st == "high" then return false, "codbad" end
if ok and st == "nodeal" then return false, "codnodeal" end
if ok and st == "taken" then return false, "codtaken" end
end
if ((GetMoney and GetMoney()) or 0) < m.cod then return false, "codmoney" end
end
if m.money > 0 and m.cod == 0 then
NoteTaken(m, "money")
pcall(TakeInboxMoney, m.i)
return true
elseif m.hasItem then
if Mail.FreeSlots() <= KEEP_FREE then return false, "full" end
NoteTaken(m, "item")
if m.cod > 0 and FW.Brett and FW.Brett.NoteDelivered then pcall(FW.Brett.NoteDelivered, m.sender, m.subject) end
pcall(TakeInboxItem, m.i)
return true
end
return false, "empty"
end
Mail.selected = {} -- key -> true
function Mail:SelectedCount()
local n = 0
for _ in pairs(self.selected) do n = n + 1 end
return n
end
function Mail:ToggleSelect(key, on)
if on == nil then on = not self.selected[key] end
self.selected[key] = on and true or nil
FW:Fire("MAIL_SELECTION_CHANGED")
end
function Mail:SelectAll(on)
self.selected = {}
if on then
for _, m in ipairs(Mail.ReadInbox()) do
if m.take then self.selected[m.key] = true end
end
end
FW:Fire("MAIL_SELECTION_CHANGED")
end
-- onlySelected: nur angehakte Briefe
function Mail:StartOpen(onlySelected)
if self.busy or not self.open then return false end
local only
if onlySelected then
only = {}
for k in pairs(self.selected) do only[k] = true end
if next(only) == nil then return false end
end
self.run = { only = only, tries = {}, money = 0, items = 0, pending = nil, removed = 0, archived = 0, touched = {} }
self.busy = "inbox"
self.driver:Show()
Trace("Posteingang: Start" .. (only and " (nur Ausgewaehlte)" or " (alle)"))
FW:Fire("MAIL_STATE")
return true
end
local function FinishOpen(self, reason)
local run = self.run
self.run, self.busy = nil, nil
if not run then return end
local skipped = 0
if run.full and reason == "done" then
for _, m in ipairs(Mail.ReadInbox()) do
if m.take and m.hasItem and (not run.only or run.only[m.key]) then skipped = skipped + 1 end
end
end
Trace(string.format("Posteingang: Ende (%s), %d Gold-Briefe, %d Items, %d Items liegen geblieben", reason, run.money, run.items, skipped))
if reason == "stopped" or reason == "closed" then
Say(string.format(FW.L.MAIL_RUN_STOPPED, run.money, run.items))
else
Say(string.format(FW.L.MAIL_RUN_DONE, run.money, run.items, skipped > 0 and string.format(FW.L.MAIL_RUN_SKIP_FULL, skipped) or ""))
end
if run.removed > 0 then Say(string.format(FW.L.MAIL_RUN_CLEANED, run.removed, run.archived)) end
self.lastRun = { money = run.money, items = run.items, skipped = skipped, removed = run.removed }
self.selected = {}
FW:Fire("MAIL_STATE")
FW:Fire("MAIL_SELECTION_CHANGED")
FW:Fire("MAIL_INBOX_CHANGED")
end
function Mail:Stop()
if self.busy == "inbox" and self.run then FinishOpen(self, "stopped") end
if self.busy == "send" and self.send then self:FinishSend("stopped") end
end
function Mail:StepOpen()
local run = self.run
local now = Now()
if not self.open then return FinishOpen(self, "closed") end
if run.pending then
-- auf die Antwort des Servers warten (oder den Ablauf der Zeit)
if not ((run.updated and now >= run.pending + INBOX_GAP) or now >= run.pending + INBOX_TIMEOUT) then return end
run.pending = nil
end
local list = Mail.ReadInbox()
local free = Mail.FreeSlots()
for idx = #list, 1, -1 do
local m = list[idx]
if m.take and (not run.only or run.only[m.key]) and (run.tries[m.sig] or 0) < 3 then
if m.money > 0 then
run.tries[m.sig] = (run.tries[m.sig] or 0) + 1
run.money = run.money + 1
run.touched[Pair(m)] = true
NoteTaken(m, "money")
run.pending, run.updated = now, false
pcall(TakeInboxMoney, m.i)
Trace(string.format("Brief %d von %s: Gold %d genommen", m.i, m.sender, m.money))
FW:Fire("MAIL_STATE")
return
elseif m.hasItem then
if free <= KEEP_FREE then
run.full = true
else
run.tries[m.sig] = (run.tries[m.sig] or 0) + 1
run.items = run.items + 1
run.touched[Pair(m)] = true
NoteTaken(m, "item")
run.pending, run.updated = now, false
pcall(TakeInboxItem, m.i)
Trace(string.format("Brief %d von %s: Item %s genommen (frei vorher %d)", m.i, m.sender, tostring(m.itemName), free))
FW:Fire("MAIL_STATE")
return
end
end
end
end
-- nichts mehr zu nehmen: leere Briefe entfernen (mit Text und fremde kommen vorher ins Archiv)
if self.db.settings.cleanup then
for idx = #list, 1, -1 do
local m = list[idx]
local key = "del|" .. m.sig
if Mail.Deletable(m) and (not run.only or run.touched[Pair(m)]) and (run.tries[key] or 0) < 2 then
run.tries[key] = (run.tries[key] or 0) + 1
local ok, how = self:RemoveMail(m)
if ok then
run.removed = run.removed + 1
if how == "archived" then run.archived = run.archived + 1 end
run.pending, run.updated = now, false
FW:Fire("MAIL_STATE")
return
end
end
end
end
FinishOpen(self, "done")
end
------------------------------------------------------------------------
-- Senden (gemeinsame Warteschlange)
------------------------------------------------------------------------
local function AttachedName()
if not GetSendMailItem then return nil end
local ok, name = pcall(GetSendMailItem, 1)
if ok and name and name ~= "" then return name end
return nil
end
-- Anhang-Platz leeren (zurueck in die Tasche)
local function ClearAttachment()
if AttachedName() and ClickSendMailItemButton then
pcall(ClickSendMailItemButton)
if CursorHasItem and CursorHasItem() then pcall(ClearCursor) end
end
end
local function FindStack(item)
local fallback
for _, s in ipairs(Mail.BagStacks()) do
if s.id == item.id and not s.locked then
if s.count == item.count then return s end
fallback = fallback or s
end
end
return fallback
end
-- Bereitet einen Brief vor. Rueckgabe: true oder false, Grund
local function Prepare(job)
if CursorHasItem and CursorHasItem() then return false, "cursor" end
ClearAttachment()
if job.item then
local s = FindStack(job.item)
if not s then return false, "gone" end
pcall(ClearCursor)
pcall(PickupContainerItem, s.bag, s.slot)
if not (CursorHasItem and CursorHasItem()) then return false, "pickup" end
if not ClickSendMailItemButton then pcall(ClearCursor) return false, "noapi" end
pcall(ClickSendMailItemButton)
if not AttachedName() then
pcall(ClearCursor)
return false, "attach"
end
end
if SetSendMailMoney then pcall(SetSendMailMoney, (job.cod or 0) > 0 and 0 or (job.money or 0)) end
if SetSendMailCOD then pcall(SetSendMailCOD, job.cod or 0) end
return true
end
local function JobLabel(job)
if job.item then
local info = Util.GetItem and Util.GetItem(job.item.id)
return (info and info.name) or ("#" .. tostring(job.item.id))
end
return Mail.Money(job.money or 0)
end
-- jobs: { to, subject, body, item = {id,count}, money, cod }
function Mail:StartSend(jobs)
if self.busy then return false, "busy" end
if not self.open then return false, "closed" end
if Mail.BoardBusy() then return false, "board" end
if not jobs or #jobs == 0 then return false, "empty" end
self.send = { jobs = jobs, i = 0, sent = 0, failed = 0, stage = "next", nextAt = 0 }
self.busy = "send"
self.driver:Show()
Trace(string.format("Senden: Start mit %d Briefen", #jobs))
FW:Fire("MAIL_STATE")
return true
end
function Mail:FinishSend(reason)
local s = self.send
self.send, self.busy = nil, nil
if not s then return end
pcall(ClearCursor)
if reason ~= "done" then ClearAttachment() end
Trace(string.format("Senden: Ende (%s), %d von %d gesendet, %d uebersprungen", reason, s.sent, #s.jobs, s.failed))
if reason == "done" then
Say(string.format(FW.L.MAIL_SEND_DONE, s.sent, #s.jobs, s.failed > 0 and string.format(FW.L.MAIL_SEND_DONE_FAIL, s.failed) or ""))
elseif reason == "timeout" then
Say(FW.L.MAIL_SEND_FAIL_TIMEOUT)
elseif reason == "error" then
Say(FW.L.MAIL_SEND_FAIL_ERROR)
else
Say(string.format(FW.L.MAIL_SEND_STOPPED, s.sent, #s.jobs))
end
FW:Fire("MAIL_STATE")
FW:Fire("MAIL_SENT_DONE", s.sent, #s.jobs)
end
function Mail:StepSend()
local s = self.send
local now = Now()
if not self.open then return self:FinishSend("closed") end
if Mail.BoardBusy() then return self:FinishSend("board") end
if s.stage == "wait" then
if s.ok then
-- Erfolg: Tempo langsam wieder erhoehen (Pause kleiner), solange der Server mitkommt
s.streak = (s.streak or 0) + 1
if s.streak >= 3 then s.streak = 0 Mail.gap = math.max(GAP_MIN, Mail.gap * 0.8) end
s.retry = 0
s.stage, s.nextAt = "next", now + Mail.gap
elseif s.err then
-- Der Server hat den Brief abgelehnt (meist zu schnell): Pause verdoppeln und denselben Brief nochmal versuchen
s.retry = (s.retry or 0) + 1
if s.retry > MAX_RETRY then return self:FinishSend("error") end
s.streak = 0
Mail.gap = math.min(GAP_MAX, Mail.gap * 2 + 0.3)
Trace(string.format("Server lehnt ab: Pause jetzt %.2f s, Versuch %d von %d fuer Brief %d", Mail.gap, s.retry, MAX_RETRY, s.i))
if not s.slowSaid then s.slowSaid = true Say(FW.L.MAIL_SPEED_ADAPT) end
pcall(ClearCursor)
ClearAttachment()
s.i = s.i - 1
s.err, s.stage, s.nextAt = false, "next", now + Mail.gap + 0.5
return
elseif now > s.deadline then
return self:FinishSend("timeout")
end
return
end
if now < s.nextAt then return end
if CursorHasItem and CursorHasItem() then return end
s.i = s.i + 1
local job = s.jobs[s.i]
if not job then return self:FinishSend("done") end
s.current = job
local ok, why = Prepare(job)
if not ok then
s.failed = s.failed + 1
s.nextAt = now + 0.2
Trace(string.format("Brief %d (%s) nicht vorbereitet: %s", s.i, JobLabel(job), tostring(why)))
if why == "attach" or why == "gone" or why == "pickup" then
Say(string.format(FW.L.MAIL_SEND_FAIL_ATTACH, JobLabel(job)))
end
FW:Fire("MAIL_STATE")
return
end
s.ok, s.err, s.deadline, s.stage = false, false, now + SEND_TIMEOUT, "wait"
Trace(string.format("Brief %d von %d an %s: %s (Betreff '%s', Nachnahme %d)", s.i, #s.jobs, job.to, JobLabel(job), tostring(job.subject), job.cod or 0))
FW:Fire("MAIL_STATE")
local okc = pcall(SendMail, job.to, job.subject or "", job.body or "")
if not okc then s.err = true end
end
-- Fortschritt fuer die Anzeige: i, n, an wen
function Mail:SendProgress()
local s = self.send
if not s then return nil end
local job = s.current or s.jobs[1]
return math.min(s.i, #s.jobs), #s.jobs, job and job.to or "?"
end
------------------------------------------------------------------------
-- Briefe zusammenstellen
------------------------------------------------------------------------
local function Stamp() return date and date("%d.%m. %H:%M") or "" end
-- Eingabe des Senden-Reiters: form = { to, subject, body, picks = { {id,count}... }, money, cod = bool }
function Mail:BuildCompose(form)
local to = Mail.CleanName(form.to)
if to == "" then return nil, "to" end
if IsSelf(to) then return nil, "self" end
local picks = form.picks or {}
local money = math.floor(tonumber(form.money) or 0)
-- Ein reiner Textbrief (ohne Anhang und Gold) ist erlaubt; leer ist nur, wer weder Betreff noch Text noch Anhang hat
if #picks == 0 and money <= 0 and Trim(form.subject) == "" and Trim(form.body) == "" then return nil, "nothing" end
if form.cod and #picks == 0 then return nil, "codItem" end
if form.cod and money <= 0 then return nil, "codMoney" end
local base = Trim(form.subject)
local jobs = {}
local n = math.max(#picks, 1)
for i = 1, n do
local pick = picks[i]
local subject = base
if subject == "" then
local info = pick and Util.GetItem and Util.GetItem(pick.id)
subject = (info and info.name) or ""
if subject == "" then
-- Textbrief ohne Betreff: erste Zeile des Textes
local first = Trim(tostring(form.body or ""):match("^[^\r\n]*") or "")
if #first > 40 then first = first:sub(1, 40) end
subject = first ~= "" and first or FW.L.MAIL_SUBJECT_DEFAULT
end
end
if n > 1 then subject = string.format(FW.L.MAIL_SUBJECT_ITEM_N, subject, i, n) end
local job = { to = to, subject = subject, body = form.body or "", item = pick }
if form.cod then
job.cod = money
elseif i == 1 and money > 0 then
job.money = money
end
jobs[#jobs + 1] = job
end
if #jobs > MAX_JOBS then return nil, "many" end
return jobs
end
-- Eintrag der Mailingliste -> Briefe. reserved: Items fuer Brett-Auftraege (werden nicht gesendet)
function Mail:BuildList(entry, reserved)
local jobs = {}
if not entry or IsSelf(entry.name) then return jobs end
reserved = reserved or Mail.Reserved()
local stacks = Mail.BagStacks()
local picks = {}
for _, id in ipairs(entry.ids or {}) do
if not reserved[id] then
for _, s in ipairs(stacks) do
if s.id == id and not s.locked then picks[#picks + 1] = { id = id, count = s.count } end
end
end
end
local surplus = 0
if entry.gold then
local have = (GetMoney and GetMoney()) or 0
surplus = have - (entry.keep or 0) - (#picks + 1) * POSTAGE
if surplus < 100 then surplus = 0 end
end
for _, p in ipairs(picks) do jobs[#jobs + 1] = { to = entry.name, item = p } end
if surplus > 0 then jobs[#jobs + 1] = { to = entry.name, money = surplus } end
local n, stamp = #jobs, Stamp()
for i, job in ipairs(jobs) do
job.subject = n > 1 and string.format(FW.L.MAIL_SUBJECT_N, stamp, i, n) or string.format(FW.L.MAIL_SUBJECT_STAMP, stamp)
job.body = ""
end
return jobs
end
function Mail:ListFor(name)
for _, e in ipairs(self.db.lists) do
if e.name:lower() == (name or ""):lower() then return e end
end
end
function Mail:AddList(name)
name = Mail.CleanName(name)
if name == "" or name:find("[^%w\128-\255%-]") then return false, "invalid" end
if #self.db.lists >= MAX_LISTS then return false, "full" end
if self:ListFor(name) then return false, "exists" end
self.db.lists[#self.db.lists + 1] = { name = name, ids = {}, auto = false, gold = false, keep = 0 }
FW:Fire("MAIL_LISTS_CHANGED")
return true
end
function Mail:RemoveList(entry)
for i, e in ipairs(self.db.lists) do
if e == entry then table.remove(self.db.lists, i) break end
end
FW:Fire("MAIL_LISTS_CHANGED")
end
function Mail:ToggleListItem(entry, id)
for i, v in ipairs(entry.ids) do
if v == id then table.remove(entry.ids, i) FW:Fire("MAIL_LISTS_CHANGED") return false end
end
if #entry.ids >= MAX_ITEMS_PER_LIST then return nil end
entry.ids[#entry.ids + 1] = id
FW:Fire("MAIL_LISTS_CHANGED")
return true
end
function Mail:HasListItem(entry, id)
for _, v in ipairs(entry.ids) do
if v == id then return true end
end
return false
end
-- Eine Liste (entry) oder alle (entry = nil) jetzt senden. auto: nur Listen mit Haken
function Mail:SendLists(entry, autoOnly)
local reserved = Mail.Reserved()
local jobs = {}
for _, e in ipairs(self.db.lists) do
if (entry == nil or entry == e) and (not autoOnly or e.auto) then
local part = self:BuildList(e, reserved)
if #part == 0 and entry == e then Say(string.format(FW.L.MAIL_SEND_NOTHING_LIST, e.name)) end
for _, j in ipairs(part) do jobs[#jobs + 1] = j end
end
end
if #jobs == 0 then return false, "empty" end
return self:StartSend(jobs)
end
-- Automatik: kurz nach dem Oeffnen, aber erst wenn Brett-Auftraege und andere Aktionen fertig sind
function Mail:ScheduleAuto()
local any
for _, e in ipairs(self.db.lists) do if e.auto then any = true end end
if not any then return end
local gen, tries, told = self.gen, 0, false
local function Try()
if not Mail.open or Mail.gen ~= gen then return end
if Mail.busy or Mail.BoardBusy() then
tries = tries + 1
if Mail.BoardBusy() and not told then
told = true
Say(FW.L.MAIL_AUTO_WAIT_BOARD)
end
if tries < 60 then After(1.5, Try) end
return
end
Mail:SendLists(nil, true)
end
After(AUTO_DELAY, Try)
end
------------------------------------------------------------------------
-- Normales Fenster ausblenden (es bleibt technisch offen, damit Brett-Auftraege weiter funktionieren)
------------------------------------------------------------------------
function Mail.Replacing()
return Mail.db and Mail.db.settings.replace and true or false
end
function Mail.Anchor()
local w = Mail.window
if Mail.Replacing() and w and w:IsShown() then return w end
return nil
end
function Mail:ApplyReplace()
local f = _G.MailFrame
if not f then return end
if self.Replacing() and not self.uiFailed then
if not self.hidden then
self.hidden = { alpha = f:GetAlpha(), scale = f:GetScale() }
end
f:SetAlpha(0)
f:SetScale(0.01)
elseif self.hidden then
f:SetAlpha(self.hidden.alpha or 1)
f:SetScale(self.hidden.scale or 1)
self.hidden = nil
end
end
function Mail:SetReplace(on)
self.db.settings.replace = on and true or false
self.uiFailed = nil
self:ApplyReplace()
FW:Fire("MAIL_REPLACE_CHANGED")
if self.open then
FW:Fire(on and "MAIL_WINDOW_SHOW" or "MAIL_WINDOW_HIDE")
end
end
------------------------------------------------------------------------
-- Ereignisse
------------------------------------------------------------------------
function Mail:OnMailShow()
pcall(self.Purge, self)
self.open = true
self.gen = self.gen + 1
self.boardTries, self.boardAutoAt, self.boardSending = 0, nil, nil
self.selected = {}
self:ApplyReplace()
Trace("Briefkasten geoeffnet")
if self.Replacing() then FW:Fire("MAIL_WINDOW_SHOW") end
FW:Fire("MAIL_INBOX_CHANGED")
self:ScheduleAuto()
end
function Mail:OnMailClosed()
if not self.open then return end
self.open = false
if self.busy == "inbox" then FinishOpen(self, "closed") end
if self.busy == "send" then self:FinishSend("closed") end
self.busy = nil
FW:Fire("MAIL_WINDOW_HIDE")
end
function Mail:OnInboxUpdate()
if self.run then self.run.updated = true end
FW:Fire("MAIL_INBOX_CHANGED")
end
function Mail:OnSendSuccess()
if self.boardSending then
self.boardSending = nil
local C = FW.Creator
if C then C.mailChoice = nil end -- Brett-Brief ist raus: unser Fenster ist wieder frei
Say(FW.L.MAIL_BOARD_SENT)
FW:Fire("MAIL_STATE")
end
local s = self.send
if self.lastTo then
self:AddRecipient(self.lastTo)
self.lastTo = nil
end
if s and s.stage == "wait" then
s.ok = true
s.sent = s.sent + 1
FW:Fire("MAIL_STATE")
end
end
function Mail:OnSendFailed()
local s = self.send
if s and s.stage == "wait" then s.err = true end
end
function Mail:OnEnable()
self.driver = self.driver or CreateFrame("Frame")
local acc = 0
self.driver:SetScript("OnUpdate", function(_, elapsed)
acc = acc + (elapsed or 0)
if acc < STEP then return end
acc = 0
local ok, err = pcall(function()
if Mail.busy == "inbox" and Mail.run then Mail:StepOpen()
elseif Mail.busy == "send" and Mail.send then Mail:StepSend()
else Mail.driver:Hide() end
end)
if not ok then
Trace("Fehler im Takt: " .. tostring(err))
Mail.run, Mail.send, Mail.busy = nil, nil, nil
Mail.driver:Hide()
FW:Fire("MAIL_STATE")
end
end)
self.driver:Hide()
FW:RegisterEvent("MAIL_SHOW", function() pcall(Mail.OnMailShow, Mail) end, self)
FW:RegisterEvent("MAIL_CLOSED", function() pcall(Mail.OnMailClosed, Mail) end, self)
FW:RegisterEvent("MAIL_INBOX_UPDATE", function() pcall(Mail.OnInboxUpdate, Mail) end, self)
FW:RegisterEvent("MAIL_SEND_SUCCESS", function() pcall(Mail.OnSendSuccess, Mail) end, self)
FW:RegisterEvent("MAIL_FAILED", function() pcall(Mail.OnSendFailed, Mail) end, self)
FW:On("CREATOR_MAIL_UPDATE", function() pcall(Mail.BoardAuto, Mail) end, self)
if not self.hooked then
self.hooked = true
-- Empfaenger merken, egal wer sendet (auch das normale Fenster und Brett-Auftraege)
if hooksecurefunc and _G.SendMail then
pcall(hooksecurefunc, "SendMail", function(to, subject, body)
if Mail.db then
Mail.lastTo = to
pcall(Mail.NoteSent, Mail, subject, body)
end
end)
end
if _G.MailFrame and MailFrame.HookScript then
pcall(MailFrame.HookScript, MailFrame, "OnShow", function() pcall(Mail.ApplyReplace, Mail) end)
-- das Ereignis MAIL_CLOSED kommt nicht immer; das Verschwinden des Fensters ist sicher
pcall(MailFrame.HookScript, MailFrame, "OnHide", function() pcall(Mail.OnMailClosed, Mail) end)
end
end
After(3, function() pcall(Mail.Purge, Mail) end)
-- Briefkasten schon offen (z. B. nach /reload)?
if _G.MailFrame and MailFrame.IsShown and MailFrame:IsShown() then
After(0.5, function() pcall(Mail.OnMailShow, Mail) end)
end
end
function Mail:OnDisable()
for _, ev in ipairs({ "MAIL_SHOW", "MAIL_CLOSED", "MAIL_INBOX_UPDATE", "MAIL_SEND_SUCCESS", "MAIL_FAILED" }) do
FW:UnregisterEvent(ev, self)
end
pcall(FW.Off, FW, "CREATOR_MAIL_UPDATE", self)
if self.driver then self.driver:Hide() end
self.run, self.send, self.busy = nil, nil, nil
if self.hidden then
local f = _G.MailFrame
if f then f:SetAlpha(self.hidden.alpha or 1) f:SetScale(self.hidden.scale or 1) end
self.hidden = nil
end
end