Repository navigation
Expand file tree
/
Copy pathDebug.lua
More file actions
755 lines (671 loc) · 29.3 KB
/
Copy pathDebug.lua
File metadata and controls
755 lines (671 loc) · 29.3 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
setfenv(1, _G.SlackHacks)
local module = Self:NewModule("Debug", "AceEvent-3.0")
Self.Debug = module
Self.ErrorLog = module -- backwards-compatibility alias
--[[
Reference addons for this feature (both installed locally, readable via read_file/run_in_terminal):
Interface\AddOns\!BugGrabber\BugGrabber.lua
Interface\AddOns\BugSack\core.lua
BugGrabber installs itself as THE global error handler (`real_seterrorhandler(grabError)`) and then makes
`seterrorhandler` a permanent no-op, so nothing else can ever replace the handler again. BugSack doesn't
fight over that either -- it just listens to BugGrabber's own public event
(`EventRegistry:RegisterCallback("BugGrabber.BugGrabbed", ...)`) and pulls the parsed error object via
`BugGrabber:GetErrorByID(id)`.
We copy that same additive pattern so BugSack/BugGrabber (or Blizzard's own default error handling, if
neither is installed) keep working completely unmodified:
- If BugGrabber is present, we only ever listen to its "BugGrabber.BugGrabbed" event.
- Otherwise, we chain onto whatever error handler is currently installed (`geterrorhandler()`), the same
technique BugGrabber itself uses -- our wrapper always calls through to it after recording.
Either way we only ever RECORD errors whose message/stack point at our own addon's files -- every other
addon's errors pass through completely untouched.
]]--
ERROR_LOG_COMM_PREFIX = "SlackHacksBug"
ERROR_LOG_ADDON_PATH_MARKERS = { "AddOns\\SlackHacks\\", "AddOns/SlackHacks/" }
GITHUB_NEW_ISSUE_URL = "https://github.com/Slackwise/SlackHacks/issues/new?labels=bug"
TARGET_GUILD_NAME = "Pulling Aggro IRL"
TARGET_CHARACTER_NAME = "Slack"
BNET_CHUNK_SIZE = 200
LOG_PURGE_MIN_HOURS = 1
LOG_PURGE_MAX_HOURS = 24 * 30 -- 30 days
-----------------------------------------------------------------------
-- Debug mode & general logging
-----------------------------------------------------------------------
--- Returns whether verbose debug logging is enabled.
---@return boolean - The persisted setting after initialization, or true for Slack during startup.
function isDebugging()
if isInitialized() then
return Self.db.global.logs and Self.db.global.logs.isDebugging
end
if isSlackwise() then
return true
else
return false
end
end
--- Toggles verbose debug logging and refreshes UI consumers of that setting.
---@return boolean - The new enabled state, or false before addon initialization.
function toggleDebugging()
if not isInitialized() then return false end
if not Self.db.global.logs then
Self.db.global.logs = { debug = {}, error = {} }
end
Self.db.global.logs.isDebugging = not Self.db.global.logs.isDebugging
if Self.db.global.logs.isDebugging then
print("SlackHacks Debugging ON")
else
print("SlackHacks Debugging OFF")
end
if Self.Buffs and Self.Buffs.Refresh then
Self.Buffs:Refresh()
end
local acr = LibStub("AceConfigRegistry-3.0", true)
if acr then
acr:NotifyChange("SlackHacks")
end
return Self.db.global.logs.isDebugging
end
--- Prints a timestamped debug message and optionally persists it with its varargs.
---@param message string - The message to print and store.
---@param ... any - Values written as separate argument records while debugging.
function log(message, ...)
if isDebugging() then
local timestamp = date("%Y-%m-%dT%H:%M:%S") -- ISO form
print(grey(timestamp) .. " " .. message)
if isInitialized() and Self.db.global.logs and Self.db.global.logs.debug then
table.insert(Self.db.global.logs.debug, { timestamp, message })
if arg then
for i, v in ipairs(arg) do
print("Arg " .. i .. " = " .. v)
table.insert(Self.db.global.logs.debug, { timestamp, "Arg " .. i .. " = " .. v })
end
end
end
end
end
-----------------------------------------------------------------------
-- Storage & Purging
-----------------------------------------------------------------------
--- Reports whether persistent error capture is currently enabled.
---@return boolean - True only after initialization and when the saved option is enabled.
local function isErrorLoggingEnabled()
return isInitialized() and db.global.logs and db.global.logs.errorLoggingEnabled
end
--- Returns the persistent error-log array, creating its containers when necessary.
---@return table|nil - The error entries, or nil before addon initialization.
local function errorLogTable()
if not isInitialized() then return nil end
if not db.global.logs then
db.global.logs = { debug = {}, error = {} }
end
if not db.global.logs.error then
db.global.logs.error = {}
end
return db.global.logs.error
end
--- Shared by debug logs (`{timestamp, message}` array-form entries) and error logs (`{timestamp = ...}`
--- named-field entries) -- both are pruned by the same "Log Purging" settings.
---@param logTable table - Array of log entries to filter.
---@param cutoff number - Unix timestamp; entries older than this are dropped.
---@return table - Entries at or after `cutoff` (an unparseable timestamp is kept defensively).
function purgeLogTable(logTable, cutoff)
local kept = {}
for _, entry in ipairs(logTable) do
local timestamp = entry.timestamp or entry[1]
local year, month, day, hour, min, sec = timestamp:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
local entryTime = year and time({ year = year, month = month, day = day, hour = hour, min = min, sec = sec })
if not entryTime or entryTime >= cutoff then
table.insert(kept, entry)
end
end
return kept
end
--- Removes debug and error entries older than the configured retention period.
function purgeOldLogs()
if not Self.db.global.logs or not Self.db.global.logs.logPurgeEnabled then
return
end
local purgeHours = Self.db.global.logs.logPurgeHours or 48
local cutoff = time() - (purgeHours * 60 * 60)
if Self.db.global.logs.debug then
Self.db.global.logs.debug = purgeLogTable(Self.db.global.logs.debug, cutoff)
end
if Self.db.global.logs.error then
Self.db.global.logs.error = purgeLogTable(Self.db.global.logs.error, cutoff)
end
end
--- Schedules log purging and an automatic report attempt after a short delay.
---@param shouldProcess boolean|nil - False suppresses processing for this call.
---@param delay number|nil - Delay in seconds; defaults to ten seconds.
function processLogs(shouldProcess, delay)
if shouldProcess == false or not isErrorLoggingEnabled() then
return
end
local waitSeconds = delay or 10
C_Timer.After(waitSeconds, function()
purgeOldLogs()
module:AttemptReport(function(success, reason)
if not success and not isSlackwise() then
local list = errorLogTable()
if list and #list > 0 then
print(grey(icon(14)) .. " SlackHacks: you have " .. #list .. " un-reported error(s). Use " .. grey("/slack bugs") .. " to view and report them.")
end
end
end)
end)
end
--- Ace3 method adapter for `processLogs`.
---@param self table - The Debug module; accepted for Ace3 method compatibility.
---@param ... any - Forwarded to `processLogs`.
module.ProcessLogs = function(self, ...) processLogs(...) end
--- Deletes all persisted verbose debug entries.
function clearDebugLogs()
if Self.db.global.logs and Self.db.global.logs.debug then
wipe(Self.db.global.logs.debug)
end
end
clearLogs = clearDebugLogs
--- Deletes all persisted captured-error entries.
function clearErrorLogs()
if Self.db.global.logs and Self.db.global.logs.error then
wipe(Self.db.global.logs.error)
end
end
-----------------------------------------------------------------------
-- Error recording
-----------------------------------------------------------------------
--- Tests whether an error message or stack trace points into SlackHacks.
---@param message string|nil - Error text.
---@param stack string|nil - Captured stack trace.
---@return boolean - True when either value contains a known addon path marker.
local function isOurAddonError(message, stack)
for _, marker in ipairs(ERROR_LOG_ADDON_PATH_MARKERS) do
if (message and message:find(marker, 1, true)) or (stack and stack:find(marker, 1, true)) then
return true
end
end
return false
end
--- Records one local SlackHacks error, coalescing repeated message/character pairs.
---@param message string - Error text.
---@param stack string|nil - Stack trace, when available.
local function recordError(message, stack)
if not isErrorLoggingEnabled() then return end
if not isOurAddonError(message, stack) then return end
local list = errorLogTable()
if not list then return end
local timestamp = date("%Y-%m-%dT%H:%M:%S") -- ISO form, matches the regular debug logs
local senderName = characterFullName(UnitName("player"), GetRealmName()) or UnitName("player")
for _, entry in ipairs(list) do
if entry.message == message and entry.senderName == senderName then
entry.timestamp = timestamp
entry.count = (entry.count or 1) + 1
return
end
end
table.insert(list, {
timestamp = timestamp,
message = message,
stack = stack,
count = 1,
senderName = senderName, -- the character/player this error happened to
})
if module:IsEnabled() then
module:AttemptReport()
end
end
--- Merges an error entry received from another player, de-duplicated the same way as `recordError`.
---@param entry table - Serialized remote error entry.
---@param senderName string - Character name used as the de-duplication owner.
---@return boolean - True if this was a new entry (not just a counter bump on an existing one).
local function recordReceivedEntry(entry, senderName)
local list = errorLogTable()
if not list then return false end
for _, existing in ipairs(list) do
if existing.message == entry.message and existing.senderName == senderName then
existing.timestamp = entry.timestamp or existing.timestamp
existing.count = math.max(existing.count or 1, entry.count or 1)
return false
end
end
table.insert(list, {
timestamp = entry.timestamp,
message = entry.message,
stack = entry.stack,
count = entry.count or 1,
senderName = senderName,
})
return true
end
-----------------------------------------------------------------------
-- Error capture (additive -- never disables BugSack/BugGrabber/Blizzard's own error handling)
-----------------------------------------------------------------------
-- Error capture is deliberately installed in one of two mutually exclusive ways. BugGrabber owns
-- the global handler when present, so listening to its public event avoids fighting it. Without
-- BugGrabber, our wrapper chains to the handler that was already installed and always calls it after
-- recording. This keeps the debugger observational: it adds an error log but does not become the
-- error-reporting system for the rest of the client.
local chainedErrorHandler
local errorCaptureInstalled = false
local listeningToBugGrabber = false
--- Captures an error for SlackHacks, then forwards it to the previously installed handler.
---@param message any - Error value supplied by the WoW error handler.
---@return any - Whatever the chained handler returns.
local function ourErrorHandler(message)
if isErrorLoggingEnabled() then
local ok, stack = pcall(debugstack, 2)
recordError(tostring(message), ok and stack or nil)
end
if chainedErrorHandler then
return chainedErrorHandler(message)
end
end
--- Installs additive error capture through BugGrabber's event or a chained global handler.
-- The BugGrabber branch is a workaround for its intentional `seterrorhandler` no-op.
local function installErrorCapture()
if errorCaptureInstalled or not isErrorLoggingEnabled() then return end
if _G.BugGrabber and EventRegistry and EventRegistry.RegisterCallback then
EventRegistry:RegisterCallback("BugGrabber.BugGrabbed", function(_, tableID)
local err = _G.BugGrabber:GetErrorByID(tableID)
if err then
recordError(tostring(err.message), err.stack)
end
end, module)
listeningToBugGrabber = true
else
chainedErrorHandler = geterrorhandler()
seterrorhandler(ourErrorHandler)
end
errorCaptureInstalled = true
end
--- Removes our event or chained-handler hook and restores the prior capture state.
local function uninstallErrorCapture()
if not errorCaptureInstalled then return end
if listeningToBugGrabber then
if EventRegistry and EventRegistry.UnregisterCallback then
EventRegistry:UnregisterCallback("BugGrabber.BugGrabbed", module)
end
listeningToBugGrabber = false
elseif geterrorhandler() == ourErrorHandler then
seterrorhandler(chainedErrorHandler)
end
chainedErrorHandler = nil
errorCaptureInstalled = false
end
-----------------------------------------------------------------------
-- Who's eligible to report their error log to Slack, and how to reach him
-----------------------------------------------------------------------
--- Returns whether the current client flavor may send error reports.
---@return boolean - True for Retail or Forever clients.
local function isEligibleToReportErrorLogs()
return isRetail() or isForever()
end
--- Looks for Slack currently online, either as a Battle.net (real-ID) friend playing WoW, or as a
--- character named "Slack" online in our shared guild. Battle.net is checked for both retail and Forever;
--- the guild check only ever matches if the local player is also in "Pulling Aggro IRL".
---@return string|nil, string|number|nil - "bnet" plus game account ID, or "guild" plus character name.
local function resolveSlackTarget()
if BNGetNumFriends and BNGetFriendInfo and C_BattleNet and C_BattleNet.GetAccountInfoByID then
for index = 1, BNGetNumFriends() do
local bnetIDAccount = BNGetFriendInfo(index)
local accountInfo = bnetIDAccount and C_BattleNet.GetAccountInfoByID(bnetIDAccount)
if accountInfo and accountInfo.battleTag == SLACKWISE_BATTLETAG then
local gameAccountInfo = accountInfo.gameAccountInfo
if gameAccountInfo and gameAccountInfo.isOnline
and gameAccountInfo.clientProgram == (BNET_CLIENT_WOW or "WoW")
and gameAccountInfo.gameAccountID then
return "bnet", gameAccountInfo.gameAccountID
end
end
end
end
if IsInGuild() and GetGuildInfo("player") == TARGET_GUILD_NAME then
for index = 1, GetNumGuildMembers() do
local name, _, _, _, _, _, _, _, online = GetGuildRosterInfo(index)
if name and online and sameName(name, TARGET_CHARACTER_NAME) then
return "guild", name
end
end
end
return nil
end
-----------------------------------------------------------------------
-- Reporting (chunked over a hidden addon channel, queued after combat)
-----------------------------------------------------------------------
--- Sends a serialized payload in Battle.net-sized chunks with start/middle/end markers.
---@param gameAccountID number - WoW game account receiving the data.
---@param text string - Serialized report payload.
---@param doneCallback fun(success:boolean) - Called after all chunks have been attempted.
local function sendBattleNetChunked(gameAccountID, text, doneCallback)
if not (C_BattleNet and C_BattleNet.SendGameData) then
doneCallback(false)
return
end
local SUCCESS = (Enum.SendAddonMessageResult and Enum.SendAddonMessageResult.Success) or 0
local total, pos, allOk = #text, 1, true
repeat
local endPos = math.min(pos + BNET_CHUNK_SIZE - 1, total)
local isFirst, isLast = pos == 1, endPos >= total
local marker = (isFirst and "\001") or "\002"
if isLast then marker = "\003" end
local result = C_BattleNet.SendGameData(gameAccountID, ERROR_LOG_COMM_PREFIX, marker .. text:sub(pos, endPos))
if result ~= SUCCESS then allOk = false end
pos = endPos + 1
until pos > total
doneCallback(allOk)
end
--- Sends the local error log to a selected Battle.net or guild target.
---@param kind string - `bnet` or `guild`.
---@param target string|number - Battle.net game account ID or guild character name.
---@param onComplete fun(success:boolean, reason:string|nil)|nil - Completion callback.
function module:ReportErrorLogs(kind, target, onComplete)
local callback = type(onComplete) == "function" and onComplete or nil
if not isErrorLoggingEnabled() then
if callback then callback(false, "logging_disabled") end
return
end
if InCombatLockdown() then
runAfterCombat(function() module:ReportErrorLogs(kind, target, callback) end)
if callback then callback(false, "in_combat") end
return
end
local list = errorLogTable()
if not list or #list == 0 then
if callback then callback(false, "no_logs") end
return
end
local payload = Self:Serialize({
sender = characterFullName(UnitName("player"), GetRealmName()) or UnitName("player"),
entries = list,
})
if kind == "bnet" then
sendBattleNetChunked(target, payload, function(success)
if success then clearErrorLogs() end
if callback then callback(success) end
end)
elseif kind == "guild" then
Self:SendCommMessage(ERROR_LOG_COMM_PREFIX, payload, "WHISPER", target, "BULK", function(_, sent, total)
if sent and total and sent >= total then
clearErrorLogs()
if callback then callback(true) end
end
end)
else
if callback then callback(false, "unknown_kind") end
end
end
module.SendErrorLogs = module.ReportErrorLogs
--- Finds an eligible target and reports local errors when one is online.
---@param onComplete fun(success:boolean, reason:string|nil)|nil - Completion callback.
function module:AttemptReport(onComplete)
local callback = type(onComplete) == "function" and onComplete or nil
if not isErrorLoggingEnabled() then
if callback then callback(false, "logging_disabled") end
return
end
if isSlackwise() then
if callback then callback(false, "is_slack") end
return
end -- never applicable to Slack's own client
if not isEligibleToReportErrorLogs() then
if callback then callback(false, "ineligible") end
return
end
local list = errorLogTable()
if not list or #list == 0 then
if callback then callback(false, "no_logs") end
return
end
local kind, target = resolveSlackTarget()
if not kind then
if callback then callback(false, "target_offline") end
return
end
module:ReportErrorLogs(kind, target, callback)
end
module.AttemptSend = module.AttemptReport
-----------------------------------------------------------------------
-- Receiving (only ever meaningful on Slack's own client, but harmless otherwise)
-----------------------------------------------------------------------
local toastFrame
--- Creates or updates the clickable notification shown for newly received errors.
---@param title string - Toast heading.
---@param body string - Toast detail text.
local function showToast(title, body)
if not toastFrame then
toastFrame = CreateFrame("Frame", "SlackHacksErrorToast", UIParent, "BackdropTemplate")
toastFrame:SetSize(280, 60)
toastFrame:SetPoint("BOTTOMRIGHT", UIParent, "BOTTOMRIGHT", -20, 60)
toastFrame:SetFrameStrata("DIALOG")
toastFrame:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = true, tileSize = 16, edgeSize = 16,
insets = { left = 4, right = 4, top = 4, bottom = 4 },
})
local icon = toastFrame:CreateTexture(nil, "ARTWORK")
icon:SetSize(32, 32)
icon:SetPoint("LEFT", toastFrame, "LEFT", 10, 0)
icon:SetTexture(SLACKHACKS_ICON)
local titleText = toastFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
titleText:SetPoint("TOPLEFT", icon, "TOPRIGHT", 8, -2)
titleText:SetPoint("TOPRIGHT", toastFrame, "TOPRIGHT", -10, -2)
titleText:SetJustifyH("LEFT")
toastFrame.titleText = titleText
local bodyText = toastFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
bodyText:SetPoint("TOPLEFT", titleText, "BOTTOMLEFT", 0, -4)
bodyText:SetPoint("BOTTOMRIGHT", toastFrame, "BOTTOMRIGHT", -10, 6)
bodyText:SetJustifyH("LEFT")
toastFrame.bodyText = bodyText
toastFrame:EnableMouse(true)
toastFrame:SetScript("OnMouseDown", function()
module:ShowWindow()
toastFrame:Hide()
end)
end
toastFrame.titleText:SetText(title)
toastFrame.bodyText:SetText(body)
toastFrame:Show()
C_Timer.After(6, function()
if toastFrame and toastFrame:IsShown() then
toastFrame:Hide()
end
end)
end
--- Deserializes and merges a received report, then notifies the player about new entries.
---@param text string - Serialized report payload.
---@param via string - Human-readable transport description.
function module:ProcessReceivedPayload(text, via)
if not isErrorLoggingEnabled() then return end
local ok, data = Self:Deserialize(text)
if not ok or type(data) ~= "table" or type(data.entries) ~= "table" then return end
local senderName = data.sender or via or "Unknown"
local newCount = 0
for _, entry in ipairs(data.entries) do
if type(entry) == "table" and entry.message and recordReceivedEntry(entry, senderName) then
newCount = newCount + 1
end
end
if newCount > 0 then
print(("SlackHacks: received %d error log %s from %s (via %s)."):format(
newCount, newCount == 1 and "entry" or "entries", senderName, via))
showToast("SlackHacks Error Received", ("%d new error(s) from %s"):format(newCount, senderName))
if RaidNotice_AddMessage and RaidWarningFrame then
RaidNotice_AddMessage(RaidWarningFrame, "SlackHacks: Error log received from " .. senderName .. "!",
{ r = 1, g = 0.3, b = 0.1 })
end
end
end
local bnetSpool = {}
--- Reassembles a marked Battle.net payload, keeping partial data isolated per sender.
---@param eventName string - AceEvent event name.
---@param prefix string - Addon-message prefix.
---@param text string - Marker-prefixed chunk.
---@param channel string - Transport channel supplied by WoW.
---@param senderID string|number - Sender key for the chunk spool.
function module:BN_CHAT_MSG_ADDON(eventName, prefix, text, channel, senderID)
if not isErrorLoggingEnabled() then return end
if prefix ~= ERROR_LOG_COMM_PREFIX then return end
local marker, chunk = text:sub(1, 1), text:sub(2)
if marker == "\001" then
bnetSpool[senderID] = chunk
elseif marker == "\002" then
bnetSpool[senderID] = (bnetSpool[senderID] or "") .. chunk
elseif marker == "\003" then
local full = (bnetSpool[senderID] or "") .. chunk
bnetSpool[senderID] = nil
module:ProcessReceivedPayload(full, "Battle.net friend")
end
end
--- Receives a guild-whisper report through AceComm and passes it to the merger.
---@param prefix string - Communication prefix.
---@param message string - Serialized report.
---@param distribution string - AceComm distribution.
---@param sender string - Sender name.
function Self:OnErrorLogCommReceived(prefix, message, distribution, sender)
module:ProcessReceivedPayload(message, shortName(sender) or sender)
end
-----------------------------------------------------------------------
-- Lifecycle
-----------------------------------------------------------------------
--- Synchronizes error-capture hooks, report events, and the AceComm receiver with settings.
function module:RefreshErrorLogging()
if isErrorLoggingEnabled() then
installErrorCapture()
self:RegisterEvent("BN_FRIEND_ACCOUNT_ONLINE", "AttemptReport")
self:RegisterEvent("GUILD_ROSTER_UPDATE", "AttemptReport")
self:RegisterEvent("BN_CHAT_MSG_ADDON")
Self:RegisterComm(ERROR_LOG_COMM_PREFIX, "OnErrorLogCommReceived")
self:AttemptReport()
else
uninstallErrorCapture()
self:UnregisterAllEvents()
Self:UnregisterComm(ERROR_LOG_COMM_PREFIX)
end
end
--- Persists the error-logging setting and immediately applies its hooks.
---@param enabled boolean - Whether local capture and reporting should be active.
function module:SetErrorLoggingEnabled(enabled)
if not db.global.logs then db.global.logs = { debug = {}, error = {} } end
db.global.logs.errorLoggingEnabled = enabled and true or false
self:RefreshErrorLogging()
end
--- Ace3 lifecycle entry point that enables configured error logging.
function module:OnEnable()
self:RefreshErrorLogging()
end
--- Ace3 lifecycle entry point that removes all error hooks and communication handlers.
function module:OnDisable()
uninstallErrorCapture()
self:UnregisterAllEvents()
Self:UnregisterComm(ERROR_LOG_COMM_PREFIX)
end
-----------------------------------------------------------------------
-- Markdown export + window (shared by a regular player filing a GitHub issue, and by Slack reviewing
-- everything that's been relayed to him)
-----------------------------------------------------------------------
--- Builds a GitHub-ready Markdown report from all persisted error entries.
---@return string - Markdown document containing client metadata and stack traces.
function module:BuildMarkdown()
local list = errorLogTable() or {}
local lines = {}
table.insert(lines, "### SlackHacks Error Report")
table.insert(lines, "")
table.insert(lines, "- **Addon Version**: " .. tostring(GetAddOnMetadata and (GetAddOnMetadata("SlackHacks", "Version") or "") or "0.5"))
table.insert(lines, "- **Client Flavor**: " .. tostring(gameClientFlavor and gameClientFlavor() or "unknown"))
table.insert(lines, "- **Date**: " .. date("%Y-%m-%d %H:%M:%S"))
table.insert(lines, "- **Player**: " .. (characterFullName(UnitName("player"), GetRealmName()) or UnitName("player") or "Unknown"))
table.insert(lines, "")
if #list == 0 then
table.insert(lines, "_No errors currently recorded in SlackHacks._")
else
table.insert(lines, ("**Total unique errors**: %d"):format(#list))
table.insert(lines, "")
for index, entry in ipairs(list) do
table.insert(lines, ("#### %d. %s"):format(index, (entry.message or "Unknown error"):gsub("[\r\n].*", "")))
table.insert(lines, ("- **Occurred**: %s"):format(entry.timestamp or "Unknown"))
table.insert(lines, ("- **Count**: %d"):format(entry.count or 1))
if entry.senderName then
table.insert(lines, ("- **Reporter**: %s"):format(entry.senderName))
end
table.insert(lines, "")
table.insert(lines, "```text")
table.insert(lines, entry.message or "")
if entry.stack and #entry.stack > 0 then
table.insert(lines, "")
table.insert(lines, entry.stack)
end
table.insert(lines, "```")
table.insert(lines, "")
end
end
return table.concat(lines, "\n")
end
--- Displays or refreshes the AceGUI error-log window.
function module:ShowWindow()
local AceGUI = LibStub and LibStub("AceGUI-3.0", true)
if not AceGUI then
print("SlackHacks: AceGUI-3.0 not available to display error log.")
return
end
if self.frame then
self.markdownBox:SetText(self:BuildMarkdown())
self.frame:Show()
return
end
local frame = AceGUI:Create("Frame")
frame:SetTitle("SlackHacks Error Log")
frame:SetStatusText("Copy the markdown below and paste it into a new GitHub issue.")
frame:SetLayout("Flow")
frame:SetWidth(650)
frame:SetHeight(500)
frame:EnableResize(true)
frame:SetCallback("OnClose", function(widget)
widget:Hide()
end)
self.frame = frame
local linkLabel = AceGUI:Create("Label")
linkLabel:SetText("New issue link (copy into your browser):")
linkLabel:SetFullWidth(true)
frame:AddChild(linkLabel)
local linkBox = AceGUI:Create("EditBox")
linkBox:SetFullWidth(true)
linkBox:SetText(GITHUB_NEW_ISSUE_URL)
linkBox:DisableButton(true)
frame:AddChild(linkBox)
local markdownBox = AceGUI:Create("MultiLineEditBox")
markdownBox:SetLabel("Error Log (Markdown)")
markdownBox:SetFullWidth(true)
markdownBox:SetNumLines(22)
markdownBox:DisableButton(true)
frame:AddChild(markdownBox)
self.markdownBox = markdownBox
markdownBox:SetText(self:BuildMarkdown())
end
--- Reports captured errors when possible, otherwise opens the local report window.
function module:ReportErrors()
local list = errorLogTable()
if not list or #list == 0 then
print("SlackHacks: no errors recorded.")
module:ShowWindow()
return
end
module:AttemptReport(function(success, reason)
if success then
print("SlackHacks: bug logs successfully reported to Slack!")
else
if reason == "target_offline" then
print("SlackHacks: Slack is not currently online to receive reported logs directly.")
elseif reason == "in_combat" then
print("SlackHacks: in combat; queued to report after combat.")
end
module:ShowWindow()
end
end)
end
module.HandleBugCommand = module.ReportErrors
-- Secret, undocumented /slack induce-error command for verifying BugGrabber/BugSack capture our errors.
--- Intentionally raises a nil-index error for testing the capture integrations.
function module:InduceError()
local induceErrorNilValue = nil
induceErrorNilValue.slackHacksInducedError = true
end