• v1.12.1 54fe96f81f

    v1.12.1 — count the summoners a poll cycle skipped
    All checks were successful
    CI / build-and-test (push) Successful in 1m58s
    CI / docker-build (push) Successful in 22s
    Stable

    joeyr released this 2026-08-03 13:30:20 -05:00 | 0 commits to main since this release

    A 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 complete now reports skipped. A spectator-v5 call that
      fails is caught inside pollSummoner, which returned no_change — the same
      value as a successful poll that found nothing new. The cycle's errors
      counter only counts polls that throw, so a summoner lost to a timeout was
      reported inside errors=0 with summonersPolled still claiming full
      coverage. Live logs showed exactly that: a 10s spectator timeout at 11:27:57
      followed by Polling cycle complete | errors=0 at 11:30:04.
    • pollSummoner returns a new 'skipped' outcome (see the PollOutcome type)
      for both failure branches — the transient network error and the
      response-mapping TypeError — 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. errors stays "the poll threw out
      of pollSummoner"; skipped is "pollSummoner handled 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
  • v1.12.0 fc0f22ce04

    v1.12.0 — boot preflight
    All checks were successful
    CI / build-and-test (push) Successful in 1m54s
    CI / docker-build (push) Successful in 23s
    Stable

    joeyr released this 2026-08-03 07:24:01 -05:00 | 1 commits to main since this release

    The 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: true

    The 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_KEY turns 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_KEY surfaces 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=riot
    

    Seven checks, one request each, run concurrently under a 10s cap — so the block
    costs one timeout rather than seven. Each gets its own caller, 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.
    • skipped means 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 reports failed — otherwise a
      BRAVE_SEARCH_API_KEY dropped 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 a CLAUDE_MODEL that 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-data because 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 as SELECT 1, and it is the difference
      between "the database answered" and "the database I meant answered".
    • Fixed a flaky ContextManager property 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
  • v1.11.0 d690f59c56

    v1.11.0 — caller tags and real stack traces
    All checks were successful
    CI / build-and-test (push) Successful in 1m57s
    CI / docker-build (push) Successful in 22s
    Stable

    joeyr released this 2026-08-02 23:07:09 -05:00 | 2 commits to main since this release

    Every 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 prediction
    

    That was the whole line. The stack existed at the call site and was thrown
    away there: 131 sites pre-stringified the message with the instanceof Error
    ternary, and another 27 passed the Error positionally 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)
    

    error is one of exactly two key names Portainer colours red, and
    stack_trace gets the renderer that prints one indented at func (source:line)
    per frame.

    Added

    • A caller tag on every line. A closed PHASES union — 45 tags, two
      segments each — and forPhase(), 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, and plugins/lol/monitor and
      plugins/d2emu/monitor would 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 into error
      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-Error carries its
      contents rather than [object Object], and a wrapped error reports its
      cause.

    Fixed

    • A message key 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 in ErrorHandler.
    • 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
      without format.splat().
    • ErrorHandler logged 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 what caller is.
    • 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, which caller already attributes to
      db/pool, db/messages and discord/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
  • v1.10.0 e65723eb5a

    v1.10.0 — readable boot output
    All checks were successful
    CI / build-and-test (push) Successful in 1m57s
    CI / docker-build (push) Successful in 34s
    Stable

    joeyr released this 2026-08-02 22:33:22 -05:00 | 3 commits to main since this release

    These 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 trailing key=value pair 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, not timestamp. 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 after format.timestamp() — no call site
      changed.
    • service=aufhocker no longer appears on every line. defaultMeta put it
      on all of them, as one more demoted key=value pair; 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 now getSummaryLines():
      one line per area (config/runtime, config/discord, config/claude,
      config/storage, config/limits, config/admin), flat scalar fields only,
      each tagged with a caller so the group reads as a section instead of six
      unrelated boot messages. Secrets stay masked exactly as before — booleans for
      keys, and the database as host: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
      timestamp key and undone the rename on the one transport Portainer reads.

    Added

    • tz in the config/runtime line. The deploy sets TZ=America/Chicago,
      and this is the only way to tell from the outside which clock every time
      field is written against.
    • databaseLabel() alongside describeDatabaseUrl() in the config module —
      the same parse, rendered as one host:port/database field.

    Deployment

    TZ=America/Chicago is a stack edit (aufhocker.yaml), not part of the
    image: update the stack in Portainer, or the new time field keeps reading
    UTC. Nothing else in the bot schedules off local time — the d2emu poll fires on
    the :00/:30 minute 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. caller tags on every line, real stack traces, and a
    boot preflight block follow in two further releases.

    Downloads
  • v1.9.0 c565ac8822

    v1.9.0 — LoL Classic result handling
    All checks were successful
    CI / build-and-test (push) Successful in 1m55s
    CI / docker-build (push) Successful in 32s
    Stable

    joeyr released this 2026-08-02 21:00:25 -05:00 | 4 commits to main since this release

    Stops 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_9999999999 404 match file not found — genuinely absent looks different
    matches/NA1_<ranked SR> 200 — same key, same routing, so the key is fine
    ids?queue=4310 [] — zero Classic matches in history

    So the result never arrives, and never will.

    Added

    • LOL_PREDICTION_CLASSIC_ENABLED (default false) — predict LoL Classic games. Off because a Classic prediction can never be scored. Set true to restore the old behaviour. Ranked predictions are untouched.
    • CLASSIC_QUEUE_ID exported from the LoL config module, replacing bare 4310 literals.

    Fixed

    • A match Riot refuses to serve no longer earns 30 minutes of retries. GameEndHandler treated 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 throw RiotApiError with 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
  • v1.8.0 5c92eeeac4

    v1.8.0 — drop champion mastery enrichment
    All checks were successful
    CI / build-and-test (push) Successful in 1m54s
    CI / docker-build (push) Successful in 35s
    Stable

    joeyr released this 2026-08-02 17:53:52 -05:00 | 5 commits to main since this release

    Drops 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: buildGameStartEmbed accepted a masteryDataMap parameter and never read it, so the signature implied a use that did not exist. Gone with it: RiotChampionMasteryApiClient, the ChampionMasteryApiClient port, the ChampionMastery model, fetchMasteryDataForParticipants, and the formatMasteryPoints helper.

    Changed

    • Phase 2 enrichment now fetches ranked + recent-performance only, and its completion log reports rankedCount/performanceCount without masteryCount. The prediction prompt preamble no longer advertises mastery as available player data.
    • buildGameStartEmbed, predictGame, and the GameDetectionHandler/PollingService constructors 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
  • v1.7.2 3b3da2a517

    v1.7.2 — announce LoL Classic games
    All checks were successful
    CI / build-and-test (push) Successful in 2m8s
    CI / docker-build (push) Successful in 28s
    Stable

    joeyr released this 2026-08-02 17:24:06 -05:00 | 6 commits to main since this release

    Fixes LoL Classic (JADE, queue 4310) games never being announced, despite 1.6.0 adding 4310 to the tracked queues.

    Fixed

    • Spectator responses that omit perks no longer crash the poller. Riot leaves the perks object 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. mapParticipant read raw.perks.perkIds unconditionally, so the whole spectator response failed to map with a TypeError. perks is now optional on both the raw Riot type and SpectatorParticipant, 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", including TypeErrors 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. TypeError now logs at error with an explicit "game will NOT be announced".
    Downloads
  • v1.7.1 b488e3db3c

    v1.7.1
    All checks were successful
    CI / build-and-test (push) Successful in 1m54s
    CI / docker-build (push) Successful in 26s
    Stable

    joeyr released this 2026-07-31 07:09:12 -05:00 | 7 commits to main since this release

    Dependency and lockfile hygiene. No runtime behavior change.

    Fixed

    • Cleared GHSA-mh99-v99m-4gvg (High) in brace-expansion. Pinned to
      ^5.0.9 via the overrides block. 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 neither npm audit fix nor
      npm update brace-expansion would move the tree off 5.0.7, despite
      minimatch's ^5.0.5 range permitting 5.0.9. npm audit is now clean.
    • package-lock.json version now tracks package.json. The lockfile still
      recorded 1.5.3 through 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
  • v1.7.0 c17b404f43

    v1.7.0
    All checks were successful
    CI / build-and-test (push) Successful in 1m48s
    CI / docker-build (push) Successful in 36s
    Stable

    joeyr released this 2026-07-30 23:45:13 -05:00 | 8 commits to main since this release

    Startup 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() reads package.json from
      disk (it sits outside tsconfig's rootDir, 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 as Startup configuration.
    • storage.databaseTarget — where the DSN actually points. Host, port,
      database, and user parsed out of DATABASE_URL via describeDatabaseUrl().
      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: false instead of throwing.
    • LoL live monitor configuration line 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_KEY now 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 as databaseType) is a free-text label that does not
      affect how the bot connects; the new databaseTarget is the field that shows
      the real destination. Both now appear, with a comment in config.ts saying
      which is which.
    Downloads
  • v1.6.0 ed661ef407

    v1.6.0
    All checks were successful
    CI / build-and-test (push) Successful in 1m54s
    CI / docker-build (push) Successful in 1m6s
    Stable

    joeyr released this 2026-07-30 23:29:43 -05:00 | 9 commits to main since this release

    The 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 to 420,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
      as gameMode: JADE on mapId: 453 — not the CLASSIC enum, which is
      Summoner's Rift and covers both ranked and normal queues. Because it is
      neither ranked nor CLASSIC, it fell through the old filter with no
      notification and no log line. Added 4310 → "LoL Classic" and JADE
      "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 info instead of debug. At the default
      LOG_LEVEL=info a 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 unless gameMode === '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 as Champion #<id>. The Phase 1
      embed posts normally; the enrichment edits degrade rather than fail.
    Downloads