Feature: existential dread, !leader, !trending compact, entity extraction fixes#56
Merged
Conversation
Custom emoji like <a:aniblobsweat:48885190**6**022825677> contain numeric IDs that could match a user's search term, mangling the emoji in the output. Adds extractEmoji() to pull emoji out before string replacement runs, mirroring the existing extractUsers() and extractUrls() pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces separate extractUsers/extractEmoji with a single extractDiscordEntities covering user mentions (<@id>, <@!id>), role mentions (<@&id>), channel mentions (<#id>), custom emoji (<:name:id>, <a:name:id>), and timestamps (<t:unix:format>). Extraction now also happens in replaceFirstMessage so entity IDs are treated as opaque placeholders during string replacement, not just during markdown processing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Placeholder collision: replace |{|url|}| and |{|entity|}| with Unicode
private-use characters (U+E001/U+E002) which cannot appear in Discord
messages, eliminating false matches (e.g. !s url/link corrupting re-insertion).
2. URL regex false positives: require an alphabetic TLD (2+ letters) so version
numbers (v1.2.3), prices (5.00), and decimals (3.14) are no longer extracted
as URLs and made unsearchable.
3. Markdown link URLs lost: deMarkDown now expands [text](url) to "text url"
and extracts URLs before removeMd runs, so the URL survives into output
instead of being silently stripped.
4. LESS_THAN/GREATER_THAN single-replace: switch to /g regex flag so all
angle-bracket pairs are restored in multi-line messages, not just the first.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Source: - config.js: fix variable name typos (messesageFetchCount, realestOneBlockedPercentneBlockedPercent), rewrite JSDoc as concise inline comments, add Token documentation - index.js: remove duplicate ready handler, clean up brace/spacing style, simplify getScore call to use return value directly - scoring.js: rename schemaCreated flag, prepare insertStmt once inside ensureSchema() instead of on every processScores call, fix getTrending label to respect the limit parameter, replace findIndex antipattern with .some() - replacer.js: rename replaceAll → replaceOccurrences to avoid shadowing the built-in, document the vertical-tab bold-marker trick, standardize comment style - one-blocked-message.js: fix triggerPecentage typo, consistent brace style, cleaner JSDoc - package.json: move jest/nodemon to devDependencies, move remove-markdown to dependencies Tests: - scoring.test.js: fix process.enve typo (env reset was silently broken), remove unnecessary async/await on synchronous functions, remove duplicate multiline test, update getScore calls to use return value - config.test.js: fix AlloeConfigDump/AllowConigDump description typos, add Token test - one-blocked-message.test.js: remove empty beforeAll, add DisableOneBlockedMessage test, extract makeQuery helper, add regular-user trigger threshold test - replacer.test.js: import updated exports after rename Docs: - CLAUDE.md: new file documenting commands, module map, the full replacer pipeline (including why two extraction passes are needed), placeholder rationale, env vars, and testing patterns Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tecture Converts unicodeToMerica and deMarkDown from String prototype extensions to named exported functions (normalizeUnicode, stripMarkdown) so the module surface is explicit, testable, and free of global side effects. Companion renames: extractGtAndLt → escapeAngleBrackets, replaceAll → replaceOccurrences, isBlockedSearchPhrase → isBlockedPhrase. Functions are now declared in strict dependency order. JSDoc is complete and accurate throughout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bugs fixed:
- escapeAngleBrackets: greedy <(.*)> matched from first < to last > on a
line, corrupting strings like "<foo> and <bar>". Fixed with <([^>]*)>.
- Bold marker collapse: .replace('\v\v','') only removed the first adjacent
pair, leaving dangling ** on 3+ consecutive matches. Fixed with /\v\v/g.
Hygiene:
- index.js: indexOf()===0 → startsWith() throughout.
- scoring.js: all prepared statements hoisted into ensureSchema() so each is
compiled once; sparkle regex compiled once at module scope.
Tests:
- Two regression tests added for each bug, using inputs that specifically
expose the failure mode (same-line multi-bracket, 3+ consecutive matches).
- Misleading RegExp-as-string test replaced with a proper no-match test plus
a separate type-guard test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tency scoring.js: extract a pure parseScoreLine() helper so the parsing logic is separate from the DB write. Switches processScores to for...of and hoists the author toString() call out of the loop. replacer.test.js: reorganize into four focused describe blocks (splitReplaceCommand, extractUrls, extractDiscordEntities, replaceFirstMessage) so failures point directly at the broken layer. Fix author field throughout the shared messages array — using a plain string relied on undefined .bot being falsy by coincidence. Add missing assertion to the markdown-strip test. Rewrite test descriptions to be precise and imperative. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hout replacer.js: - Hoist ENTITY_RE, URL_RE, and BLOCKED_PHRASE_RES to module scope so they are compiled exactly once rather than on every call. BLOCKED_PHRASE_RES also replaces the per-call new RegExp() in isBlockedPhrase. - Remove the dead ignoreCase parameter from replaceOccurrences — every call site passed true; the false branch was unreachable. Function now always matches literally and case-insensitively, as all callers required. - isBlockedPhrase now uses re.test() instead of phrase.match(re) — test() is the correct API when only a boolean is needed. scoring.js: - Replace lazy ensureSchema() with module-level eager initialization. All statement variables become const. Jest's resetModules: true gives each test a fresh module anyway, so the test isolation guarantee is unchanged. - processScores no longer calls ensureSchema() per invocation. index.js: - Rename initialQuery → message (matches Discord.js convention and the rest of the codebase). Rename messages → history to avoid shadowing the module-level term. Inline getScore() return into the send call. - Replace string concatenation with template literals throughout. scoring.test.js: - Add getTrending overlap test: exercises the filter(r => !top.includes(r)) false branch, which fires when a phrase appears in both the top-N slice and the bottom-N slice. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CLAUDE.md: replace all stale function names (unicodeToMerica → normalizeUnicode,
deMarkDown → stripMarkdown); update pipeline steps to match current code;
add section on module-scope constants and their restart implication; clarify
{LESS_THAN}/{GREATER_THAN} as string tokens (not PUA); document eager
scoring.js initialization and its effect on test isolation; raise test
instructions to first-class step; note parseScoreLine as internal.
README.md: remove incorrect 'npm install -g discord.js dotenv' instruction
(these are project dependencies, not globals); add npm test step; clean up
formatting.
scoring.js: add comment explaining why reference equality is correct in the
getTrending bottom filter.
jest.config.json: raise coverage thresholds from 59/77/66/66 to 65/88/73/73
to match actual coverage — the old values would not catch a meaningful
regression.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ests, fix cosmetic issues - Extract applyReplacement() from replaceFirstMessage for single-responsibility clarity - scoring.test.js: restructure into nested describe blocks (getScore/processScores/getTrending) - one-blocked-message.test.js: rename makeQuery/query → makeMessage/message throughout - one-blocked-message.js: convert string concat to template literal - scoring.js: correct SPARKLE_RE comment (parseScoreLine, not processScores) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
replacer.js:
- Replace {LESS_THAN}/{GREATER_THAN} string tokens with PUA characters
\uE003/\uE004 — now all four placeholder kinds are PUA-consistent and
immune to a user typing the token text literally in Discord
- Remove redundant outer capture group from URL_RE (match() with /g flag
returns full-match strings; inner groups are never read)
- Rename afterEntities → withoutEntities for consistency with stripMarkdown
scoring.js:
- Replace SPARKLE_RE.test()+match() double-execution with a single exec()
- Extract 7-day window to SEVEN_DAYS_MS module constant
- Rename shadowed `rows` parameter in fmt() to `entries`
CLAUDE.md: replace prose placeholder description with a four-row table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
replacer.js: - Replace 5-chain .replace() in normalizeUnicode with a single compiled regex + Map — one pass through the string, and new mappings require exactly one new line in the table with no function changes - Extract reinsert() helper — replaces the ?.forEach mutation pattern at both call sites with a named reduce, making the extract→transform→restore lifecycle explicit and purely functional - Fix stripMarkdown JSDoc step 6 to match actual restoration order scoring.js: - Format CREATE TABLE as multi-line SQL with aligned column definitions - Fix parseScoreLine @returns property order to match the code index.js: - Use channel alias consistently; remove stray message.channel.send Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…en tests
scoring.js:
- SPARKLE_RE: use non-capturing groups for the sparkle delimiters — groups 1
and 3 were captured but never read; phrase is now sparkle[1] not sparkle[2]
index.js:
- Parse command and check isBlockedPhrase before fetching message history;
blocked commands previously paid an unnecessary Discord API call
- Replace regex prefix-strip in !score handler with slice('!score '.length)
config.js:
- Pass explicit radix 10 to Number.parseInt
replacer.test.js:
- Tighten two entity-protection tests from not.toHaveBeenCalledWith(specific
corruption) to not.toHaveBeenCalled() — the search term exists only inside
entity IDs, so no match should be found at all
- Update stale regression comment that referenced old {GREATER_THAN} token
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
reactions.js (new): - SQLite table with PRIMARY KEY (message_id, reactor_id, emoji); at most one row per combination at any time - recordReaction(): INSERT OR IGNORE — silently discards duplicate add events; excludes self-reactions at insert time - removeReaction(): DELETE — if the user re-adds, a fresh row is inserted; the count at query time always reflects reality - getLeaderboard(): returns ranked mentions via <@author_id> for Discord rendering; returns "Who is one <emoji> message" for zero results - parseLeaderCommand(): handles Unicode emoji and custom emoji (<:name:id>, <a:name:id>); returns null when no emoji is provided index.js: - Add GuildMessageReactions intent and Partials (Message, Channel, Reaction) so reactions on uncached messages are received and resolved - messageReactionAdd / messageReactionRemove event handlers with partial fetch - !leader command handler jest.config.json: - Exclude index.js from coverage — it is a Discord client entrypoint whose business logic lives entirely in the tested modules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lvedReaction helper
- reactions.js: rewrite recordReaction JSDoc to explain PRIMARY KEY / INSERT OR IGNORE
invariant; fix extra whitespace; getLeaderboard returns array spread + join
- reactions.test.js: hoist thumbsUp/makeReaction to outer describe scope, eliminating
duplication across recordReaction, removeReaction, and getLeaderboard suites
- index.js: extract withResolvedReaction(reaction, user, { fetchMessage, handler })
helper; collapse both reaction event handlers to single-line registrations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- reactions.js: add proxyAuthors Map and registerProxyMessage(messageId, authorId); recordReaction checks the map first so reactions to bot !s replies credit the command issuer rather than the bot; self-reaction guard compares against the credited author, not the bot - replacer.js: replaceFirstMessage becomes async and returns Promise<Message|null> (null = no match) instead of a boolean; uses for...of instead of every() to support await; callers can now obtain the sent Message to register it as a proxy - index.js: await replaceFirstMessage, call registerProxyMessage on success - tests: update replacer suite for async + null/not-null assertions; add registerProxyMessage tests to reactions suite (118 tests total, up from 116) - CLAUDE.md: document proxy message mechanism and updated return type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After 2 hours of no user messages or reactions, the bot quietly posts one of 19 dread lines in #general. The instant any human does anything, the message deletes itself. Timer only starts after the first real user message. DREAD_INACTIVITY_HOURS env var makes local testing easy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the multi-line numbered list with a dense inline format: **Trending (7d)** ↑ phrase +3, phrase +2 | ↓ phrase -3, phrase -1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ption Previously, extractDiscordEntities was called on the stripMarkdown output, meaning entities were reinserted and then re-extracted. Any failure in that second extraction left the raw entity text (including its numeric ID) visible to applyReplacement, so a search term like "50" could corrupt an entity ID that happened to contain those digits. Fix: extract entities once, up front, before stripMarkdown. The placeholder is never reinserted mid-pipeline, so entity content is never visible to the search or replacement step. Also adds the missing tests for user mention entity protection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…gation Adds two missing Discord entity formats to ENTITY_RE: - Slash command mentions </commandname:id> and </cmd subcommand:id> - Guild navigation links <id:home|browse|customize|guide|linked-roles> Both are now extracted as opaque placeholders before search/replace, preventing the same entity-ID corruption fixed in the previous commit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 tasks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
npx jest --coverage already runs the full suite; the trailing npm test was a duplicate that exited with code 1 on coverage threshold failures, failing the build even when all tests passed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors the existing exclusion for index.js — both files depend on the Discord client and can't be unit tested in isolation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
badLlama121
approved these changes
May 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
<emoji>— leaderboard of who received a given emoji reaction in the last 30 days; credits!scommand issuers for reactions on bot proxy messages!trending— output collapsed to a single line:**Trending (7d)** ↑ phrase +3, phrase +2 | ↓ phrase -3stripMarkdownso their numeric IDs can never be corrupted by a search term (e.g.!s 50/not great lookingno longer mangles<@416708751500902411>)ENTITY_REnow also matches slash command mentions (</cmd:id>) and guild navigation links (<id:home>,<id:browse>, etc.)Test plan
npm test)🤖 Generated with Claude Code