-
released this
2026-08-25 19:09:27 -05:00 | 67 commits to main since this releaseThe live monitor was taking sustained Riot 429s while polling 11 summoners once
every 5 minutes — one call every ~27s, roughly 4% of a personal key's budget. It
was never the heavy spender: the key is shared, and nothing here could see that.Added
- Riot budget telemetry. Every Riot response carries
X-App-Rate-Limit,
X-App-Rate-Limit-Count, the method-scoped pair, and — on a 429 —
X-Rate-Limit-Type. All of it was discarded, leavingRetry-Afteras the only
evidence a 429 had happened, and no way at all to tell an application limit
from a method one.RiotApiClientBasenow reads them and logs a
Riot budget nearly spentwarning once the key-wide count reaches 80% of the
cap, reportingmine(this process's dispatches) besidekey(what Riot
counted for the whole key). The gap between those two is what other holders
of the key spent — the one thing that could not be observed from inside this
process, and the reason a 4%-of-budget poller was seeing 429s at all. RiotRateLimiterLikegained an optionalgetLongWindowCount()so the client
can report its own spend without importing the concrete limiter.
Changed
riotMaxRequestsPer2Min75 → 40. The published personal-key cap is
~100/2min, but the same key is also held by thelol-companionproject, whose
limiter runs to its own ceiling knowing nothing of this one. Two independent
limiters on one budget are safe only if their ceilings sum under the cap —
these summed to 165. 40 here pairs with ~50 there.- Recent-form lookup drops from 5 matches to 3. The enrichment pass cost
10 × (1 + 5) = 60 match calls, and with the ranked pass alongside it a single
detected game spent ~70 requests of a shared 100 in one burst. At 3 the same
pass costs 40. Recent form is a one-line hint, not a sample worth 60 requests.
Fixed
- A 429 no longer manufactures a second 429. The retry wait was
Math.min(retryAfter, 10), so Riot asking for 17s produced a retry at 10s —
while still rate-limited — turning one refused request into two and feeding the
pressure that caused it. Production logs show precisely that at 09:26:02:
retryAfter=17logged as "retrying in 10s", refused again ten seconds later.
The wait now honors what Riot asks for. - A long
Retry-Afterskips the call rather than overrunning the poll cycle.
Simply honoring an app-limitRetry-Afterof up to 120s across 3 retries could
sleep ~6 minutes inside a 5-minute cycle, andpollOnce's in-progress guard
would then drop the next cycle wholesale. Waits beyond 25s now abandon the
call — the last staggered summoner starts ~27s before the cycle is due. A
spectator answer describes right now, so a two-minute-old one is worthless when
the next cycle re-asks in five minutes. - The header-less 429 fallback rose from 2s to 8s. Riot omits
Retry-Afteron
some service-level 429s, and 2s retried straight back into the same wall. js-yamlpinned to^5.4.0viaoverrides, clearing GHSA-5p4m-2wfm-xmqj
(High — quadratic CPU consumption in!!omapresolution), which the
npm audit --audit-level=highCI gate step was failing on. Dev-only,
reached solely via
ts-jest → @jest/transform → babel-plugin-istanbul → @istanbuljs/load-nyc-config,
so it was never in the production image (npm ci --omit=dev). The 3.x line
never received the backport; the major bump is safe becauseload-nyc-config
callsjs-yaml's.load(), not thesafeLoad()removed in 4.x.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Riot budget telemetry. Every Riot response carries
-
released this
2026-08-03 13:30:20 -05:00 | 68 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 | 69 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 | 70 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 | 71 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 | 72 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 | 73 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 | 74 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 | 75 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 | 76 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.