mirror of
https://github.com/TheRealFayz/AutoSpam.git
synced 2026-11-14 23:20:51 +00:00
Chat messages are still capped at the standard 255 characters. Only macros got the longer 511 limit, so messages and auto-replies go back to 255.
1446 lines
54 KiB
Lua
1446 lines
54 KiB
Lua
-- AutoSpam: core logic (data, timer, posting, AFK, auto-invite, links)
|
|
-- WoW 1.12 / Turtle WoW, Lua 5.0
|
|
|
|
AutoSpam = {}
|
|
|
|
AutoSpam.CHANNELS = {
|
|
{ text = "Say", value = "SAY" },
|
|
{ text = "Yell", value = "YELL" },
|
|
{ text = "Guild", value = "GUILD" },
|
|
{ text = "Officer", value = "OFFICER" },
|
|
{ text = "Party", value = "PARTY" },
|
|
{ text = "Raid", value = "RAID" },
|
|
{ text = "World", value = "WORLD" },
|
|
{ text = "Custom Channel", value = "CHANNEL" },
|
|
}
|
|
|
|
local WORLD_CHANNELS = { "World", "world", "LookingForGroup", "LFG" }
|
|
local INVITE_COOLDOWN = 10 -- seconds before the same player can trigger another invite
|
|
local DEFAULT_KEYWORDS = "inv, invite, +1"
|
|
AutoSpam.MAX_LEN = 255 -- longest message the editor and sender allow (standard chat limit)
|
|
local CHANNEL_GAP = 1 -- seconds between channels when a message posts to several
|
|
local RESUME_DELAY = 120 -- seconds before posting turns itself off after logging in while still running
|
|
local REPLY_COOLDOWN = 300 -- seconds before the same player gets the auto-reply again
|
|
AutoSpam.REPLY_MAX = AutoSpam.MAX_LEN -- auto-reply allows the same length as a message
|
|
AutoSpam.GITHUB_URL = "https://github.com/TheRealFayz/AutoSpam"
|
|
local STAR_MILESTONE = 100 -- live posts, across all characters, before the one-time GitHub star ask
|
|
local STAR_LOGIN_WAIT = 60 -- never show the star ask in the first minute after logging in
|
|
local STAR_CHECK_EVERY = 5 -- seconds between "is now a good time?" checks
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Helpers
|
|
---------------------------------------------------------------------------
|
|
|
|
local function Print(msg, r, g, b)
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffffd100AutoSpam:|r " .. msg, r or 1, g or 1, b or 1)
|
|
end
|
|
AutoSpam.Print = Print
|
|
|
|
local function Trim(s)
|
|
if not s then return "" end
|
|
local r = string.gsub(s, "^%s*(.-)%s*$", "%1")
|
|
return r
|
|
end
|
|
AutoSpam.Trim = Trim
|
|
|
|
function AutoSpam:ChannelLabel(channel, customChannel)
|
|
if channel == "CHANNEL" then
|
|
local c = Trim(customChannel)
|
|
if c ~= "" then return c end
|
|
return "Custom"
|
|
end
|
|
for i = 1, table.getn(self.CHANNELS) do
|
|
if self.CHANNELS[i].value == channel then
|
|
return self.CHANNELS[i].text
|
|
end
|
|
end
|
|
return "Say"
|
|
end
|
|
|
|
-- Checked channels in menu order, as a list of values
|
|
function AutoSpam:CheckedChannels(channels)
|
|
local list = {}
|
|
if not channels then return list end
|
|
for i = 1, table.getn(self.CHANNELS) do
|
|
local v = self.CHANNELS[i].value
|
|
if channels[v] then table.insert(list, v) end
|
|
end
|
|
return list
|
|
end
|
|
|
|
-- "Say, World" style label for a set of channels
|
|
function AutoSpam:ChannelsLabel(channels, customChannel)
|
|
local list = self:CheckedChannels(channels)
|
|
if table.getn(list) == 0 then return "No channel" end
|
|
local names = {}
|
|
for i = 1, table.getn(list) do
|
|
table.insert(names, self:ChannelLabel(list[i], customChannel))
|
|
end
|
|
return table.concat(names, ", ")
|
|
end
|
|
|
|
-- Display version of message text: item links shown as their [Name]
|
|
function AutoSpam:PlainText(text)
|
|
if not text then return "" end
|
|
local r = string.gsub(text, "|c%x%x%x%x%x%x%x%x|H.-|h(.-)|h|r", "%1")
|
|
return r
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Saved variables
|
|
---------------------------------------------------------------------------
|
|
|
|
function AutoSpam:Initialize()
|
|
if not AutoSpamDB then AutoSpamDB = {} end
|
|
local db = AutoSpamDB
|
|
|
|
if not db.messages then db.messages = {} end
|
|
if not db.minimapPos then db.minimapPos = 180 end
|
|
if not db.interval then db.interval = 300 end
|
|
if db.debugMode == nil then db.debugMode = false end
|
|
if db.dryRun == nil then db.dryRun = false end
|
|
if not db.scale then db.scale = 1 end
|
|
if db.autoReply == nil then db.autoReply = false end
|
|
if not db.replyText then db.replyText = "" end
|
|
if not db.totalSent then db.totalSent = 0 end -- live posts ever, account wide
|
|
|
|
-- Bring messages saved by older versions up to the current format
|
|
for i = 1, table.getn(db.messages) do
|
|
local m = db.messages[i]
|
|
if not m.name or m.name == "" then m.name = "Message " .. i end
|
|
if not m.text then m.text = "" end
|
|
if not m.channel then m.channel = "SAY" end
|
|
-- Messages can post to several channels now; older ones had just one
|
|
if type(m.channels) ~= "table" then
|
|
m.channels = {}
|
|
m.channels[m.channel] = true
|
|
end
|
|
if not m.customChannel then m.customChannel = "" end
|
|
if not m.weight then m.weight = 1 end
|
|
m.weight = math.floor(m.weight + 0.5)
|
|
if m.weight < 1 then m.weight = 1 end
|
|
if not m.enabled then m.enabled = false end
|
|
if not m.autoInvite then m.autoInvite = false end
|
|
-- Older builds kept keywords and convert-to-raid globally; seed each message from them
|
|
if not m.inviteKeywords then m.inviteKeywords = db.inviteKeywords or DEFAULT_KEYWORDS end
|
|
if m.inviteConvert == nil then m.inviteConvert = (db.autoConvertRaid == true) end
|
|
end
|
|
|
|
self.db = db
|
|
self.lastPostTime = 0 -- GetTime() of the last post, 0 = never
|
|
self.timerStarted = false -- timer runs only after Start or Post now
|
|
self.sessionStart = 0
|
|
self.sentCount = 0
|
|
|
|
-- Still running at logout? Hold everything (no posts, no invites) and ask.
|
|
-- Unless the player picks Post now within RESUME_DELAY seconds, posting turns off.
|
|
self.resumed = db.enabled and true or false
|
|
self.resumeHold = false
|
|
if self.resumed then
|
|
db.enabled = true
|
|
self.resumeHold = true
|
|
self.resumeDeadline = GetTime() + RESUME_DELAY
|
|
else
|
|
db.enabled = false
|
|
end
|
|
self.nextMsg = nil
|
|
self.recentInvites = {}
|
|
self.inviteQueue = {}
|
|
self.sendQueue = {}
|
|
self.echoWatch = {}
|
|
self.addonQueue = {}
|
|
self.incoming = {}
|
|
self.outgoing = {}
|
|
self.offers = {}
|
|
self.recentReplies = {}
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Message management
|
|
---------------------------------------------------------------------------
|
|
|
|
function AutoSpam:FindByName(name)
|
|
for i = 1, table.getn(self.db.messages) do
|
|
if self.db.messages[i].name == name then
|
|
return self.db.messages[i]
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function AutoSpam:AddMessage(name)
|
|
name = Trim(name)
|
|
if name == "" then
|
|
Print("Enter a name for the new message.", 1, 0.3, 0.3)
|
|
return nil
|
|
end
|
|
if self:FindByName(name) then
|
|
Print("A message named '" .. name .. "' already exists.", 1, 0.3, 0.3)
|
|
return nil
|
|
end
|
|
local m = {
|
|
name = name, text = "", enabled = false,
|
|
channel = "SAY", channels = { SAY = true }, customChannel = "", weight = 1,
|
|
autoInvite = false, inviteKeywords = DEFAULT_KEYWORDS, inviteConvert = false,
|
|
}
|
|
table.insert(self.db.messages, m)
|
|
self:RefreshUI()
|
|
return m
|
|
end
|
|
|
|
function AutoSpam:DeleteMessage(m)
|
|
for i = 1, table.getn(self.db.messages) do
|
|
if self.db.messages[i] == m then
|
|
table.remove(self.db.messages, i)
|
|
Print("Removed '" .. m.name .. "'.", 1, 1, 0)
|
|
if self.OnMessageDeleted then self:OnMessageDeleted(m) end
|
|
self:ValidateNext()
|
|
self:RefreshUI()
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
function AutoSpam:MoveMessage(index, dir)
|
|
local list = self.db.messages
|
|
local other = index + dir
|
|
if not list[index] or not list[other] then return end
|
|
local tmp = list[index]
|
|
list[index] = list[other]
|
|
list[other] = tmp
|
|
self:RefreshUI()
|
|
end
|
|
|
|
function AutoSpam:SetMessageEnabled(m, enabled)
|
|
m.enabled = enabled and true or false
|
|
if self.db.debugMode then
|
|
Print("'" .. m.name .. "' " .. (m.enabled and "enabled" or "disabled") .. ".", 1, 1, 0)
|
|
end
|
|
self:ValidateNext()
|
|
self:RefreshUI()
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Weighted selection (the next message is picked ahead of time so the UI can show it)
|
|
---------------------------------------------------------------------------
|
|
|
|
function AutoSpam:IsEligible(m)
|
|
if not m or not m.enabled or not m.text or m.text == "" then return false end
|
|
for i = 1, table.getn(self.db.messages) do
|
|
if self.db.messages[i] == m then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function AutoSpam:RollNext()
|
|
local total = 0
|
|
local list = {}
|
|
for i = 1, table.getn(self.db.messages) do
|
|
local m = self.db.messages[i]
|
|
if self:IsEligible(m) then
|
|
table.insert(list, m)
|
|
total = total + (m.weight or 1)
|
|
end
|
|
end
|
|
if total <= 0 then
|
|
self.nextMsg = nil
|
|
return nil
|
|
end
|
|
local r = math.random(1, total)
|
|
for i = 1, table.getn(list) do
|
|
r = r - (list[i].weight or 1)
|
|
if r <= 0 then
|
|
self.nextMsg = list[i]
|
|
return list[i]
|
|
end
|
|
end
|
|
self.nextMsg = list[table.getn(list)]
|
|
return self.nextMsg
|
|
end
|
|
|
|
function AutoSpam:ValidateNext()
|
|
if not self:IsEligible(self.nextMsg) then
|
|
self:RollNext()
|
|
end
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Sending
|
|
---------------------------------------------------------------------------
|
|
|
|
-- Fill in {GROUP}, {ZONE} and friends at the moment a message posts
|
|
AutoSpam.PLACEHOLDERS = {
|
|
{ key = "GROUP", desc = "How many are in your group" },
|
|
{ key = "ZONE", desc = "The zone you're in" },
|
|
{ key = "TIME", desc = "Server time" },
|
|
{ key = "NAME", desc = "Your character's name" },
|
|
{ key = "GUILD", desc = "Your guild's name" },
|
|
}
|
|
|
|
function AutoSpam:PlaceholderValue(key)
|
|
if key == "GROUP" then
|
|
local raidN = GetNumRaidMembers()
|
|
if raidN > 0 then return tostring(raidN) end
|
|
return tostring(GetNumPartyMembers() + 1)
|
|
elseif key == "ZONE" then
|
|
return GetZoneText() or ""
|
|
elseif key == "TIME" then
|
|
local h, mins = GetGameTime()
|
|
local suffix = "AM"
|
|
if h >= 12 then suffix = "PM" end
|
|
local h12 = math.mod(h, 12)
|
|
if h12 == 0 then h12 = 12 end
|
|
return string.format("%d:%02d %s", h12, mins, suffix)
|
|
elseif key == "NAME" then
|
|
return UnitName("player") or ""
|
|
elseif key == "GUILD" then
|
|
return GetGuildInfo("player") or ""
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- {UTC Sunday 1300}: your local day/time converted to UTC
|
|
local DAYS = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" }
|
|
|
|
-- Day word in the same style it was typed: Sun, Sunday or Sundays, matching upper case
|
|
local function DayLike(word, typedIndex, newIndex)
|
|
local typedFull = string.lower(DAYS[typedIndex])
|
|
local full = DAYS[newIndex]
|
|
local lw = string.lower(word)
|
|
local out
|
|
if lw == typedFull then
|
|
out = full
|
|
elseif lw == typedFull .. "s" then
|
|
out = full .. "s"
|
|
else
|
|
out = string.sub(full, 1, 3)
|
|
end
|
|
if word == string.upper(word) then out = string.upper(out) end
|
|
return out
|
|
end
|
|
|
|
-- Reads "Sunday 1300", "Sun 1:00 PM", "1pm" or "13:00".
|
|
-- Returns a table describing it, or nil if it can't be read.
|
|
local function ParseDayTime(spec)
|
|
local s = Trim(spec)
|
|
local p = {}
|
|
local _, _, w, rest = string.find(s, "^(%a+)%s+(.+)$")
|
|
if w and string.find(rest, "^%d") then
|
|
local key = string.lower(string.sub(w, 1, 3))
|
|
for i = 1, 7 do
|
|
if string.lower(string.sub(DAYS[i], 1, 3)) == key then p.dayIndex = i end
|
|
end
|
|
if not p.dayIndex then return nil end
|
|
p.dayWord = w
|
|
s = rest
|
|
end
|
|
|
|
local h, m, a, b, c
|
|
_, _, a, b, c = string.find(s, "^(%d%d?):(%d%d)%s*([AaPp]%.?[Mm]%.?)$")
|
|
if a then
|
|
h, m, p.ampm, p.colon = a, b, c, true
|
|
else
|
|
_, _, a, b = string.find(s, "^(%d%d?):(%d%d)$")
|
|
if a then
|
|
h, m, p.colon = a, b, true
|
|
else
|
|
_, _, a, c = string.find(s, "^(%d%d?)%s*([AaPp]%.?[Mm]%.?)$")
|
|
if a then
|
|
h, p.ampm = a, c
|
|
else
|
|
_, _, a, b = string.find(s, "^(%d?%d)(%d%d)$")
|
|
if not a then return nil end
|
|
h, m = a, b
|
|
end
|
|
end
|
|
end
|
|
p.hadMinutes = m and true or false
|
|
p.spaceBeforeAmpm = string.find(s, "%s[AaPp]") and true or false
|
|
|
|
h = tonumber(h)
|
|
local mins = 0
|
|
if m then mins = tonumber(m) end
|
|
if mins > 59 then return nil end
|
|
if p.ampm then
|
|
if h < 1 or h > 12 then return nil end
|
|
if h == 12 then h = 0 end
|
|
if string.find(string.lower(p.ampm), "^p") then h = h + 12 end
|
|
elseif h > 23 then
|
|
return nil
|
|
end
|
|
p.hour, p.min = h, mins
|
|
return p
|
|
end
|
|
|
|
-- Writes a time back in the style it was typed
|
|
local function FormatLike(p, day, h, mins)
|
|
local out
|
|
if p.ampm then
|
|
local suffix = "AM"
|
|
if h >= 12 then suffix = "PM" end
|
|
if string.find(p.ampm, "%l") then suffix = string.lower(suffix) end
|
|
local h12 = math.mod(h, 12)
|
|
if h12 == 0 then h12 = 12 end
|
|
if p.hadMinutes or mins ~= 0 then
|
|
out = string.format("%d:%02d", h12, mins)
|
|
else
|
|
out = tostring(h12)
|
|
end
|
|
if p.spaceBeforeAmpm then out = out .. " " end
|
|
out = out .. suffix
|
|
elseif p.colon then
|
|
out = string.format("%02d:%02d", h, mins)
|
|
else
|
|
out = string.format("%02d%02d", h, mins)
|
|
end
|
|
if p.dayWord then out = DayLike(p.dayWord, p.dayIndex, day) .. " " .. out end
|
|
return out
|
|
end
|
|
|
|
-- Turns your local day and time into UTC. Works out the actual date it falls on
|
|
-- (the next one, if you gave a day), so a daylight saving change in between is
|
|
-- handled. Returns nil if the text can't be read or the client has no clock.
|
|
function AutoSpam:ConvertToUTC(spec)
|
|
if not date or not time then return nil end
|
|
local p = ParseDayTime(spec)
|
|
if not p then return nil end
|
|
|
|
local now = time()
|
|
local lt = date("*t", now)
|
|
if not lt or not lt.wday then return nil end
|
|
local ahead = 0
|
|
if p.dayIndex then
|
|
ahead = math.mod(p.dayIndex - lt.wday + 7, 7)
|
|
if ahead == 0 and p.hour * 60 + p.min < lt.hour * 60 + lt.min then ahead = 7 end
|
|
end
|
|
|
|
local t = time({ year = lt.year, month = lt.month, day = lt.day + ahead, hour = p.hour, min = p.min, sec = 0 })
|
|
if not t then return nil end
|
|
-- Check that moment really reads as the typed time on this PC; Lua 5.0 assumes
|
|
-- standard time, so during daylight saving it lands an hour off without this
|
|
local chk = date("*t", t)
|
|
local diff = (p.hour * 60 + p.min) - (chk.hour * 60 + chk.min)
|
|
if diff > 720 then diff = diff - 1440 end
|
|
if diff < -720 then diff = diff + 1440 end
|
|
t = t + diff * 60
|
|
|
|
local u = date("!*t", t)
|
|
if not u or not u.wday then return nil end
|
|
return FormatLike(p, u.wday, u.hour, u.min) .. " UTC"
|
|
end
|
|
|
|
function AutoSpam:ExpandText(text)
|
|
local out = string.gsub(text or "", "({[Uu][Tt][Cc]%s+([^{}]-)})", function(whole, spec)
|
|
return AutoSpam:ConvertToUTC(spec) or whole -- unreadable: leave it as typed
|
|
end)
|
|
out = string.gsub(out, "{(%a+)}", function(word)
|
|
local v = AutoSpam:PlaceholderValue(string.upper(word))
|
|
if v then return v end
|
|
return "{" .. word .. "}" -- not a placeholder, leave it alone
|
|
end)
|
|
return out
|
|
end
|
|
|
|
-- Sends one queued item to one channel
|
|
function AutoSpam:SendToChannel(item)
|
|
local text, ch = item.text, item.channel
|
|
if ch == "SAY" or ch == "YELL" or ch == "GUILD" or ch == "OFFICER" or ch == "PARTY" or ch == "RAID" then
|
|
SendChatMessage(text, ch)
|
|
return true
|
|
elseif ch == "CHANNEL" then
|
|
local name = Trim(item.custom)
|
|
if name == "" then
|
|
Print("'" .. item.name .. "' has no custom channel set.", 1, 0.3, 0.3)
|
|
return false
|
|
end
|
|
local lookup = tonumber(name) or name
|
|
local id = GetChannelName(lookup)
|
|
if id and id > 0 then
|
|
self:WatchEcho(item, name) -- before sending, in case the echo is instant
|
|
SendChatMessage(text, "CHANNEL", nil, id)
|
|
return true
|
|
end
|
|
Print("Channel '" .. name .. "' not found. Join it first.", 1, 0.3, 0.3)
|
|
return false
|
|
elseif ch == "WORLD" then
|
|
for i = 1, table.getn(WORLD_CHANNELS) do
|
|
local id = GetChannelName(WORLD_CHANNELS[i])
|
|
if id and id > 0 then
|
|
self:WatchEcho(item, WORLD_CHANNELS[i])
|
|
SendChatMessage(text, "CHANNEL", nil, id)
|
|
return true
|
|
end
|
|
end
|
|
Print("World channel not found.", 1, 0.3, 0.3)
|
|
return false
|
|
end
|
|
return false
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Post check: the server echoes every channel message back to the sender.
|
|
-- If ours doesn't come back within a few seconds, the server dropped it
|
|
-- (spam filter, mute, channel rule), so say so instead of failing silently.
|
|
---------------------------------------------------------------------------
|
|
|
|
local ECHO_WAIT = 6
|
|
|
|
local function Squash(t)
|
|
t = string.gsub(t or "", "%s+", " ")
|
|
return string.lower(Trim(t))
|
|
end
|
|
|
|
function AutoSpam:WatchEcho(item, chanName)
|
|
table.insert(self.echoWatch, {
|
|
key = Squash(item.text), name = item.name, chan = chanName, item = item,
|
|
deadline = GetTime() + ECHO_WAIT,
|
|
})
|
|
end
|
|
|
|
function AutoSpam:OnChannelEcho(text, author)
|
|
if author ~= UnitName("player") or table.getn(self.echoWatch) == 0 then return end
|
|
local key = Squash(text)
|
|
for i = 1, table.getn(self.echoWatch) do
|
|
local w = self.echoWatch[i]
|
|
-- Exact match, or the server trimmed or reworded the end slightly
|
|
if w.key == key or string.sub(w.key, 1, 40) == string.sub(key, 1, 40) then
|
|
table.remove(self.echoWatch, i)
|
|
if self.db.debugMode then
|
|
Print("'" .. w.name .. "' showed up in " .. w.chan .. ".", 0, 1, 1)
|
|
end
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
function AutoSpam:OnSystemDuringWatch(text)
|
|
if table.getn(self.echoWatch) > 0 and text and text ~= "" then
|
|
self.echoSystemMsg = text
|
|
end
|
|
end
|
|
|
|
function AutoSpam:CheckEchoes(now)
|
|
local i = 1
|
|
while self.echoWatch[i] do
|
|
local w = self.echoWatch[i]
|
|
if now >= w.deadline then
|
|
table.remove(self.echoWatch, i)
|
|
if not w.item.retried and self.db.enabled then
|
|
-- Dropped once: send it one more time
|
|
w.item.retried = true
|
|
Print("'" .. w.name .. "' didn't show up in " .. w.chan .. ". Sending it once more.", 1, 0.82, 0)
|
|
self:SendToChannel(w.item)
|
|
else
|
|
Print("'" .. w.name .. "' was sent twice but never showed up in " .. w.chan .. ". The server probably blocked it.", 1, 0.3, 0.3)
|
|
if self.echoSystemMsg then
|
|
Print("Server said: " .. self.echoSystemMsg, 1, 0.3, 0.3)
|
|
end
|
|
end
|
|
else
|
|
i = i + 1
|
|
end
|
|
end
|
|
if table.getn(self.echoWatch) == 0 then self.echoSystemMsg = nil end
|
|
end
|
|
|
|
function AutoSpam:SendText(m)
|
|
local text = self:ExpandText(m.text)
|
|
if string.len(text) > self.MAX_LEN then
|
|
Print("'" .. m.name .. "' runs over " .. self.MAX_LEN .. " characters once placeholders are filled in, so it was skipped. Shorten it a little.", 1, 0.3, 0.3)
|
|
return false
|
|
end
|
|
local list = self:CheckedChannels(m.channels)
|
|
if table.getn(list) == 0 then
|
|
Print("'" .. m.name .. "' has no channel picked.", 1, 0.3, 0.3)
|
|
return false
|
|
end
|
|
|
|
if self.db.dryRun then
|
|
for i = 1, table.getn(list) do
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff66ccff[AutoSpam dry run]|r |cffaaaaaa[" .. self:ChannelLabel(list[i], m.customChannel) .. "]|r " .. text)
|
|
end
|
|
if self.OnDryRunPost then self:OnDryRunPost() end
|
|
return true
|
|
end
|
|
|
|
-- One channel goes now, each extra one waits CHANNEL_GAP more seconds
|
|
local now = GetTime()
|
|
for i = 1, table.getn(list) do
|
|
table.insert(self.sendQueue, {
|
|
at = now + (i - 1) * CHANNEL_GAP,
|
|
text = text, channel = list[i], custom = m.customChannel, name = m.name,
|
|
})
|
|
end
|
|
self:ProcessSendQueue(now)
|
|
return true
|
|
end
|
|
|
|
function AutoSpam:ProcessSendQueue(now)
|
|
local q = self.sendQueue
|
|
while q[1] and now >= q[1].at do
|
|
local item = table.remove(q, 1)
|
|
self:SendToChannel(item)
|
|
end
|
|
end
|
|
|
|
function AutoSpam:SetDryRun(on)
|
|
self.db.dryRun = on and true or false
|
|
if self.db.dryRun then
|
|
Print("Dry run on. Posts print to your chat only.", 0.4, 0.8, 1)
|
|
else
|
|
Print("Dry run off. Posts go live.", 0, 1, 0)
|
|
end
|
|
self:RefreshUI()
|
|
end
|
|
|
|
function AutoSpam:PostNext()
|
|
self:ValidateNext()
|
|
local m = self.nextMsg
|
|
if not m then
|
|
Print("Nothing to post. Enable a message that has text.", 1, 0.3, 0.3)
|
|
self:RefreshUI()
|
|
return false
|
|
end
|
|
if self.db.debugMode then
|
|
Print("Posting '" .. m.name .. "'.", 0, 1, 1)
|
|
end
|
|
local ok = self:SendText(m)
|
|
if ok then
|
|
self.sentCount = self.sentCount + 1
|
|
if not self.db.dryRun then
|
|
self.db.totalSent = (self.db.totalSent or 0) + 1
|
|
-- The star ask itself waits for a quiet moment; see CheckStarAsk
|
|
end
|
|
end
|
|
self:RollNext()
|
|
self:RefreshUI()
|
|
return ok
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- One-time GitHub star ask. Once the account reaches STAR_MILESTONE live
|
|
-- posts it's due, but it only shows when the player isn't busy: not in
|
|
-- combat, a party, a raid or battleground, an instance, dead, or on a
|
|
-- flight, and not in the first minute after logging in. Until then it
|
|
-- keeps waiting, across logins if need be.
|
|
---------------------------------------------------------------------------
|
|
|
|
function AutoSpam:IsPlayerFree()
|
|
if UnitAffectingCombat and UnitAffectingCombat("player") then return false end
|
|
if GetNumRaidMembers() > 0 or GetNumPartyMembers() > 0 then return false end
|
|
if IsInInstance and IsInInstance() then return false end
|
|
if UnitIsDeadOrGhost and UnitIsDeadOrGhost("player") then return false end
|
|
if UnitOnTaxi and UnitOnTaxi("player") then return false end
|
|
if self.resumeHold then return false end
|
|
if self.offerPopup and self.offerPopup:IsShown() then return false end
|
|
if self.shareDialog and self.shareDialog:IsShown() then return false end
|
|
return true
|
|
end
|
|
|
|
function AutoSpam:CheckStarAsk(now)
|
|
if self.db.starAsked or (self.db.totalSent or 0) < STAR_MILESTONE then return end
|
|
if now - (self.lastStarCheck or 0) < STAR_CHECK_EVERY then return end
|
|
self.lastStarCheck = now
|
|
if now - (self.loginTime or now) < STAR_LOGIN_WAIT then return end
|
|
if not self.ShowStarPopup or not self:IsPlayerFree() then return end
|
|
-- If a /autospam star preview is already open, this makes it the real ask
|
|
self:ShowStarPopup(true)
|
|
end
|
|
|
|
function AutoSpam:EndResumeHold()
|
|
if self.resumeHold then
|
|
-- Posting picks up again from the login popup: start the Running clock now
|
|
self.sessionStart = GetTime()
|
|
self.sentCount = 0
|
|
end
|
|
self.resumeHold = false
|
|
end
|
|
|
|
-- Called when nobody answered the login popup in time
|
|
function AutoSpam:ResumeTimedOut()
|
|
self.resumeHold = false
|
|
self.db.enabled = false
|
|
self.timerStarted = false
|
|
self.lastPostTime = 0
|
|
Print("Posting turned itself off since it was left on at logout. Click Start when you're ready.", 1, 0.5, 0)
|
|
self:RefreshUI()
|
|
end
|
|
|
|
function AutoSpam:TogglePosting()
|
|
self:EndResumeHold()
|
|
local db = self.db
|
|
db.enabled = not db.enabled
|
|
if db.enabled then
|
|
Print("Started posting." .. (db.dryRun and " (dry run)" or ""), 0, 1, 0)
|
|
self.sessionStart = GetTime()
|
|
self.sentCount = 0
|
|
self.timerStarted = true
|
|
if self.lastPostTime == 0 then
|
|
-- Timer was idle: post right away and start the clock
|
|
self:PostNext()
|
|
self.lastPostTime = GetTime()
|
|
else
|
|
self:ValidateNext()
|
|
end
|
|
else
|
|
Print("Stopped posting.", 1, 1, 0)
|
|
end
|
|
self:RefreshUI()
|
|
end
|
|
|
|
function AutoSpam:PostNow()
|
|
self:EndResumeHold()
|
|
self:PostNext()
|
|
self.lastPostTime = GetTime()
|
|
self.timerStarted = true
|
|
self:RefreshUI()
|
|
end
|
|
|
|
function AutoSpam:TimeRemaining()
|
|
if self.resumeHold or not self.timerStarted then return nil end
|
|
local r = self.db.interval - (GetTime() - self.lastPostTime)
|
|
if r < 0 then r = 0 end
|
|
return r
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Auto-invite (each message has its own switch, keywords and convert option;
|
|
-- only enabled messages count, and only while posting is running)
|
|
---------------------------------------------------------------------------
|
|
|
|
function AutoSpam:MatchesKeyword(text, keywords)
|
|
local said = string.lower(Trim(text))
|
|
if said == "" then return false end
|
|
for kw in string.gfind(keywords or "", "[^,]+") do
|
|
local k = string.lower(Trim(kw))
|
|
if k ~= "" and said == k then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function AutoSpam:FindInviteMessage(text)
|
|
if not self.db.enabled or self.resumeHold then return nil end
|
|
for i = 1, table.getn(self.db.messages) do
|
|
local m = self.db.messages[i]
|
|
if m.enabled and m.autoInvite and self:MatchesKeyword(text, m.inviteKeywords) then
|
|
return m
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function AutoSpam:IsInMyGroup(name)
|
|
local raidN = GetNumRaidMembers()
|
|
if raidN > 0 then
|
|
for i = 1, raidN do
|
|
if GetRaidRosterInfo(i) == name then return true end
|
|
end
|
|
return false
|
|
end
|
|
for i = 1, GetNumPartyMembers() do
|
|
if UnitName("party" .. i) == name then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Whisper auto-reply. Two checks: posting has to be running, and replies
|
|
-- have to be turned on in Settings. Each player gets it once per
|
|
-- REPLY_COOLDOWN, which also stops two auto-replies from answering each
|
|
-- other forever. Placeholders are filled in like in a post.
|
|
---------------------------------------------------------------------------
|
|
|
|
function AutoSpam:HandleAutoReply(text, sender)
|
|
local db = self.db
|
|
if not db.enabled or self.resumeHold then return end
|
|
if not db.autoReply then return end
|
|
if not sender or sender == "" or sender == UnitName("player") then return end
|
|
local reply = Trim(db.replyText or "")
|
|
if reply == "" then return end
|
|
|
|
local now = GetTime()
|
|
local last = self.recentReplies[sender]
|
|
if last and now - last < REPLY_COOLDOWN then return end
|
|
self.recentReplies[sender] = now
|
|
|
|
local out = self:ExpandText(reply)
|
|
if string.len(out) > self.REPLY_MAX then
|
|
Print("Your auto-reply runs over " .. self.REPLY_MAX .. " characters once placeholders are filled in, so it wasn't sent to " .. sender .. ".", 1, 0.3, 0.3)
|
|
return
|
|
end
|
|
if db.dryRun then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff66ccff[AutoSpam dry run]|r |cffaaaaaaauto-reply to " .. sender .. ":|r " .. out)
|
|
return
|
|
end
|
|
SendChatMessage(out, "WHISPER", nil, sender)
|
|
if db.debugMode then
|
|
Print("Auto-replied to " .. sender .. ".", 0, 1, 1)
|
|
end
|
|
end
|
|
|
|
function AutoSpam:HandleWhisper(text, sender)
|
|
if not text or not sender or sender == "" then return end
|
|
if sender == UnitName("player") then return end
|
|
local m = self:FindInviteMessage(text)
|
|
if not m then return end
|
|
if self:IsInMyGroup(sender) then return end
|
|
|
|
local now = GetTime()
|
|
if self.recentInvites[sender] and now - self.recentInvites[sender] < INVITE_COOLDOWN then return end
|
|
|
|
local delay = 0
|
|
local raidN = GetNumRaidMembers()
|
|
local partyN = GetNumPartyMembers()
|
|
if raidN > 0 then
|
|
if not (IsRaidLeader() or IsRaidOfficer()) then return end
|
|
if raidN >= 40 then
|
|
Print("Raid is full, couldn't invite " .. sender .. ".", 1, 0.5, 0)
|
|
return
|
|
end
|
|
elseif partyN > 0 then
|
|
if not IsPartyLeader() then return end
|
|
if partyN >= 4 then
|
|
if not m.inviteConvert then
|
|
Print("Party is full, couldn't invite " .. sender .. ". Turn on 'Convert to raid' for '" .. m.name .. "' to keep inviting.", 1, 0.5, 0)
|
|
return
|
|
end
|
|
if not self.db.dryRun then ConvertToRaid() end
|
|
delay = 1.5 -- give the server a moment to finish the conversion
|
|
end
|
|
end
|
|
|
|
self.recentInvites[sender] = now
|
|
if self.db.dryRun then
|
|
Print("Dry run: would invite " .. sender .. " (" .. m.name .. ").", 0.4, 0.8, 1)
|
|
return
|
|
end
|
|
table.insert(self.inviteQueue, { name = sender, at = now + delay, msg = m.name })
|
|
end
|
|
|
|
function AutoSpam:ProcessInviteQueue(now)
|
|
local q = self.inviteQueue
|
|
local i = 1
|
|
while i <= table.getn(q) do
|
|
if now >= q[i].at then
|
|
InviteByName(q[i].name)
|
|
Print("Invited " .. q[i].name .. " (" .. q[i].msg .. ").", 0, 1, 0)
|
|
table.remove(q, i)
|
|
else
|
|
i = i + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Shift-click links into the message editor
|
|
---------------------------------------------------------------------------
|
|
|
|
function AutoSpam:InsertLink(link)
|
|
if not link or not self.linkFocus or not self.linkTarget then return false end
|
|
if not self.linkTarget:IsVisible() then return false end
|
|
local cur = self.linkTarget:GetText() or ""
|
|
local limit = self.linkTarget.autoSpamMax or self.MAX_LEN
|
|
if string.len(cur) + string.len(link) > limit then
|
|
Print("Not enough room for that link (" .. limit .. " character limit).", 1, 0.3, 0.3)
|
|
return true
|
|
end
|
|
self.linkTarget:Insert(link)
|
|
return true
|
|
end
|
|
|
|
function AutoSpam:InstallLinkHooks()
|
|
if SetItemRef then
|
|
local origSetItemRef = SetItemRef
|
|
SetItemRef = function(link, text, button)
|
|
if IsShiftKeyDown() and AutoSpam:InsertLink(text) then return end
|
|
origSetItemRef(link, text, button)
|
|
end
|
|
end
|
|
if ContainerFrameItemButton_OnClick then
|
|
local origBag = ContainerFrameItemButton_OnClick
|
|
ContainerFrameItemButton_OnClick = function(button, ignoreModifiers)
|
|
if button == "LeftButton" and IsShiftKeyDown() and not ignoreModifiers and AutoSpam.linkFocus then
|
|
local link = GetContainerItemLink(this:GetParent():GetID(), this:GetID())
|
|
if AutoSpam:InsertLink(link) then return end
|
|
end
|
|
origBag(button, ignoreModifiers)
|
|
end
|
|
end
|
|
if PaperDollItemSlotButton_OnClick then
|
|
local origPaperDoll = PaperDollItemSlotButton_OnClick
|
|
PaperDollItemSlotButton_OnClick = function(button, ignoreModifiers)
|
|
if button == "LeftButton" and IsShiftKeyDown() and not ignoreModifiers and AutoSpam.linkFocus then
|
|
local link = GetInventoryItemLink("player", this:GetID())
|
|
if AutoSpam:InsertLink(link) then return end
|
|
end
|
|
origPaperDoll(button, ignoreModifiers)
|
|
end
|
|
end
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- UI hooks (replaced by AutoSpamUI.lua)
|
|
---------------------------------------------------------------------------
|
|
|
|
function AutoSpam:RefreshUI() end
|
|
function AutoSpam:OnTick(elapsed) end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Timer loop (GetTime based, frame-rate independent)
|
|
---------------------------------------------------------------------------
|
|
|
|
local tickFrame = CreateFrame("Frame")
|
|
tickFrame:SetScript("OnUpdate", function()
|
|
if not AutoSpam.db then return end
|
|
local now = GetTime()
|
|
|
|
if AutoSpam.resumeHold and now >= AutoSpam.resumeDeadline then
|
|
AutoSpam:ResumeTimedOut()
|
|
end
|
|
|
|
if AutoSpam.timerStarted and now - AutoSpam.lastPostTime >= AutoSpam.db.interval then
|
|
if AutoSpam.db.enabled then
|
|
AutoSpam.lastPostTime = now
|
|
-- Post straight away, AFK or not. The server takes channel messages
|
|
-- from AFK players fine; toggling /afk first only added a second
|
|
-- chat packet right before the post.
|
|
-- pcall so a hidden script error shows up in chat instead of
|
|
-- silently skipping the post (script errors are often turned off)
|
|
local ok, err = pcall(function() AutoSpam:PostNext() end)
|
|
if not ok then
|
|
Print("Post failed with an error: " .. tostring(err), 1, 0.3, 0.3)
|
|
end
|
|
if AutoSpam.db.debugMode then
|
|
Print("Timer fired. Sent so far: " .. AutoSpam.sentCount .. ".", 0, 1, 1)
|
|
end
|
|
else
|
|
-- Timer ran out while stopped: go idle so the next Start posts immediately
|
|
AutoSpam.timerStarted = false
|
|
AutoSpam.lastPostTime = 0
|
|
AutoSpam:RefreshUI()
|
|
end
|
|
end
|
|
|
|
if table.getn(AutoSpam.inviteQueue) > 0 then
|
|
AutoSpam:ProcessInviteQueue(now)
|
|
end
|
|
if table.getn(AutoSpam.sendQueue) > 0 then
|
|
AutoSpam:ProcessSendQueue(now)
|
|
end
|
|
if table.getn(AutoSpam.echoWatch) > 0 then
|
|
AutoSpam:CheckEchoes(now)
|
|
end
|
|
if table.getn(AutoSpam.addonQueue) > 0 then
|
|
AutoSpam:ProcessAddonQueue(now)
|
|
end
|
|
AutoSpam:CheckShares(now)
|
|
AutoSpam:CheckStarAsk(now)
|
|
|
|
-- UIParent may show the logout popup after our event handler ran; hide it again
|
|
if AutoSpam.campHideAt and now >= AutoSpam.campHideAt then
|
|
AutoSpam.campHideAt = nil
|
|
if StaticPopup_Hide then StaticPopup_Hide("CAMP") end
|
|
end
|
|
|
|
AutoSpam:OnTick(arg1)
|
|
end)
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Slash commands
|
|
---------------------------------------------------------------------------
|
|
|
|
SLASH_AUTOSPAM1 = "/autospam"
|
|
SLASH_AUTOSPAM2 = "/aspam"
|
|
SlashCmdList["AUTOSPAM"] = function(msg)
|
|
if not AutoSpam.db then return end
|
|
local cmd = string.lower(Trim(msg))
|
|
if cmd == "start" then
|
|
if not AutoSpam.db.enabled then AutoSpam:TogglePosting() end
|
|
elseif cmd == "stop" then
|
|
if AutoSpam.db.enabled then AutoSpam:TogglePosting() end
|
|
elseif cmd == "now" then
|
|
AutoSpam:PostNow()
|
|
elseif cmd == "dryrun" then
|
|
AutoSpam:SetDryRun(not AutoSpam.db.dryRun)
|
|
elseif cmd == "time" then
|
|
-- Shows the clock behind {UTC ...} so a wrong conversion can be tracked down
|
|
local pc, utc = "unavailable", "unavailable"
|
|
if date then
|
|
local t = date("*t")
|
|
if t and t.hour then pc = string.format("%02d:%02d", t.hour, t.min) end
|
|
local u = date("!*t")
|
|
if u and u.hour then utc = string.format("%02d:%02d", u.hour, u.min) end
|
|
end
|
|
Print("Your PC: " .. pc .. " UTC: " .. utc)
|
|
Print("Sun 1300 your time = " .. (AutoSpam:ConvertToUTC("Sun 1300") or "?"))
|
|
elseif cmd == "star" and AutoSpam.db.debugMode then
|
|
-- Shows the GitHub star popup on demand, for testing and review.
|
|
-- Only with Debug messages on; otherwise it acts like any unknown option.
|
|
-- A test view: closing it doesn't count as the one real ask
|
|
if AutoSpam.ShowStarPopup then AutoSpam:ShowStarPopup(false) end
|
|
elseif cmd == "help" then
|
|
Print("/autospam: open the window. Options: start, stop, now, dryrun, time.")
|
|
else
|
|
if AutoSpam.ToggleWindow then AutoSpam:ToggleWindow() end
|
|
end
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Events
|
|
---------------------------------------------------------------------------
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Sharing messages with guildmates
|
|
-- Two ways to carry a share, since 1.12 has no addon whispers:
|
|
-- * Guild: hidden addon messages on the guild channel. A guild share is
|
|
-- addressed to "*"; a targeted share to an online guildmate carries their
|
|
-- name and everyone else ignores it.
|
|
-- * Whisper: for a player outside your guild (or when you have none).
|
|
-- AutoSpam first sends one readable whisper. If they run AutoSpam it's
|
|
-- hidden and answered behind the scenes; if they don't, that one line
|
|
-- tells them what it was and nothing else is sent. The data whispers that
|
|
-- follow are hidden from chat on both sides.
|
|
-- Either way the message is split into small pieces, sent a moment apart so
|
|
-- the server doesn't see a burst, and put back together on the other end.
|
|
-- Auto-invite settings are not shared.
|
|
---------------------------------------------------------------------------
|
|
|
|
local SHARE_PREFIX = "AutoSpam"
|
|
local SHARE_PIECE = 180 -- characters of message data per addon message
|
|
local SHARE_GAP = 0.25 -- seconds between addon messages
|
|
local SHARE_WAIT = 75 -- how long the sender waits for answers
|
|
local SHARE_WHISPER_GAP = 0.5 -- whispers go a little slower than addon messages
|
|
local SHARE_PROBE_WAIT = 15 -- how long to wait for a whisper target's AutoSpam to answer
|
|
local WHISPER_TAG = "AutoSpam~" -- marks hidden data whispers
|
|
AutoSpam.SHARE_ANSWER = 60 -- how long the receiver's popup stays up
|
|
|
|
local function Escape(s)
|
|
s = string.gsub(s or "", "%%", "%%25")
|
|
s = string.gsub(s, "~", "%%7E")
|
|
s = string.gsub(s, "|", "%%7C")
|
|
s = string.gsub(s, "\n", " ")
|
|
return s
|
|
end
|
|
|
|
local function Unescape(s)
|
|
local r = string.gsub(s or "", "%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
|
|
return r
|
|
end
|
|
|
|
local function Split(s)
|
|
local t = {}
|
|
for part in string.gfind(s .. "~", "([^~]*)~") do
|
|
table.insert(t, part)
|
|
end
|
|
return t
|
|
end
|
|
|
|
function AutoSpam:IsChannelValue(v)
|
|
for i = 1, table.getn(self.CHANNELS) do
|
|
if self.CHANNELS[i].value == v then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- "online", "offline", or "unknown" (not in the roster we can see right now)
|
|
function AutoSpam:GuildMemberStatus(name)
|
|
if not GetNumGuildMembers or not GetGuildRosterInfo then return "unknown" end
|
|
local want = string.lower(name)
|
|
for i = 1, GetNumGuildMembers() do
|
|
local n, _, _, _, _, _, _, _, online = GetGuildRosterInfo(i)
|
|
if n and string.lower(n) == want then
|
|
return online and "online" or "offline"
|
|
end
|
|
end
|
|
return "unknown"
|
|
end
|
|
|
|
-- via "G" = guild addon channel, "W" = hidden whisper to `to`.
|
|
-- raw = send the text as an ordinary whisper (the readable first contact).
|
|
function AutoSpam:QueueAddon(msg, via, to, raw)
|
|
table.insert(self.addonQueue, { msg = msg, via = via or "G", to = to, raw = raw })
|
|
end
|
|
|
|
function AutoSpam:ProcessAddonQueue(now)
|
|
local item = self.addonQueue[1]
|
|
if not item then return end
|
|
local gap = (item.via == "W") and SHARE_WHISPER_GAP or SHARE_GAP
|
|
if now - (self.lastAddonSend or 0) < gap then return end
|
|
table.remove(self.addonQueue, 1)
|
|
if item.via == "W" then
|
|
SendChatMessage(item.raw and item.msg or (WHISPER_TAG .. item.msg), "WHISPER", nil, item.to)
|
|
else
|
|
SendAddonMessage(SHARE_PREFIX, item.msg, "GUILD")
|
|
end
|
|
self.lastAddonSend = now
|
|
end
|
|
|
|
-- The readable first-contact whisper: "... (#123456)"
|
|
local function ProbeText(id)
|
|
return "[AutoSpam] I'd like to share an AutoSpam message with you. You need the AutoSpam addon to receive it. (#" .. id .. ")"
|
|
end
|
|
|
|
-- Is this whisper one of ours that should stay out of the chat window?
|
|
function AutoSpam:IsShareWhisper(text)
|
|
if not text then return false end
|
|
if string.sub(text, 1, string.len(WHISPER_TAG)) == WHISPER_TAG then return true end
|
|
if string.find(text, "^%[AutoSpam%] .*%(#%d+%)$") then return true end
|
|
return false
|
|
end
|
|
|
|
-- A whisper came in. Returns true if it was a share whisper (handled here).
|
|
function AutoSpam:OnShareWhisper(text, sender)
|
|
if not text or not sender then return false end
|
|
if string.sub(text, 1, string.len(WHISPER_TAG)) == WHISPER_TAG then
|
|
self:OnAddonMessage(SHARE_PREFIX, string.sub(text, string.len(WHISPER_TAG) + 1), sender, "W")
|
|
return true
|
|
end
|
|
local _, _, id = string.find(text, "^%[AutoSpam%] .*%(#(%d+)%)$")
|
|
if id then
|
|
-- We have AutoSpam: tell the sender to go ahead
|
|
self:QueueAddon("K~" .. id, "W", sender)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- target = a player's name, or nil to offer it to the whole guild
|
|
function AutoSpam:ShareMessage(m, target)
|
|
local inGuild = IsInGuild and IsInGuild()
|
|
if not m or Trim(m.text) == "" then
|
|
Print("Write some text before sharing.", 1, 0.3, 0.3)
|
|
return false
|
|
end
|
|
|
|
local guild = (target == nil)
|
|
local via = "G"
|
|
if guild then
|
|
if not inGuild then
|
|
Print("You need to be in a guild to share with your guild. You can still send it to a player by name.", 1, 0.3, 0.3)
|
|
return false
|
|
end
|
|
else
|
|
target = Trim(target)
|
|
if target == "" then
|
|
Print("Enter the name of the player to share with.", 1, 0.3, 0.3)
|
|
return false
|
|
end
|
|
if string.find(target, "[~%s]") then
|
|
Print("'" .. target .. "' isn't a valid character name.", 1, 0.3, 0.3)
|
|
return false
|
|
end
|
|
if string.lower(target) == string.lower(UnitName("player") or "") then
|
|
Print("You can't share a message with yourself.", 1, 0.3, 0.3)
|
|
return false
|
|
end
|
|
local status = inGuild and self:GuildMemberStatus(target) or "unknown"
|
|
if status == "offline" then
|
|
Print(target .. " is offline right now.", 1, 0.3, 0.3)
|
|
return false
|
|
end
|
|
-- Online guildmates get the quiet guild channel; anyone else gets whispers
|
|
if status ~= "online" then via = "W" end
|
|
end
|
|
if via == "G" and not SendAddonMessage then
|
|
Print("This game client can't send addon messages, so guild sharing isn't available.", 1, 0.3, 0.3)
|
|
return false
|
|
end
|
|
|
|
local id = tostring(math.random(100000, 999999))
|
|
local payload = table.concat({
|
|
Escape(m.name),
|
|
Escape(m.text),
|
|
Escape(table.concat(self:CheckedChannels(m.channels), ",")),
|
|
Escape(m.customChannel or ""),
|
|
tostring(m.weight or 1),
|
|
}, "~")
|
|
local n = math.ceil(string.len(payload) / SHARE_PIECE)
|
|
local to = guild and "*" or target
|
|
local pieces = {}
|
|
for i = 1, n do
|
|
local piece = string.sub(payload, (i - 1) * SHARE_PIECE + 1, i * SHARE_PIECE)
|
|
table.insert(pieces, "D~" .. id .. "~" .. to .. "~" .. i .. "~" .. n .. "~" .. piece)
|
|
end
|
|
|
|
local out = {
|
|
name = m.name, target = to, guild = guild, via = via, answers = 0,
|
|
deadline = GetTime() + SHARE_WAIT + n * SHARE_WHISPER_GAP,
|
|
}
|
|
self.outgoing[id] = out
|
|
|
|
if via == "W" then
|
|
-- Ask first; the pieces only go out once their AutoSpam answers
|
|
out.pieces = pieces
|
|
out.probing = true
|
|
out.probeDeadline = GetTime() + SHARE_PROBE_WAIT
|
|
self:QueueAddon(ProbeText(id), "W", target, true)
|
|
else
|
|
for i = 1, n do self:QueueAddon(pieces[i], "G") end
|
|
end
|
|
|
|
if guild then
|
|
Print("Offering '" .. m.name .. "' to your guild. Anyone running AutoSpam gets a popup.", 0, 1, 1)
|
|
else
|
|
Print("Offering '" .. m.name .. "' to " .. target .. ".", 0, 1, 1)
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- via is "W" when it arrived as a hidden whisper, otherwise the guild channel
|
|
function AutoSpam:OnAddonMessage(prefix, msg, sender, via)
|
|
if prefix ~= SHARE_PREFIX or not msg or not sender then return end
|
|
local me = UnitName("player") or ""
|
|
if sender == me then return end
|
|
|
|
local kind = string.sub(msg, 1, 2)
|
|
if kind == "D~" then
|
|
local _, _, id, to, i, n, piece = string.find(msg, "^D~(%d+)~([^~]*)~(%d+)~(%d+)~(.*)$")
|
|
if not id then return end
|
|
if to ~= "*" and string.lower(to) ~= string.lower(me) then return end
|
|
i, n = tonumber(i), tonumber(n)
|
|
if n < 1 or n > 20 or i < 1 or i > n then return end
|
|
|
|
local key = sender .. ":" .. id
|
|
local buf = self.incoming[key]
|
|
if not buf then
|
|
buf = { parts = {}, got = 0, n = n, started = GetTime() }
|
|
self.incoming[key] = buf
|
|
end
|
|
if not buf.parts[i] then
|
|
buf.parts[i] = piece
|
|
buf.got = buf.got + 1
|
|
end
|
|
if buf.got < buf.n then return end
|
|
self.incoming[key] = nil
|
|
|
|
local f = Split(table.concat(buf.parts, "", 1, buf.n))
|
|
if table.getn(f) < 5 then return end
|
|
local offer = {
|
|
from = sender, id = id, guild = (to == "*"), via = (via == "W") and "W" or "G",
|
|
name = Trim(Unescape(f[1])),
|
|
text = string.sub(Unescape(f[2]), 1, self.MAX_LEN),
|
|
channels = {},
|
|
customChannel = Unescape(f[4]),
|
|
weight = math.floor(tonumber(f[5]) or 1),
|
|
}
|
|
if offer.name == "" then offer.name = "Shared message" end
|
|
if offer.weight < 1 then offer.weight = 1 end
|
|
if offer.weight > 10 then offer.weight = 10 end
|
|
for v in string.gfind(Unescape(f[3]), "[^,]+") do
|
|
if self:IsChannelValue(v) then offer.channels[v] = true end
|
|
end
|
|
if table.getn(self:CheckedChannels(offer.channels)) == 0 then offer.channels = { SAY = true } end
|
|
|
|
table.insert(self.offers, offer)
|
|
if self.ShowNextOffer then self:ShowNextOffer() end
|
|
|
|
elseif kind == "K~" then
|
|
-- Their AutoSpam answered our first whisper: send the message
|
|
local _, _, id = string.find(msg, "^K~(%d+)$")
|
|
local out = id and self.outgoing[id]
|
|
if not out or not out.probing then return end
|
|
if string.lower(out.target) ~= string.lower(sender) then return end
|
|
out.probing = nil
|
|
for i = 1, table.getn(out.pieces) do self:QueueAddon(out.pieces[i], "W", sender) end
|
|
out.pieces = nil
|
|
|
|
elseif kind == "R~" then
|
|
local _, _, id, to, answer = string.find(msg, "^R~(%d+)~([^~]*)~(%a)$")
|
|
if not id or string.lower(to) ~= string.lower(me) then return end
|
|
local out = self.outgoing[id]
|
|
if not out then return end
|
|
out.answers = out.answers + 1
|
|
if answer == "A" then
|
|
Print(sender .. " accepted '" .. out.name .. "'.", 0, 1, 0)
|
|
elseif answer == "D" then
|
|
Print(sender .. " declined '" .. out.name .. "'.", 1, 0.82, 0)
|
|
else
|
|
Print(sender .. " didn't answer the offer for '" .. out.name .. "'.", 1, 0.82, 0)
|
|
end
|
|
if not out.guild then self.outgoing[id] = nil end
|
|
end
|
|
end
|
|
|
|
-- answer is "A" (accept), "D" (decline) or "N" (popup timed out)
|
|
function AutoSpam:AnswerOffer(offer, answer)
|
|
if not offer then return end
|
|
self:QueueAddon("R~" .. offer.id .. "~" .. offer.from .. "~" .. answer, offer.via, offer.from)
|
|
if answer == "A" then
|
|
self:ImportOffer(offer)
|
|
elseif answer == "D" then
|
|
Print("Declined '" .. offer.name .. "' from " .. offer.from .. ".", 1, 0.82, 0)
|
|
else
|
|
Print("The offer of '" .. offer.name .. "' from " .. offer.from .. " timed out.", 1, 0.82, 0)
|
|
end
|
|
end
|
|
|
|
function AutoSpam:ImportOffer(o)
|
|
local name, k = o.name, 2
|
|
while self:FindByName(name) do
|
|
name = o.name .. " (" .. k .. ")"
|
|
k = k + 1
|
|
end
|
|
local picked = self:CheckedChannels(o.channels)
|
|
local m = {
|
|
name = name, text = o.text, enabled = false,
|
|
channels = o.channels, channel = picked[1], customChannel = o.customChannel or "",
|
|
weight = o.weight or 1,
|
|
autoInvite = false, inviteKeywords = DEFAULT_KEYWORDS, inviteConvert = false,
|
|
}
|
|
table.insert(self.db.messages, m)
|
|
Print("Added '" .. name .. "' from " .. o.from .. ". It stays unchecked until you turn it on.", 0, 1, 0)
|
|
self:RefreshUI()
|
|
return m
|
|
end
|
|
|
|
function AutoSpam:CheckShares(now)
|
|
for key, buf in pairs(self.incoming) do
|
|
if now - buf.started > 30 then self.incoming[key] = nil end
|
|
end
|
|
for id, out in pairs(self.outgoing) do
|
|
if out.probing and now >= out.probeDeadline then
|
|
self.outgoing[id] = nil
|
|
Print(out.target .. " doesn't seem to have AutoSpam, or isn't online. They got one whisper saying you tried to share a message.", 1, 0.82, 0)
|
|
elseif now >= out.deadline then
|
|
self.outgoing[id] = nil
|
|
if out.answers == 0 then
|
|
if out.guild then
|
|
Print("No guildmates answered the offer for '" .. out.name .. "'. Nobody else online may be running AutoSpam.", 1, 0.82, 0)
|
|
else
|
|
Print("No answer from " .. out.target .. " about '" .. out.name .. "'. They may be offline or not running AutoSpam.", 1, 0.82, 0)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Idle logout guard
|
|
-- After 30 minutes without keyboard or mouse input the client starts the
|
|
-- same 20 second logout countdown as /camp (PLAYER_CAMPING). Chat posts and
|
|
-- clearing the AFK flag don't count as input, so while posting is running
|
|
-- we cancel that countdown. Logouts you start yourself (/logout, /camp, the
|
|
-- game menu, the keybind) all go through Logout(), so we mark those and
|
|
-- leave them alone.
|
|
---------------------------------------------------------------------------
|
|
|
|
AutoSpam.userLogoutAt = 0
|
|
local origLogout = Logout
|
|
Logout = function()
|
|
AutoSpam.userLogoutAt = GetTime()
|
|
if origLogout then return origLogout() end
|
|
end
|
|
|
|
function AutoSpam:HandleCamping()
|
|
if not self.db or not self.db.enabled or self.resumeHold then return end
|
|
self:InstallIdleFilter() -- catch chat windows another addon re-hooked since login
|
|
if GetTime() - self.userLogoutAt < 5 then return end
|
|
if not CancelLogout then return end
|
|
CancelLogout()
|
|
if StaticPopup_Hide then StaticPopup_Hide("CAMP") end
|
|
self.campHideAt = GetTime() + 0.2
|
|
-- The client asks again every few seconds while you stay idle; say so once
|
|
if not self.campNoteAt or GetTime() - self.campNoteAt > 600 then
|
|
Print("Stopped an idle logout so posting can keep going.", 1, 0.82, 0)
|
|
end
|
|
self.campNoteAt = GetTime()
|
|
end
|
|
|
|
-- The client repeats "You have been inactive for some time..." in chat every
|
|
-- few seconds while you stay idle. While AutoSpam is holding off the logout
|
|
-- that line is just spam, so drop it from the chat windows.
|
|
local function IsIdleSpam(text)
|
|
if not text or not AutoSpam.db or not AutoSpam.db.enabled then return false end
|
|
if IDLE_MESSAGE and text == IDLE_MESSAGE then return true end
|
|
return string.find(text, "inactive for some time", 1, true) and true or false
|
|
end
|
|
|
|
-- A shown chat line that carries one of our share whispers
|
|
local function IsShareLine(text)
|
|
if not text then return false end
|
|
if string.find(text, WHISPER_TAG, 1, true) then return true end
|
|
return string.find(text, "[AutoSpam] I'd like to share", 1, true) and string.find(text, "%(#%d+%)") and true or false
|
|
end
|
|
|
|
-- Filters at two levels, since chat addons (pfUI and friends) replace a chat
|
|
-- window's AddMessage at different times:
|
|
-- 1. the system chat event, before any chat window sees it
|
|
-- 2. each chat window's AddMessage, re-wrapped whenever another addon
|
|
-- has swapped in its own version since we last looked
|
|
local function WrapChatFrames()
|
|
local n = NUM_CHAT_WINDOWS or 7
|
|
if n < 7 then n = 7 end
|
|
for i = 1, n do
|
|
local cf = getglobal("ChatFrame" .. i)
|
|
if cf and cf.AddMessage and cf.AddMessage ~= cf.autoSpamFilter then
|
|
local orig = cf.AddMessage
|
|
local wrapper = function(frame, text, a1, a2, a3, a4, a5, a6, a7, a8, a9)
|
|
if IsIdleSpam(text) or IsShareLine(text) then return end
|
|
return orig(frame, text, a1, a2, a3, a4, a5, a6, a7, a8, a9)
|
|
end
|
|
cf.autoSpamFilter = wrapper
|
|
cf.AddMessage = wrapper
|
|
end
|
|
end
|
|
end
|
|
|
|
function AutoSpam:InstallIdleFilter()
|
|
if not self.idleEventHooked and ChatFrame_OnEvent then
|
|
self.idleEventHooked = true
|
|
local origOnEvent = ChatFrame_OnEvent
|
|
ChatFrame_OnEvent = function(ev)
|
|
if ev == "CHAT_MSG_SYSTEM" and IsIdleSpam(arg1) then return end
|
|
if (ev == "CHAT_MSG_WHISPER" or ev == "CHAT_MSG_WHISPER_INFORM") and AutoSpam:IsShareWhisper(arg1) then return end
|
|
return origOnEvent(ev)
|
|
end
|
|
end
|
|
WrapChatFrames()
|
|
end
|
|
|
|
local eventFrame = CreateFrame("Frame")
|
|
eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
|
|
eventFrame:RegisterEvent("VARIABLES_LOADED")
|
|
eventFrame:RegisterEvent("CHAT_MSG_WHISPER")
|
|
eventFrame:RegisterEvent("PLAYER_CAMPING")
|
|
eventFrame:RegisterEvent("CHAT_MSG_CHANNEL")
|
|
eventFrame:RegisterEvent("CHAT_MSG_SYSTEM")
|
|
eventFrame:RegisterEvent("CHAT_MSG_ADDON")
|
|
eventFrame:SetScript("OnEvent", function()
|
|
if event == "VARIABLES_LOADED" then
|
|
if AutoSpam.initialized then return end
|
|
AutoSpam:Initialize()
|
|
AutoSpam:InstallLinkHooks()
|
|
if AutoSpam.CreateUI then
|
|
local ok, err = pcall(function() AutoSpam:CreateUI() end)
|
|
if not ok then
|
|
Print("UI error: " .. tostring(err), 1, 0, 0)
|
|
end
|
|
end
|
|
AutoSpam.initialized = true
|
|
AutoSpam.loginTime = GetTime()
|
|
AutoSpam:RollNext()
|
|
AutoSpam:RefreshUI()
|
|
Print("loaded. Type /autospam or click the minimap button.", 0, 1, 1)
|
|
if AutoSpam.resumed then
|
|
Print("You didn't stop posting before logging out. AutoSpam will turn itself off in 2 minutes unless you choose Post now.", 1, 0.5, 0)
|
|
if AutoSpam.ShowResumePopup then AutoSpam:ShowResumePopup() end
|
|
end
|
|
elseif event == "CHAT_MSG_WHISPER" then
|
|
if AutoSpam.initialized and not AutoSpam:OnShareWhisper(arg1, arg2) then
|
|
AutoSpam:HandleWhisper(arg1, arg2)
|
|
AutoSpam:HandleAutoReply(arg1, arg2)
|
|
end
|
|
elseif event == "PLAYER_ENTERING_WORLD" then
|
|
-- Late enough that chat addons have set up their windows first
|
|
AutoSpam:InstallIdleFilter()
|
|
elseif event == "PLAYER_CAMPING" then
|
|
AutoSpam:HandleCamping()
|
|
elseif event == "CHAT_MSG_CHANNEL" then
|
|
if AutoSpam.initialized then AutoSpam:OnChannelEcho(arg1, arg2) end
|
|
elseif event == "CHAT_MSG_ADDON" then
|
|
if AutoSpam.initialized then AutoSpam:OnAddonMessage(arg1, arg2, arg4) end
|
|
elseif event == "CHAT_MSG_SYSTEM" then
|
|
if AutoSpam.initialized then AutoSpam:OnSystemDuringWatch(arg1) end
|
|
end
|
|
end)
|