-
released this
2026-08-03 13:30:20 -05:00 | 0 commits to main since this releaseA correction to the LoL polling cycle's own health line, which could report a
cycle as clean while a watched summoner had silently dropped out of it.Fixed
Polling cycle completenow reportsskipped. A spectator-v5 call that
fails is caught insidepollSummoner, which returnedno_change— the same
value as a successful poll that found nothing new. The cycle'serrors
counter only counts polls that throw, so a summoner lost to a timeout was
reported insideerrors=0withsummonersPolledstill claiming full
coverage. Live logs showed exactly that: a 10s spectator timeout at 11:27:57
followed byPolling cycle complete | errors=0at 11:30:04.pollSummonerreturns a new'skipped'outcome (see thePollOutcometype)
for both failure branches — the transient network error and the
response-mappingTypeError— since neither actually checked the summoner.
'no_change'now means only what it says: we looked, and nothing had changed.
Notes on the design
- The counters are deliberately separate.
errorsstays "the poll threw out
ofpollSummoner";skippedis "pollSummonerhandled it and gave up on
this summoner for this cycle". Both mean not polled, and neither is a fault
worth waking up for on its own — spectator-v5 timeouts run at a low
single-digit percent and the next cycle picks the summoner back up. - No retry was added. A skipped summoner costs one 5-minute cycle: a game
start is announced late, and a game end is caught next cycle or by the hourly
stale sweep. The reason to count the skips is that this is the number that
would have to climb before any of that stopped being true, and until now it
was not visible anywhere.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
v1.12.0 — boot preflight
Stablereleased this
2026-08-03 07:24:01 -05:00 | 1 commits to main since this releaseThe last of three releases on the log output, and the one that changes what the
logs are for. The first two made a line readable; this one makes the bot say at
boot whether it can actually do its job.The problem with
apiKeyConfigured: trueThe startup banner reported that every credential was a non-empty string. That
is not the same as working, and every dependency this bot has was therefore
discovered broken at the moment of use:- A dead
RIOT_API_KEYturns the LoL plugin's API clients into no-op stubs that
return "no game" forever — indistinguishable from a healthy monitor whose
players simply are not playing. - A bad
BRAVE_SEARCH_API_KEYsurfaces the first time an admin runs
web_search, which may be days later. - A d2emu outage surfaces as a terror-zone rotation nobody was told about.
In each case the container is up, healthy, and reporting nothing wrong, because
it has not yet tried anything.The block
2026-08-03 07:17:38 INF preflight/db > ok server=PostgreSQL 16.3 2026-08-03 07:17:38 INF preflight/discord > ok bot=aufhocker id=1429626051755118693 2026-08-03 07:17:38 INF preflight/claude > ok model=claude-sonnet-5 2026-08-03 07:17:38 ERR preflight/riot > FAILED reason=platform-data returned HTTP 403 2026-08-03 07:17:38 INF preflight/ddragon > ok patch=16.15.1 2026-08-03 07:17:38 INF preflight/brave > skipped reason=admin tools are disabled 2026-08-03 07:17:38 INF preflight/d2emu > ok current=5 2026-08-03 07:17:38 ERR preflight/summary > preflight found problems; starting anyway failed=riotSeven checks, one request each, run concurrently under a 10s cap — so the block
costs one timeout rather than seven. Each gets its owncaller, which is what
makes it read as a checklist rather than seven unrelated boot messages.Design
- Loud, never fatal. Nothing here can stop the bot. A dead Brave key must
not cost the chat command that has nothing to do with it, and a boot that
exited would have Swarm restart it in a loop — churning away the very lines
that say what is wrong. The checks report; the operator decides. - Probes, not retries. One shot, hard timeout. A dependency that is merely
slow at boot is not worth delaying the boot over, and the answer a retry would
eventually get is the answer the first real use will get for itself. skippedmeans switched off, not unconfigured.ENABLE_ADMIN_TOOLS=false
is the only input that produces one. A missing credential on a dependency that
is supposed to be running reportsfailed— otherwise a
BRAVE_SEARCH_API_KEYdropped from the secret set would look identical to one
deliberately left out, and the block would train you to ignore it.- The Claude check is a model lookup, not a message: no tokens, and it also
catches aCLAUDE_MODELthat does not exist.Config.validate()only warns
when the value matches a known-retired prefix, so a misspelled model — or one
retired after that list was written — currently surfaces as a 404 on the first
question someone asks the bot. - Riot uses
platform-databecause it is the only key-authenticated Riot
endpoint taking no parameters; every call the monitor actually makes needs a
puuid, a tier or a match ID, none of which exist at boot. What is being
checked is the key. - No credential is logged, in any form — not even a prefix. A prefix is
still enough to pair with a leak elsewhere, and which key is configured has
never been the question. There is a test asserting no configured secret
appears anywhere in the reported outcomes.
Also
DatabaseConnection.serverVersion()—SELECT version()trimmed to
PostgreSQL 16.3. The same round trip asSELECT 1, and it is the difference
between "the database answered" and "the database I meant answered".- Fixed a flaky
ContextManagerproperty test that failed on roughly one seed
in ten. The generators draw user and bot message content from one pool, so
they could collide, and "bot content never appears in the context" then failed
because a user had said the same thing.
Minor rather than patch for the new boot output. No schema, command, or
config-input changes, and nothing to do on the stack. This completes the
Portainer work across 1.10.0 → 1.12.0: all five keys the dashboard renders are
in use, and the boot block now proves the dependencies behind them.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- A dead
-
released this
2026-08-02 23:07:09 -05:00 | 2 commits to main since this releaseEvery line now says which part of the bot wrote it, and an error line finally
carries a stack. Second of three releases bringing the log output to what
Portainer's viewer actually renders.What an error line used to be
2026-08-02 22:38:36 ERR Failed to record predictionThat was the whole line. The stack existed at the call site and was thrown
away there: 131 sites pre-stringified the message with theinstanceof Error
ternary, and another 27 passed theErrorpositionally as
logger.error('Failed:', error)— where winston merges it as metadata and an
Error's non-enumerable properties serialise to{}, so those lines carried
no reason at all. aufhocker has emitted no stack traces in production since
the logger was written.What it is now
2026-08-02 23:03:13 ERR lol/riot > Spectator lookup failed puuid=abc-123 error=RiotApiError: 429 rate limited by Riot at deep (/app/dist/plugins/lol/services/riotApiClient.js:17) at pollSummoner (/app/dist/plugins/lol/services/pollingService.js:474)erroris one of exactly two key names Portainer colours red, and
stack_tracegets the renderer that prints one indentedat func (source:line)
per frame.Added
- A
callertag on every line. A closedPHASESunion — 45 tags, two
segments each — andforPhase(), returning a winston child logger. Each
module takes its tag once at the top, so none of the 545 call sites changed.
Closed rather than free strings because Portainer renders only the last
two segments: a third is dropped silently, andplugins/lol/monitorand
plugins/d2emu/monitorwould have arrived identical. A typo is now a compile
error rather than a tag that quietly never appears. - Real stack traces.
errorFields()turns any thrown value intoerror
plus an array of{func, source, line}. Ported from lol-companion-data with
its 12-frame cap — Node's async traces run twenty-odd deep, mostly
node:internal/plumbing — and the right-anchored location split that keeps
a Windows drive letter with its path. A thrown non-Errorcarries its
contents rather than[object Object], and a wrapped error reports its
cause.
Fixed
- A
messagekey in metadata silently replaced the log message. Winston
merges metadata over the record, so the d2emu poll failures rendered as the
API's error text with no indication of which poll produced it. Two sites,
plus six inErrorHandler. ClaudeClient's network-retry warning lost its reason — the error went
in as a bare string second argument, which winston routes to SPLAT and drops
withoutformat.splat().ErrorHandlerlogged its context as a nested object, which Portainer
flattens to JSON inside one trailing pair — which user, which guild, which
command, in the least readable available form. Flat fields now.
Changed
- The 52
[d2emu]message prefixes are gone. That is whatcalleris. - Nested and array metadata flattened:
riotPacing={…}became three scalar
fields, and the JSON arrays (trackedQueueIds,plugins,adminIds,
availableTools,mappedUserIds) are comma-joined strings. - Six duplicate boot lines removed.
AufhockerBot.start()echoed the
success of every step it awaited, whichcalleralready attributes to
db/pool,db/messagesanddiscord/gateway.
Minor rather than patch for the changed log output. No schema, command, or
config-input changes, and nothing to do on the stack — this one arrives with
the image. The boot preflight block is all that remains of the plan.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- A
-
released this
2026-08-02 22:33:22 -05:00 | 3 commits to main since this releaseThese logs are read in Portainer, whose JSON log viewer renders exactly five
keys —time,level,caller,message,stack_trace— and demotes every
other key to a trailingkey=valuepair at the far end of the line. aufhocker
was emitting its timestamp under a name that viewer does not know, and burying
its startup configuration in a wall of JSON.First of three releases fixing that; see
docs/LOGGING_PLAN.md.Changed
- The timestamp is
time, nottimestamp. Winston writes its timestamp to
info.timestamp, which is not one of the five, so every line arrived with no
timestamp column at all and the clock trailing the fields. Renamed once in
the logger's format, immediately afterformat.timestamp()— no call site
changed. service=aufhockerno longer appears on every line.defaultMetaput it
on all of them, as one more demotedkey=valuepair; the container name
already says which service these came from.- The startup configuration is six lines rather than one.
Config.getSummary()returned a deeply nested object
(storage.databaseTarget.host), and an unrecognised key holding an object is
flattened to JSON inside its trailing pair — so the first thing you saw in
the logs was a single very long line of JSON. It is nowgetSummaryLines():
one line per area (config/runtime,config/discord,config/claude,
config/storage,config/limits,config/admin), flat scalar fields only,
each tagged with acallerso the group reads as a section instead of six
unrelated boot messages. Secrets stay masked exactly as before — booleans for
keys, and the database ashost:port/database, never the DSN, which carries
the password. - The production stdout transport no longer re-applies
format.timestamp(). A
transport format runs after the logger's, so it would have written a fresh
timestampkey and undone the rename on the one transport Portainer reads.
Added
tzin theconfig/runtimeline. The deploy setsTZ=America/Chicago,
and this is the only way to tell from the outside which clock everytime
field is written against.databaseLabel()alongsidedescribeDatabaseUrl()in the config module —
the same parse, rendered as onehost:port/databasefield.
Deployment
TZ=America/Chicagois a stack edit (aufhocker.yaml), not part of the
image: update the stack in Portainer, or the newtimefield keeps reading
UTC. Nothing else in the bot schedules off local time — the d2emu poll fires on
the:00/:30minute marks and America/Chicago is a whole-hour offset from
UTC, so its schedule is unchanged to the millisecond.
Minor rather than patch for the changed log output. No schema, command, or
config-input changes.callertags on every line, real stack traces, and a
boot preflight block follow in two further releases.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- The timestamp is
-
released this
2026-08-02 21:00:25 -05:00 | 4 commits to main since this releaseStops chasing LoL Classic match results that Riot never publishes, and makes Classic predictions opt-in.
Why
Riot serves LoL Classic (JADE, queue 4310) through Spectator v5 but not Match v5. Verified against the live API with the production key:
Probe Result matches/NA1_<classic>403 Forbidden — the match file exists, a third-party key may not read it matches/NA1_9999999999404 match file not found— genuinely absent looks differentmatches/NA1_<ranked SR>200 — same key, same routing, so the key is fine ids?queue=4310[]— zero Classic matches in historySo the result never arrives, and never will.
Added
LOL_PREDICTION_CLASSIC_ENABLED(defaultfalse) — predict LoL Classic games. Off because a Classic prediction can never be scored. Settrueto restore the old behaviour. Ranked predictions are untouched.CLASSIC_QUEUE_IDexported from the LoL config module, replacing bare4310literals.
Fixed
- A match Riot refuses to serve no longer earns 30 minutes of retries.
GameEndHandlertreated the 403 like any other failure and started its deferred retry timer — 30 more attempts, 60s apart, each a guaranteed 403. Every Classic game cost 31 pointless Riot calls and held its entry open for half an hour. The fetch now separates pending (retry) from unavailable (403 — stop), and the retry loop aborts if a 403 first appears mid-retry. - A 403 no longer masquerades as an expired API key. 401 and 403 were logged together as
"Riot API auth error"at error level, so each Classic game emitted 31 error lines claiming the key had died — enough to bury a genuine expiry. 401 keeps error level; 403 now logs at warn as"Riot API forbidden". Both still throwRiotApiErrorwith the status.
Changed
- The game-end embed now reads "Riot doesn't publish results for this queue" instead of "results unavailable", which implied one was still coming.
Full gate green: format, lint, typecheck, 742 tests / 54 suites, build.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
released this
2026-08-02 17:53:52 -05:00 | 5 commits to main since this releaseDrops champion mastery from live-game enrichment, cutting 10 Riot API calls per announced game.
Removed
- Champion mastery is no longer fetched, stored, or sent to Claude. Mastery cost one Champion-Mastery-v4 call for every one of the ten participants in a detected game — 10 of the ~80 calls a single announcement spends — and its only consumer was one line of the prediction prompt. It never reached the embed:
buildGameStartEmbedaccepted amasteryDataMapparameter and never read it, so the signature implied a use that did not exist. Gone with it:RiotChampionMasteryApiClient, theChampionMasteryApiClientport, theChampionMasterymodel,fetchMasteryDataForParticipants, and theformatMasteryPointshelper.
Changed
- Phase 2 enrichment now fetches ranked + recent-performance only, and its completion log reports
rankedCount/performanceCountwithoutmasteryCount. The prediction prompt preamble no longer advertises mastery as available player data. buildGameStartEmbed,predictGame, and theGameDetectionHandler/PollingServiceconstructors each lost their mastery parameter. These are positional, so the argument after mastery shifts left by one.
Predictions now see strictly less input, so their output changes. No config, command, or data format is affected.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Champion mastery is no longer fetched, stored, or sent to Claude. Mastery cost one Champion-Mastery-v4 call for every one of the ten participants in a detected game — 10 of the ~80 calls a single announcement spends — and its only consumer was one line of the prediction prompt. It never reached the embed:
-
released this
2026-08-02 17:24:06 -05:00 | 6 commits to main since this releaseFixes LoL Classic (JADE, queue 4310) games never being announced, despite 1.6.0 adding 4310 to the tracked queues.
Fixed
- Spectator responses that omit
perksno longer crash the poller. Riot leaves theperksobject off entirely for a subset of participants on JADE/Classic — 1 of 10 players in one observed live game, 3 of 10 in the next, varying per game rather than per champion.mapParticipantreadraw.perks.perkIdsunconditionally, so the whole spectator response failed to map with aTypeError.perksis now optional on both the raw Riot type andSpectatorParticipant, and is mapped only when present. The sole consumer (gameNotificationFormatters keystone lookup) already optional-chained it, so a participant without runes degrades that one embed field instead of dropping the game. - Response-mapping bugs are no longer reported as network errors.
pollSummoners catch labelled every exception "Transient network error polling summoner, will retry next cycle", includingTypeErrors that no retry can clear. That is why the above went unnoticed: each cycle logged a warning that read like a passing hiccup while the game was silently never announced.TypeErrornow logs aterrorwith an explicit "game will NOT be announced".
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Spectator responses that omit
-
v1.7.1
Stablereleased this
2026-07-31 07:09:12 -05:00 | 7 commits to main since this releaseDependency and lockfile hygiene. No runtime behavior change.
Fixed
- Cleared GHSA-mh99-v99m-4gvg (High) in
brace-expansion. Pinned to
^5.0.9via theoverridesblock. The advisory is a DoS through unbounded
brace expansion; the package is dev-only here, reached solely via
eslint → minimatch, so it was never present in the production image
(npm ci --omit=dev) — the exposure was limited to local and CI lint runs.
An override was required because neithernpm audit fixnor
npm update brace-expansionwould move the tree off 5.0.7, despite
minimatch's^5.0.5range permitting 5.0.9.npm auditis now clean. package-lock.jsonversion now trackspackage.json. The lockfile still
recorded1.5.3through the 1.6.0 and 1.7.0 releases — the bumps edited only
the manifest, so the two drifted. Both now read the same version.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Cleared GHSA-mh99-v99m-4gvg (High) in
-
v1.7.0
Stablereleased this
2026-07-30 23:45:13 -05:00 | 8 commits to main since this releaseStartup observability — the boot logs now answer "which build is this, what is
it pointed at, and is the LoL monitor actually working?" without shelling into
the container.Added
- Version in the startup logs.
getAppVersion()readspackage.jsonfrom
disk (it sits outside tsconfig'srootDir, and the container's
CMD ["node", "dist/index.js"]means npm never sets
npm_package_version). Reported on the first line of boot, on
Bot startup completed, and in the config summary. - The masked config summary is now actually logged.
Config.getSummary()
existed and masked every secret, but nothing outside tests ever called it — so
a boot revealed almost nothing about its own configuration. It now logs once
per start asStartup configuration. storage.databaseTarget— where the DSN actually points. Host, port,
database, and user parsed out ofDATABASE_URLviadescribeDatabaseUrl().
Deliberately field-by-field rather than a redacted DSN string, so the password
cannot reach a log line through a formatting slip; unparseable values report
parsed: falseinstead of throwing.LoL live monitor configurationline at plugin init. Logs the tracked
queues, poll interval, prediction settings, Riot pacing, and — critically —
riotApiKeyConfigured. Emitted whether the monitor is on or off.
Changed
- A monitor enabled without
RIOT_API_KEYnow warns. That combination wires
no-op API clients that return "no game" forever, which in the logs was
indistinguishable from a healthy monitor whose players simply weren't playing.
Notes
DATABASE_TYPE(logged asdatabaseType) is a free-text label that does not
affect how the bot connects; the newdatabaseTargetis the field that shows
the real destination. Both now appear, with a comment inconfig.tssaying
which is which.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Version in the startup logs.
-
v1.6.0
Stablereleased this
2026-07-30 23:29:43 -05:00 | 9 commits to main since this releaseThe live monitor's announced-queue list is now configurable, and LoL Classic
games are announced by default.Added
LOL_TRACKED_QUEUES— configurable announce allowlist. The live monitor
previously hard-coded[420, 440](Ranked Solo/Duo + Flex) as the only queues
it would announce; everything else was silently dropped. The list is now a
comma-separated env var, defaulting to420,440,4310. Untracked queues are
still polled — that's what detects the end of a tracked game — just never
posted. A malformed value rejects the whole list and falls back to the default
rather than silently narrowing what gets announced.- LoL Classic (queue 4310) is announced by default. Riot reports this mode
asgameMode: JADEonmapId: 453— not theCLASSICenum, which is
Summoner's Rift and covers both ranked and normal queues. Because it is
neither ranked norCLASSIC, it fell through the old filter with no
notification and no log line. Added4310→ "LoL Classic" andJADE→
"LoL Classic" to the queue/mode label maps used by the game-start embed and
the prediction prompt.
Changed
- Untracked-queue skips now log at
infoinstead ofdebug. At the default
LOG_LEVEL=infoa skipped game left no trace at all, which is
indistinguishable from the monitor never seeing the game. The line now names
the queue id, game mode, and the configured allowlist.
Notes
- Enrichment for non-Summoner's-Rift modes is inherently thin: lane detection
bails unlessgameMode === 'CLASSIC' && mapId === 11, ranked stats are
solo/flex only, and LoL Classic's champion ids (observed in the 60015–60081
range) aren't in Data Dragon, so names render asChampion #<id>. The Phase 1
embed posts normally; the enrichment edits degrade rather than fail.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads