Repository navigation
Expand file tree
/
Copy pathGuildRecruiter.lua
More file actions
2692 lines (2501 loc) · 127 KB
/
Copy pathGuildRecruiter.lua
File metadata and controls
2692 lines (2501 loc) · 127 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--[[ Guild Rawcruiter -- vanilla 1.12 / Turtle WoW (version: see .toc / VERSION below)
Scans online players with /who and sends paced guild contacts (invite and/or
whisper) to players who are NOT already in a guild.
Coverage: /who returns at most ~49 results per query and is server-throttled.
We ADAPTIVELY SUBDIVIDE -- query a wide level band, and only if it comes back
at the cap (truncated) do we narrow it (band -> halves -> single level -> by
class). A slice under the cap was fully captured in one query.
GUILD COORDINATION (addon messages over the GUILD channel):
* dedup -- every contact is broadcast so other recruiters skip that player;
the re-invite cooldown becomes guild-wide.
* split -- active recruiters announce presence; the 1-60 sweep is divided
evenly among them (deterministic by name) so they don't overlap.
Slash: /gr (see the bottom of the file, or type /gr help in-game)
]]--
GuildRecruiter_Settings = GuildRecruiter_Settings or {}
local VERSION = "3.22"
local CAP_HINT = 49 -- treat a query returning >= this many as truncated
local START_WIDTH = 10 -- initial level-band width to try
local WHO_TIMEOUT = 15 -- give up waiting on a /who reply after this many seconds (then retry)
local WHO_RETRIES = 3 -- re-send a band this many times on timeout before skipping past it
local SYNC_PREFIX = "GuildRec"
local PRESENCE_INTERVAL = 20 -- re-announce "I'm recruiting" this often (s)
local PRESENCE_TTL = 60 -- forget a recruiter not heard from in this long
local WHISPER_WAIT = 120 -- keep waiting this long for a whisper reply (s)
local BACKOFF_SECS = 12 -- pause sends this long when the server throttles us
-- config-GUI slider ranges and option tables
local INVITE_MIN, INVITE_MAX = 1, 10
local WHO_MIN, WHO_MAX = 1, 15
local AUTO_MIN, AUTO_MAX = 5, 900 -- seconds to rest between auto-rescan cycles
local METHOD_ORDER = { "auto", "byname", "invite", "chat" }
local METHOD_LABEL = {
auto = "Auto (recommended)",
byname = "Invite by name",
invite = "Invite my target",
chat = "Use /ginvite command",
}
local MODE_ORDER = { "invite", "whisper", "whisperinvite" }
local MODE_LABEL = {
invite = "Invite only",
whisper = "Whisper only",
whisperinvite = "Whisper, invite on reply",
}
-- shorter text for the compact config buttons (full labels above are for chat)
local METHOD_SHORT = { auto = "Auto", byname = "GuildInviteByName", invite = "GuildInvite", chat = "/ginvite" }
local MODE_SHORT = { invite = "Invite only", whisper = "Whisper only", whisperinvite = "Whisper, then invite" }
-- one shared yes/cancel popup for destructive actions; GuildRecruiter_Confirm(msg, fn)
-- runs fn only if the player confirms.
StaticPopupDialogs["GUILDRECRUITER_CONFIRM"] = {
text = "%s", button1 = "Yes", button2 = "Cancel",
OnAccept = function() local f = GuildRecruiter_confirmFn; GuildRecruiter_confirmFn = nil; if f then f() end end,
OnCancel = function() GuildRecruiter_confirmFn = nil end,
timeout = 0, whileDead = 1, hideOnEscape = 1,
}
function GuildRecruiter_Confirm(msg, fn)
GuildRecruiter_confirmFn = fn
StaticPopup_Show("GUILDRECRUITER_CONFIRM", msg)
end
-- default whisper. Kept short and plain on purpose -- a one-line, low-pressure
-- ask reads as a person, not an ad, and fits the message box without scrolling.
-- Any earlier built-in default (OLD/LONG) is migrated forward; a message the
-- user actually customised is never touched.
local OLD_WHISPER = "Hi %p! We're recruiting for %g -- whisper me back if you're interested and I'll send an invite. :)"
local LONG_WHISPER = "Hi %p! :) I'm recruiting for <%g>, a friendly and active guild that loves grouping up for quests, dungeons and raids. If you're after a guild, just whisper me back and I'll send an invite -- no pressure either way!"
local NEW_WHISPER = "Hi %p! Want an invite to <%g>? Just whisper back. :)"
-- a reply matching any of these is treated as a refusal (no invite). Everything
-- else -- "sure", "y", "ok", "yea", typos, even a question -- counts as interest.
local NEGATIVES = {
"no", "nope", "nah", "naw", "no thanks", "no thank you", "no thx", "nty",
"not interested", "not now", "maybe later", "pass", "im good", "no im good",
"leave me alone", "leave me", "stop", "go away", "not looking",
"already in", "already in a guild", "have a guild", "got a guild",
"busy", "fuck off", "piss off", "reported", "report",
}
-- whitelist used by the "yesonly" reply policy (invite only if a reply matches)
local AFFIRM_DEFAULTS = {
"yes", "yeah", "yep", "yup", "ya", "yea", "yess", "yas", "y", "yes please",
"sure", "ok", "okay", "kk", "alright", "aight", "of course", "ofc",
"definitely", "absolutely", "sounds good", "why not", "go ahead", "go for it",
"please", "pls", "plz", "im in", "count me in", "sign me up", "lets go",
"do it", "send it", "invite me", "inv me", "add me", "invite", "inv",
"for sure", "id love to", "love to", "happy to",
}
-- how a whisper reply is judged in whisper-then-invite mode
local REPLY_ORDER = { "notno", "yesonly", "any" }
local REPLY_LABEL = {
notno = "Unless they say no", -- invite unless reply hits the refusal list
yesonly = "Only on a yes word", -- invite only if reply hits the yes list
any = "Any reply at all", -- invite on any reply, no filtering
}
-- which settings a profile snapshots (history/blacklist/stats/negatives stay global)
local PROFILE_KEYS = {
"inviteDelay", "whoDelay", "reinviteDays", "mode", "whisperMsg", "minLevel",
"maxLevel", "sessionCap", "jitter", "skipCombat", "skipInstance", "guildSync",
"quietWho", "inviteMethod", "replyMode", "classFilter",
}
local f = CreateFrame("Frame", "GuildRecruiterFrame")
-- run state
local running, scanning, awaiting, paused = false, false, false, false
local lo = 1 -- next uncovered level in the sweep
local width = START_WIDTH
local observedCap = 0 -- largest /who result count seen this run; the server's
-- real cap is learned, not assumed (see ProcessWho)
local myLo, myHi = 1, 60 -- my assigned slice of the level range (after split)
local pending = {} -- targeted class sub-queries for dense levels
local current = nil -- slice currently being queried
local contactQueue = {} -- guildless names pending a contact
local replyQueue = {} -- names who replied to a whisper, pending an invite
local seen = {} -- names already handled this run
local whispered = {} -- [name] = GetTime() we whispered (awaiting reply)
local recruiters = {} -- [name] = GetTime() last presence ping (others)
local hideSoon = false
local inCombat = false
local classes = {} -- our faction's class names
local backoffUntil = 0
local presenceTimer = 0
local whoTimer, inviteTimer, whoTimeout = 0, 0, 0
local whoRetries = 0 -- consecutive timeouts on the current band before we give up on it
local stats = { contacted=0, invited=0, whispered=0, guilded=0, scanned=0, queries=0, dropped=0, cooldown=0, collected=0 }
local function Print(msg)
DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99Rawcruiter|r: "..msg)
end
local function clamp(v, a, b)
if v < a then return a elseif v > b then return b else return v end
end
local function CountTable(t)
local n = 0
for _ in t do n = n + 1 end
return n
end
-- Stop the default UI's Who panel from popping during a run. We unregister
-- WHO_LIST_UPDATE on FriendsFrame (our own frame still receives it) so the
-- window never opens at all -- no open-then-hide flicker. Restored when we stop.
local whoSuppressed = false
local function SuppressWho(on)
if not FriendsFrame then return end
if on then
if not whoSuppressed and GuildRecruiter_Settings.hideWho then
FriendsFrame:UnregisterEvent("WHO_LIST_UPDATE")
whoSuppressed = true
end
elseif whoSuppressed then
FriendsFrame:RegisterEvent("WHO_LIST_UPDATE")
whoSuppressed = false
end
end
-- ---------------------------------------------------------------------------
-- Settings defaults
-- ---------------------------------------------------------------------------
local function Defaults()
local s = GuildRecruiter_Settings
if not s.inviteDelay then s.inviteDelay = 1 end -- base seconds between contacts
if not s.whoDelay then s.whoDelay = 3 end -- base seconds between /who
if not s.reinviteDays then s.reinviteDays = 14 end -- 0 = never re-invite
if not s.history then s.history = {} end -- [name] = last contact time()
if s.hideWho == nil then s.hideWho = true end
if not s.inviteMethod then s.inviteMethod = "auto" end
if not s.mode then s.mode = "invite" end -- invite|whisper|whisperinvite
if not s.whisperMsg or s.whisperMsg == OLD_WHISPER or s.whisperMsg == LONG_WHISPER then s.whisperMsg = NEW_WHISPER end
if not s.minLevel then s.minLevel = 1 end
if not s.maxLevel then s.maxLevel = 60 end
if not s.classFilter then s.classFilter = nil end -- nil = all classes; else set of lowercase names
if not s.blacklist then s.blacklist = {} end -- [lowername] = true
if not s.sessionCap then s.sessionCap = 0 end -- 0 = unlimited contacts per run
if s.jitter == nil then s.jitter = false end
if s.skipCombat == nil then s.skipCombat = true end
if s.skipInstance == nil then s.skipInstance = true end
if s.guildSync == nil then s.guildSync = true end
if s.quietWho == nil then s.quietWho = true end -- hide /who chat spam during a run
if not s.replyMode then -- how whisper replies are judged: notno|yesonly|any
s.replyMode = (s.affirmOnly == false) and "any" or "notno" -- migrate old affirmOnly
end
if not s.negatives then -- editable refusal-word list ("notno" mode skips these)
s.negatives = {}
for i = 1, table.getn(NEGATIVES) do s.negatives[NEGATIVES[i]] = true end
end
if not s.affirmatives then -- editable yes-word list ("yesonly" mode requires these)
s.affirmatives = {}
for i = 1, table.getn(AFFIRM_DEFAULTS) do s.affirmatives[AFFIRM_DEFAULTS[i]] = true end
end
if not s.profiles then s.profiles = {} end
if not s.tally then s.tally = {} end
if not s.tally.totals then s.tally.totals = { invited=0, whispered=0, joined=0, declined=0 } end
if not s.tally.days then s.tally.days = {} end
if not s.varStats then s.varStats = {} end -- [profile] = {contacted,joined,declined}
if s.abOn == nil then s.abOn = false end -- simultaneous A/B: deal each contact a variant
if not s.abWeightA then s.abWeightA = 1 end -- frequency weight of the control (variant A)
if s.collectOnly == nil then s.collectOnly = false end -- scan into a list instead of contacting
if s.autoScan == nil then s.autoScan = false end -- loop: after a scan sweep finishes, rest then sweep again
if not s.autoScanDelay then s.autoScanDelay = 60 end -- seconds to rest between auto-rescan cycles
if s.debug == nil then s.debug = false end -- print per-/who diagnostics during a scan
if not s.candidates then s.candidates = {} end -- [name] = {t, level, class}; persists across logout
if not s.dataset then s.dataset = {} end -- [name] = {race, class, level, t}; every player seen in /who, for name-corpus building (persists across logout)
-- abVariants are the CHALLENGERS only (B/C/D). Variant A is always the live
-- Settings tab (the control), so it is never stored here.
if not s.abVariants then s.abVariants = {} end
local chal, used = {}, {} -- max 3 challengers; names B/C/D, kept stable
for i = 1, table.getn(s.abVariants) do
local v = s.abVariants[i]
if type(v) == "string" then -- legacy: flagged-profile-name string -> table
local p = s.profiles and s.profiles[v]
v = { mode = p and p.mode, whisperMsg = p and p.whisperMsg,
inviteMethod = p and p.inviteMethod, replyMode = p and p.replyMode }
end
-- "A" used to be a stored variant; it's the control now, so drop it. Keep an
-- already-valid, unused B/C/D name as-is (so stats stay attached); only mint
-- a new name for a bad/missing/duplicate one. Never reshuffle on load.
if v.name ~= "A" and table.getn(chal) < 3 then
local nm = v.name
if (nm ~= "B" and nm ~= "C" and nm ~= "D") or used[nm] then
nm = nil
for k = 1, 3 do local c = string.sub("BCD", k, k); if not used[c] then nm = c; break end end
end
v.name = nm; used[nm] = true
tinsert(chal, v)
end
end
s.abVariants = chal
if not s.minimapAngle then s.minimapAngle = 210 end
-- prune history past the cooldown so the saved table can't grow forever
if s.reinviteDays > 0 then
local cutoff = time() - s.reinviteDays * 86400
for n, e in s.history do
local et = (type(e) == "table") and e.t or e -- entries are {t,p} now; tolerate old numbers
if (et or 0) < cutoff then s.history[n] = nil end
end
else
-- reinviteDays == 0 ("never re-invite") skips time-pruning, so history grows
-- forever. Cap the SavedVariables at ~1 year of entries to bound memory.
local cutoff = time() - 365 * 86400
for n, e in s.history do
local et = (type(e) == "table") and e.t or e
if (et or 0) < cutoff then s.history[n] = nil end
end
end
end
-- ---------------------------------------------------------------------------
-- Analytics tally (persisted: lifetime totals + per-day buckets)
-- ---------------------------------------------------------------------------
local function Today()
if type(date) == "function" then return date("%Y%m%d") end
return "alltime"
end
local function TallyBump(field)
local t = GuildRecruiter_Settings.tally
if not t then return end
t.totals[field] = (t.totals[field] or 0) + 1
local d = Today()
if not t.days[d] then t.days[d] = { invited=0, whispered=0, joined=0, declined=0 } end
t.days[d][field] = (t.days[d][field] or 0) + 1
end
-- ---------------------------------------------------------------------------
-- Affirmative-reply detection
-- ---------------------------------------------------------------------------
local function Normalize(s)
s = string.lower(s or "")
s = string.gsub(s, "'", "") -- drop apostrophes so "i'm" == "im"
s = string.gsub(s, "[^%a%d ]", " ") -- other punctuation -> space
s = " " .. s .. " "
s = string.gsub(s, "%s+", " ")
return s
end
-- Treat a whisper reply as interest UNLESS it's a clear refusal. Requiring an
-- exact "yes" missed "sure", "y", "ok", typos, etc., so we invert: any reply
-- that doesn't contain a negative phrase counts (they bothered to whisper back).
-- decide whether a whisper reply should trigger an invite, per the reply policy
local function ShouldInvite(reply, vname)
local v = GuildRecruiter_VariantByName(vname) -- the variant that contacted them
local mode = (v and v.replyMode) or GuildRecruiter_Settings.replyMode or "notno"
if mode == "any" then return true end
local norm = Normalize(reply)
if mode == "yesonly" then
for phrase in (GuildRecruiter_Settings.affirmatives or {}) do
if string.find(norm, " " .. phrase .. " ", 1, true) then return true end
end
return false
end
-- "notno": invite unless a refusal word appears
for phrase in (GuildRecruiter_Settings.negatives or {}) do
if string.find(norm, " " .. phrase .. " ", 1, true) then return false end
end
return true
end
-- ---------------------------------------------------------------------------
-- Guild join / decline detection (locale-safe via the client's format strings)
-- ---------------------------------------------------------------------------
local function ToPattern(s)
if not s then return nil end
s = string.gsub(s, "%%s", "\001") -- protect the name slot
s = string.gsub(s, "([%^%$%(%)%.%[%]%*%+%-%?%%])", "%%%1") -- escape magic chars
s = string.gsub(s, "\001", "(.+)") -- name slot -> capture
return "^" .. s .. "$"
end
-- built from the client's format strings when present, with an enUS fallback so
-- detection still works if those globals are absent/renamed on this core
local JOIN_PAT = ToPattern(ERR_GUILD_JOIN_S) or "^(.+) has joined the guild"
local DECLINE_PAT = ToPattern(ERR_GUILD_DECLINE_S) or "^(.+) declines your guild invitation"
-- ---------------------------------------------------------------------------
-- Skip rules: recently contacted, blacklisted
-- ---------------------------------------------------------------------------
local function RecentlyInvited(name)
local s = GuildRecruiter_Settings
local e = s.history and s.history[name]
if not e then return false end
local days = s.reinviteDays
if days <= 0 then return true end
local t = (type(e) == "table") and e.t or e -- {t,p} now; tolerate old numbers
return (time() - (t or 0)) < days * 86400
end
local function Blacklisted(name)
local bl = GuildRecruiter_Settings.blacklist
return bl and bl[string.lower(name)] and true or false
end
-- ---------------------------------------------------------------------------
-- Guild coordination (addon messages over GUILD)
-- ---------------------------------------------------------------------------
local RecomputeBand -- forward decl (defined below; used by the message handler)
local function Broadcast(payload)
if GuildRecruiter_Settings.guildSync and IsInGuild() then
SendAddonMessage(SYNC_PREFIX, "V1 "..payload, "GUILD")
end
end
-- names of all recruiters currently active (self when running, plus live peers)
local function ActiveNames()
local me = UnitName("player")
local names = {}
if running then tinsert(names, me) end
for n, _ in recruiters do
if n ~= me then tinsert(names, n) end
end
if table.getn(names) == 0 then tinsert(names, me) end -- always at least self
table.sort(names)
return names
end
-- divide [minLevel, maxLevel] evenly among the active recruiters; take my chunk
RecomputeBand = function()
local s = GuildRecruiter_Settings
local loL, hiL = s.minLevel, s.maxLevel
if loL > hiL then loL, hiL = hiL, loL end -- tolerate reversed saved bounds
if not s.guildSync or not IsInGuild() then
myLo, myHi = loL, hiL
else
local names = ActiveNames()
local total = table.getn(names)
local me = UnitName("player")
local idx = 1
for i = 1, total do if names[i] == me then idx = i end end
local span = hiL - loL + 1
local per = math.ceil(span / total)
myLo = loL + (idx - 1) * per
myHi = myLo + per - 1
if myHi > hiL then myHi = hiL end
if myLo > hiL then myLo = hiL + 1 end -- nothing left for me
end
-- if my band grew while idle but the run is alive, resume the sweep -- but
-- ONLY for a scan run. A send/invite-all run is running with scanning=false on
-- purpose; without this guard Resume() (or a peer's HI/BYE) would flip it into
-- a /who scan.
if running and GuildRecruiter_runKind == "scan" and not scanning and lo <= myHi then scanning = true end
end
local function OnAddonMessage()
if arg1 ~= SYNC_PREFIX then return end
local me = UnitName("player")
local sender = arg4
if sender == me then return end -- ignore our own echo
local _, _, ver, kind, rest = string.find(arg2 or "", "^(%S+)%s+(%S+)%s*(.*)$")
if ver ~= "V1" then return end
if kind == "INV" then
if rest and rest ~= "" then
if not GuildRecruiter_Settings.history then GuildRecruiter_Settings.history = {} end
GuildRecruiter_Settings.history[rest] = { t = time(), p = "(remote)" } -- guild-wide dedup
seen[rest] = true
end
elseif kind == "HI" then
local changed = (recruiters[sender] == nil)
recruiters[sender] = GetTime()
if changed then RecomputeBand() end
elseif kind == "BYE" then
if recruiters[sender] then
recruiters[sender] = nil
RecomputeBand()
end
end
end
-- ---------------------------------------------------------------------------
-- Contacting: invite and/or whisper
-- ---------------------------------------------------------------------------
local function RunChatCommand(text)
local eb = ChatFrameEditBox or (DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.editBox)
if not eb then return false end
eb:SetText(text)
if ChatEdit_SendText then
ChatEdit_SendText(eb, 0)
else
local handler = eb:GetScript("OnEnterPressed")
if handler then
local prev = this
this = eb; handler(); this = prev
end
end
eb:SetText("")
return true
end
-- GuildInviteByName is Turtle's canonical name-taking invite; auto prefers it
-- because bare GuildInvite() invites your TARGET on some cores.
local function DoGuildInvite(name, method)
local m = method or GuildRecruiter_Settings.inviteMethod or "auto"
if m == "byname" and type(GuildInviteByName) == "function" then
GuildInviteByName(name)
elseif m == "invite" and type(GuildInvite) == "function" then
GuildInvite(name)
elseif m == "chat" then
RunChatCommand("/ginvite "..name)
elseif type(GuildInviteByName) == "function" then
GuildInviteByName(name)
elseif type(GuildInvite) == "function" then
GuildInvite(name)
else
RunChatCommand("/ginvite "..name)
end
end
local function WhisperBody(name, msg)
msg = msg or GuildRecruiter_Settings.whisperMsg or ""
local gname = GetGuildInfo("player") or "our guild"
msg = string.gsub(msg, "%%p", name)
msg = string.gsub(msg, "%%g", gname)
-- hard cap: a chat message over 255 chars DISCONNECTS the client. Token expansion
-- (%p/%g) can push an at-the-limit template over, so clamp after expansion.
if string.len(msg) > 255 then msg = string.sub(msg, 1, 255) end
return msg
end
-- global so contact/event code adds no upvalues; per-profile conversion counters
function GuildRecruiter_VarBump(profile, field)
profile = profile or "(live)"
local vs = GuildRecruiter_Settings.varStats
if not vs then return end
if not vs[profile] then vs[profile] = { contacted = 0, joined = 0, declined = 0 } end
vs[profile][field] = (vs[profile][field] or 0) + 1
end
-- A/B model: variant "A" is the live Settings config (the control) and is NOT
-- stored; s.abVariants holds only the challengers (B/C/D). A/B is "active" once
-- it's switched on AND at least one challenger exists (so the pool is A + >=1).
function GuildRecruiter_ABActive()
local s = GuildRecruiter_Settings
return s.abOn and s.abVariants and table.getn(s.abVariants) >= 1
end
-- deal a variant to a contact, weighted by each variant's frequency. nil =
-- control A (live). A's weight is s.abWeightA; each challenger has v.weight.
function GuildRecruiter_PickVariant()
if not GuildRecruiter_ABActive() then return nil end
local s = GuildRecruiter_Settings
local av = s.abVariants
local wA = s.abWeightA or 1
local total = wA
for i = 1, table.getn(av) do total = total + (av[i].weight or 1) end
local r = random(1, total)
if r <= wA then return nil end -- landed in the control's share
r = r - wA
for i = 1, table.getn(av) do
local w = av[i].weight or 1
if r <= w then return av[i] end
r = r - w
end
return nil
end
-- adjust a variant's frequency weight (variant=nil means the control A). Clamped
-- 1..9. GLOBAL so the A/B weight buttons add no upvalue to the panel builder.
function GuildRecruiter_BumpWeight(variant, delta)
local s = GuildRecruiter_Settings
if variant then variant.weight = clamp((variant.weight or 1) + delta, 1, 9)
else s.abWeightA = clamp((s.abWeightA or 1) + delta, 1, 9) end
GuildRecruiter_RefreshOpen()
end
-- tooltip for the A/B weight buttons (so right-click-to-decrement is discoverable)
function GuildRecruiter_WeightTip(btn)
GameTooltip:SetOwner(btn, "ANCHOR_RIGHT")
GameTooltip:SetText("Frequency weight")
GameTooltip:AddLine("Left-click: +1 Right-click: -1", 1, 1, 1)
GameTooltip:AddLine("Higher weight = dealt to a larger share of contacts.", 0.7, 0.7, 0.7, 1)
GameTooltip:Show()
end
-- per-contact settings for a variant table (blank fields fall back to live)
function GuildRecruiter_CfgOf(v)
local s = GuildRecruiter_Settings
if not v then v = s end -- nil variant -> live settings
return {
mode = v.mode or s.mode or "invite",
whisperMsg = v.whisperMsg or s.whisperMsg,
inviteMethod = v.inviteMethod or s.inviteMethod,
replyMode = v.replyMode or s.replyMode or "notno",
}
end
-- find a variant table by name (to credit a reply/join back to it)
function GuildRecruiter_VariantByName(nm)
local av = GuildRecruiter_Settings.abVariants
if nm and av then
for i = 1, table.getn(av) do if av[i].name == nm then return av[i] end end
end
return nil
end
local function RecordHandled(name, vname)
if not GuildRecruiter_Settings.history then GuildRecruiter_Settings.history = {} end
GuildRecruiter_Settings.history[name] = { t = time(), p = vname }
Broadcast("INV "..name)
end
-- perform the configured contact action for one name (variant chosen per A/B)
local function Contact(name)
local v = GuildRecruiter_PickVariant() -- challenger table, or nil = control A
local cfg = GuildRecruiter_CfgOf(v)
-- during an A/B test a nil pick is the control, credited as "A"; otherwise
-- (A/B off) it's just the active profile, for sequential profile comparison.
local vname = (v and v.name) or (GuildRecruiter_ABActive() and "A") or GuildRecruiter_Settings.activeProfile
if cfg.mode == "whisper" then
SendChatMessage(WhisperBody(name, cfg.whisperMsg), "WHISPER", nil, name)
stats.whispered = stats.whispered + 1; TallyBump("whispered")
RecordHandled(name, vname)
elseif cfg.mode == "whisperinvite" then
SendChatMessage(WhisperBody(name, cfg.whisperMsg), "WHISPER", nil, name)
stats.whispered = stats.whispered + 1; TallyBump("whispered")
whispered[name] = { t = GetTime(), v = vname }
RecordHandled(name, vname)
else
DoGuildInvite(name, cfg.inviteMethod)
stats.invited = stats.invited + 1; TallyBump("invited")
RecordHandled(name, vname)
end
stats.contacted = stats.contacted + 1
GuildRecruiter_VarBump(vname, "contacted")
end
-- ---------------------------------------------------------------------------
-- Scan core (no race split; class is the finest subdivision)
-- ---------------------------------------------------------------------------
local function BuildFilter(s)
local parts = {}
if s.lo == s.hi then tinsert(parts, "" .. s.lo)
else tinsert(parts, s.lo .. "-" .. s.hi) end
if s.class then tinsert(parts, 'c-"' .. s.class .. '"') end
return table.concat(parts, " ")
end
local function NextQuery()
if table.getn(pending) > 0 then
current = tremove(pending, 1)
return true
end
if lo < myLo then lo = myLo end
if lo <= myHi and lo <= 60 then
local hi = lo + width - 1
if hi > myHi then hi = myHi end
if hi > 60 then hi = 60 end
current = { lo = lo, hi = hi, sweep = true }
return true
end
return false
end
local function SendNextWho()
if not NextQuery() then
-- Sweep of my level band is done. If auto-rescan is on (and this is a scan
-- run, not a send/blast), rest for autoScanDelay and then sweep again instead
-- of ending -- catches players who log in later. `seen` is kept across cycles
-- so a repeat sweep only acts on newly-appeared guildless players, and the
-- persistent re-invite cooldown still guards against re-contacting. We keep
-- scanning=true through the rest so the run stays alive (the drain-complete
-- branch won't fire) and contacts keep draining in parallel.
if GuildRecruiter_runKind == "scan" and GuildRecruiter_Settings.autoScan then
local gap = GuildRecruiter_Settings.autoScanDelay or 60
GuildRecruiter_rescanWait = gap
Print("Scan cycle done ("..stats.queries.." queries). Rescanning in "..math.floor(gap).."s. /gr stop to end.")
return
end
scanning = false
if GuildRecruiter_Settings.collectOnly then
Print("Scan complete: "..stats.queries.." queries, collected "..stats.collected.." (list now "..CountTable(GuildRecruiter_Settings.candidates).."). Send from the Lists tab.")
else
Print("Scan complete: "..stats.queries.." queries, "..table.getn(contactQueue).." left to contact.")
end
return
end
awaiting = true
whoTimeout = WHO_TIMEOUT
stats.queries = stats.queries + 1
SendWho(BuildFilter(current))
end
local function ClassAllowed(class)
local cf = GuildRecruiter_Settings.classFilter
if not cf then return true end
return cf[string.lower(class or "")] and true or false
end
local function ProcessWho()
local me = UnitName("player")
local num = GetNumWhoResults()
local inBand = 0
for i = 1, num do
-- 1.12: name, guild, level, race, class, zone
local name, guild, level, race, class = GetWhoInfo(i)
if name then
stats.scanned = stats.scanned + 1
-- Name-corpus collector: record EVERY player seen in /who (guilded or not),
-- keyed by name -> race/class/level/last-seen. Independent of the contact
-- pipeline so it builds a full race+class-tagged dataset. All global-keyed,
-- so it adds no upvalues to ProcessWho. (Gender is not available from /who.)
if name ~= me and GuildRecruiter_Settings.dataset then
local rec = GuildRecruiter_Settings.dataset[name] or {}
rec.race = race or rec.race
rec.class = class or rec.class
rec.level = level or rec.level
rec.t = time()
GuildRecruiter_Settings.dataset[name] = rec
end
if current and level and level >= current.lo and level <= (current.hi or current.lo)
and (not current.class or class == current.class) then inBand = inBand + 1 end
if name == me or seen[name] then
-- skip
elseif guild and guild ~= "" then
stats.guilded = stats.guilded + 1; seen[name] = true
elseif Blacklisted(name) then
seen[name] = true
elseif not ClassAllowed(class) then
seen[name] = true
elseif RecentlyInvited(name) then
stats.cooldown = stats.cooldown + 1; seen[name] = true
else
seen[name] = true
if GuildRecruiter_Settings.collectOnly then -- build a list now, contact later
if not GuildRecruiter_Settings.candidates[name] then
GuildRecruiter_Settings.candidates[name] = { t = time(), level = level, class = class }
stats.collected = stats.collected + 1
end
else
tinsert(contactQueue, name)
end
end
end
end
-- Learn the server's real /who cap instead of assuming 49. The most rows the
-- server has ever handed us is at-or-below its true cap, so a query that hits
-- that ceiling is presumed truncated and gets subdivided. CAP_HINT is only an
-- upper bound for the first few queries. This is coverage-safe: it can only
-- over-subdivide (slower but complete), never miss people on a low-cap server.
if num > observedCap then observedCap = num end
local ceiling = (observedCap < CAP_HINT) and observedCap or CAP_HINT
local capped = (num >= ceiling and num >= 5)
local c = current
local band = c and (c.lo..(c.hi and c.hi ~= c.lo and ("-"..c.hi) or "")..(c.class and (" c-"..c.class) or "")) or "?"
-- CRITICAL: only the reply we're actively awaiting may move the sweep cursor.
-- A reply that lands after we already timed out (or any stray WHO_LIST_UPDATE)
-- still harvests players above -- that's safe, seen[] dedups -- but must NOT
-- touch lo/width. Otherwise a late "1-10 capped" reply rewrites the cursor
-- after the timeout already advanced it, and the sweep jumps forward in coarse
-- bands (1-10 -> 11-15 -> ...) instead of subdividing, so anyone past the /who
-- cap in a dense low-level band is never found.
if not awaiting then
if GuildRecruiter_Settings.debug then Print("who "..band.." results="..num.." (late/stray reply -- harvested only, cursor unchanged)") end
return
end
-- band sanity: the player's own manual /who can land while we're awaiting and be
-- mistaken for our reply (1.12 gives no query id). Our query filters server-side by
-- band, so OUR reply's results all fit the band; if NONE do, it's foreign -- harvest
-- only and keep awaiting our real reply (or let the timeout retry).
if num > 0 and c and inBand == 0 then
if GuildRecruiter_Settings.debug then Print("who "..band.." results="..num.." (none in band -- foreign /who? cursor unchanged)") end
return
end
if GuildRecruiter_Settings.debug then
Print("who "..band.." results="..num.." cap~="..ceiling.." capped="..(capped and "Y" or "n"))
end
if c and c.sweep then
if capped then
if c.lo < c.hi then
-- re-scan THIS band from its start at half width. Absolute positioning:
-- we do NOT trust `lo`, which the timeout path may have advanced.
lo = c.lo
width = math.floor((c.hi - c.lo + 1) / 2)
if width < 1 then width = 1 end
if GuildRecruiter_Settings.debug then Print(" -> capped: subdividing to "..lo.."-"..(lo + width - 1)) end
else
for i = 1, table.getn(classes) do
tinsert(pending, { lo = c.lo, hi = c.lo, class = classes[i] })
end
lo = c.lo + 1; width = 1
if GuildRecruiter_Settings.debug then Print(" -> lvl "..c.lo.." capped: splitting by class ("..table.getn(classes).." queries)") end
end
else
lo = c.hi + 1
if num < ceiling * 0.6 then width = math.floor(width * 1.7) + 1 end
local remain = myHi - lo + 1
if width > remain then width = remain end
if width < 1 then width = 1 end
end
elseif c and capped then
-- a single class at a single level is still capped -- take what we got
stats.dropped = stats.dropped + 1
if GuildRecruiter_Settings.debug then Print(" -> lvl "..c.lo.." c-"..(c.class or "?").." still capped; some dropped") end
end
awaiting = false
whoRetries = 0
whoTimer = GuildRecruiter_Settings.whoDelay
end
-- Re-arm the level-band sweep for another auto-rescan cycle. GLOBAL so it costs
-- GR_OnUpdate zero upvalues (Lua 5.0 caps them at 32/function). We reset the
-- sweep position but deliberately KEEP `seen` (and the learned observedCap and
-- cumulative stats) so a repeat cycle only picks up newly-arrived guildless
-- players rather than re-processing everyone.
function GuildRecruiter_RestartScanCycle()
GuildRecruiter_rescanWait = 0
scanning, awaiting = true, false
current = nil
pending = {}
RecomputeBand()
lo, width, whoRetries = myLo, START_WIDTH, 0
whoTimer = 0
stats.cycles = (stats.cycles or 0) + 1
if GuildRecruiter_Settings.debug then Print("Auto-rescan: starting cycle "..stats.cycles.." on levels "..myLo.."-"..myHi..".") end
end
-- ---------------------------------------------------------------------------
-- Pacing helpers
-- ---------------------------------------------------------------------------
local function Pace(base)
if GuildRecruiter_Settings.jitter then
-- random(0,120)/100 is in [0,1.2]; gives a delay uniformly in [base, base*2.2]
return base + (random(0, 120) / 100) * base
end
return base
end
local function InstanceBlocked()
if not GuildRecruiter_Settings.skipInstance then return false end
if type(IsInInstance) == "function" then
local inside = IsInInstance()
if inside and inside ~= 0 and inside ~= "none" then return true end
end
return false
end
-- can we send a contact/invite right now?
local function SendsBlocked()
if GetTime() < backoffUntil then return true end
if GuildRecruiter_Settings.skipCombat and inCombat then return true end
if InstanceBlocked() then return true end
return false
end
local function CapReached()
local cap = GuildRecruiter_Settings.sessionCap
return cap and cap > 0 and stats.contacted >= cap
end
-- ---------------------------------------------------------------------------
-- Error handling + abort
-- ---------------------------------------------------------------------------
local function Abort(reason)
running, scanning, awaiting, paused = false, false, false, false
GuildRecruiter_rescanWait = 0
SuppressWho(false)
if type(SetWhoToUI) == "function" then pcall(SetWhoToUI, 0) end -- RestoreWho (defined later; inline to avoid a forward ref)
Print("|cffff4040Stopped on error:|r "..tostring(reason))
end
-- ---------------------------------------------------------------------------
-- Events
-- ---------------------------------------------------------------------------
local InitMinimap -- forward decl
local function GR_OnEvent()
if event == "VARIABLES_LOADED" then
GuildRecruiter_Settings = GuildRecruiter_Settings or {}
Defaults()
InitMinimap()
local api = (type(GuildInviteByName) == "function" and "GuildInviteByName")
or (type(GuildInvite) == "function" and "GuildInvite")
or "/ginvite (chat fallback)"
Print("v"..VERSION.." loaded. Invite API: "..api..". /gr for commands, /gr config for settings.")
elseif event == "WHO_LIST_UPDATE" and running then
ProcessWho()
if GuildRecruiter_Settings.hideWho then hideSoon = true end
elseif event == "CHAT_MSG_ADDON" then
OnAddonMessage()
elseif event == "CHAT_MSG_SYSTEM" then
local raw = arg1 or ""
local m = string.lower(raw)
if string.find(m, "too quickly") or string.find(m, "too many")
or (string.find(m, "wait") and string.find(m, "invit")) then
backoffUntil = GetTime() + BACKOFF_SECS
Print("|cffffcc00Server throttle detected -- pausing sends "..BACKOFF_SECS.."s.|r")
end
-- analytics: attribute a join to us only if we contacted them; declines of
-- our invitation are always ours
local hist = GuildRecruiter_Settings.history
if JOIN_PAT then
local _, _, jn = string.find(raw, JOIN_PAT)
local e = jn and hist and hist[jn]
if e then
TallyBump("joined")
local p = (type(e) == "table") and e.p or nil
if p ~= "(remote)" then GuildRecruiter_VarBump(p, "joined") end -- a peer's contact isn't our variant
end
end
if DECLINE_PAT then
local _, _, dn = string.find(raw, DECLINE_PAT)
if dn then
TallyBump("declined")
local e = hist and hist[dn]
local p = (type(e) == "table") and e.p or nil
if e and p ~= "(remote)" then GuildRecruiter_VarBump(p, "declined") end
end
end
elseif event == "CHAT_MSG_WHISPER" then
local sender = arg2
local w = sender and whispered[sender]
if w then
if ShouldInvite(arg1, w.v) then -- judge with the variant that whispered them
whispered[sender] = nil
tinsert(replyQueue, sender) -- reply passes the policy: invite (paced)
end
-- otherwise keep waiting -- a later, clearer "yes" still triggers it
end
elseif event == "PLAYER_REGEN_DISABLED" then
inCombat = true
elseif event == "PLAYER_REGEN_ENABLED" then
inCombat = false
end
end
f:RegisterEvent("VARIABLES_LOADED")
f:RegisterEvent("WHO_LIST_UPDATE")
f:RegisterEvent("CHAT_MSG_ADDON")
f:RegisterEvent("CHAT_MSG_SYSTEM")
f:RegisterEvent("CHAT_MSG_WHISPER")
f:RegisterEvent("PLAYER_REGEN_DISABLED")
f:RegisterEvent("PLAYER_REGEN_ENABLED")
f:SetScript("OnEvent", function()
local ok, err = pcall(GR_OnEvent)
if not ok then Abort(err) end
end)
-- ---------------------------------------------------------------------------
-- Main loop
-- ---------------------------------------------------------------------------
local function HasOutstandingWhispers()
for _, w in whispered do
if GetTime() - (w.t or 0) < WHISPER_WAIT then return true end
end
return false
end
-- GLOBAL on purpose: extracting the contacting/draining block here keeps
-- GR_OnUpdate's upvalue count safely under the Lua 5.0 limit of 32.
function GuildRecruiter_Drain(e)
-- contacting / inviting (paced; replies take priority)
if table.getn(replyQueue) > 0 or table.getn(contactQueue) > 0 then
inviteTimer = inviteTimer - e
if inviteTimer <= 0 and not SendsBlocked() then
if table.getn(replyQueue) > 0 then
local name = tremove(replyQueue, 1)
DoGuildInvite(name)
if not GuildRecruiter_Settings.history then GuildRecruiter_Settings.history = {} end
local pe = GuildRecruiter_Settings.history[name] -- keep the profile that contacted them
GuildRecruiter_Settings.history[name] = { t = time(), p = (type(pe) == "table") and pe.p or GuildRecruiter_Settings.activeProfile }
stats.invited = stats.invited + 1
TallyBump("invited")
Print("Invited (replied) "..name.." ("..stats.invited..")")
inviteTimer = Pace(GuildRecruiter_Settings.inviteDelay)
elseif not CapReached() then
local name = tremove(contactQueue, 1)
if GuildRecruiter_runKind == "blast" then -- invite-all: straight guild invite, fast
DoGuildInvite(name)
if not GuildRecruiter_Settings.history then GuildRecruiter_Settings.history = {} end
GuildRecruiter_Settings.history[name] = { t = time(), p = GuildRecruiter_Settings.activeProfile }
Broadcast("INV "..name) -- share with peers (guild-wide dedup), like RecordHandled
stats.invited = stats.invited + 1; stats.contacted = stats.contacted + 1
TallyBump("invited")
Print("Invited "..name.." ("..stats.invited..")")
else
Contact(name)
Print("Contacted "..name.." ("..stats.contacted..")")
end
if GuildRecruiter_Settings.candidates then GuildRecruiter_Settings.candidates[name] = nil end -- collected -> contacted
inviteTimer = (GuildRecruiter_runKind == "blast") and INVITE_MIN or Pace(GuildRecruiter_Settings.inviteDelay)
else
-- session cap hit with names still queued: end the run instead of hanging
-- "Running" forever (the queue kept this branch alive but nothing drained)
Print("Session cap ("..GuildRecruiter_Settings.sessionCap..") reached -- dropping "..table.getn(contactQueue).." queued name(s).")
contactQueue = {}
scanning = false
GuildRecruiter_rescanWait = 0
end
end
elseif not scanning then
-- ANY outstanding whisper (incl. an A/B variant's whisperinvite when the global
-- mode differs) keeps the run alive to catch late replies -- else those replies
-- would arrive with running=false and the invites would be dropped
if HasOutstandingWhispers() then
-- keep waiting
else
running = false
local kind = GuildRecruiter_runKind
GuildRecruiter_runKind = nil
SuppressWho(false)
if type(SetWhoToUI) == "function" then pcall(SetWhoToUI, 0) end -- restore manual /who to chat (RestoreWho is defined later in the file)
Broadcast("BYE")
if kind == "blast" or kind == "send" then
Print("Done. "..(kind == "blast" and "Invited " or "Contacted ")..stats.contacted.." of "..(GuildRecruiter_runTotal or stats.contacted)..". "..CountTable(GuildRecruiter_Settings.candidates).." candidate(s) remain.")
elseif GuildRecruiter_Settings.collectOnly and stats.contacted == 0 then
Print("Done collecting. List now has "..CountTable(GuildRecruiter_Settings.candidates).." candidate(s). Send them from the Lists tab.")
else
Print("Done. Contacted "..stats.contacted.." ("..stats.invited.." invited, "..stats.whispered.." whispered), skipped "..stats.guilded.." guilded, "..stats.queries.." queries.")
end
end
end
end
local function GR_OnUpdate()
if not running or paused then return end
local e = arg1
if hideSoon then
hideSoon = false
if FriendsFrame and FriendsFrame:IsVisible() then HideUIPanel(FriendsFrame) end
end
-- presence ping + peer pruning
if GuildRecruiter_Settings.guildSync and IsInGuild() then
presenceTimer = presenceTimer - e
if presenceTimer <= 0 then
Broadcast("HI")
presenceTimer = PRESENCE_INTERVAL
end
end
local pruned = false
for n, t in recruiters do
if GetTime() - t > PRESENCE_TTL then recruiters[n] = nil; pruned = true end
end
if pruned then RecomputeBand() end
-- forget whisper targets who never replied
for n, w in whispered do
if GetTime() - (w.t or 0) > WHISPER_WAIT then whispered[n] = nil end
end
-- scanning
if scanning then
if (GuildRecruiter_rescanWait or 0) > 0 then
-- auto-rescan rest between cycles; when it elapses, re-arm the sweep.
-- (globals => no upvalue cost to GR_OnUpdate.)
GuildRecruiter_rescanWait = GuildRecruiter_rescanWait - e
if GuildRecruiter_rescanWait <= 0 then GuildRecruiter_RestartScanCycle() end
elseif awaiting then