Skip to content

feat(bot): add /starboard and /level engagement commands - #314

Merged
LucasSantana-Dev merged 3 commits into
mainfrom
feat/engagement-starboard-level
Mar 16, 2026
Merged

LucasSantana-Dev merged 3 commits into
mainfrom
feat/engagement-starboard-level

Conversation

@LucasSantana-Dev

@LucasSantana-Dev LucasSantana-Dev commented Mar 16, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Adds /starboard command — configure a starboard channel with custom emoji, threshold, and self-star settings; shows top starred messages
  • Adds /level command — XP tracking with per-message cooldown, leaderboard, rank lookup, role rewards on level-up
  • New Prisma models: StarboardConfig, StarboardEntry, LevelConfig, MemberXP, LevelReward
  • New services: StarboardService, LevelService (exported via @lucky/shared/services)
  • New reactionHandler.ts — wires MessageReactionAdd for automatic starboard posting
  • XP pipeline in messageHandler.ts with configurable cooldown, level-up announcements, and role reward grants
  • GatewayIntentBits.GuildMessageReactions added to bot intents

Closes #308, #309

Tests

  • 18 new tests (9 starboard + 9 level), all passing
  • Full bot suite: 469/470 passing (1 pre-existing failure in queueResolver.guard.spec.ts)

Summary by CodeRabbit

Release Notes

  • New Features
    • Level system: Track member XP progression, configure per-message rewards, view guild leaderboards, and automatically assign roles upon reaching milestone levels.
    • Starboard: React to messages to star them, configure emoji and thresholds, and view the most popular messages in your guild.

- Add StarboardConfig, StarboardEntry, LevelConfig, MemberXP, LevelReward Prisma models
- Implement StarboardService with getConfig/upsertConfig/deleteConfig/getEntry/upsertEntry/getTopEntries
- Implement LevelService with XP, level-up detection, leaderboard, rank, and role rewards
- Add /starboard command: setup, disable, top, status subcommands
- Add /level command: rank, leaderboard, setup, reward add/remove subcommands
- Wire reaction handler for starboard emoji tracking (MessageReactionAdd)
- Add XP logic to messageHandler with cooldown and level-up announcements
- Add GuildMessageReactions intent to Discord client
- Export new services from packages/shared/src/services/index.ts
- 18 passing unit tests across starboard and level commands
@vercel

vercel Bot commented Mar 16, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lucky Ready Ready Preview, Comment Mar 16, 2026 5:18am

Request Review

@netlify

netlify Bot commented Mar 16, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for regal-bunny-0c8efe ready!

Name Link
🔨 Latest commit 789bc15
🔍 Latest deploy log https://app.netlify.com/projects/regal-bunny-0c8efe/deploys/69b792541f32c50008d4d706
😎 Deploy Preview https://deploy-preview-314--regal-bunny-0c8efe.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Mar 16, 2026 •

Copy link
Copy Markdown

Warning

Rate limit exceeded

@LucasSantana-Dev has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 13 minutes and 5 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0412ad81-7545-4098-8319-6fc960daeaa5

📥 Commits

Reviewing files that changed from the base of the PR and between ff1be3b and 789bc15.

📒 Files selected for processing (2)
  • packages/bot/src/handlers/messageHandler.spec.ts
  • packages/bot/src/handlers/reactionHandler.spec.ts
📝 Walkthrough

Walkthrough

This PR introduces two major Discord bot features: a Starboard system for starring and reposting popular messages, and a Level/XP system for granting user experience points, tracking progression, and assigning role-based rewards. Includes Prisma schema models, database services, slash commands with subcommands, event handlers for message reactions, message processing for XP grants, comprehensive test coverage, and client configuration updates.

Changes

Cohort / File(s) Summary
Database Schema
prisma/schema.prisma
Added five Prisma models: StarboardConfig, StarboardEntry, LevelConfig, MemberXP, LevelReward with appropriate indexes, unique constraints, and database table mappings.
Level System Services
packages/shared/src/services/LevelService.ts
New LevelService with methods for config management (getConfig, upsertConfig), member XP tracking (getMemberXP, addXP, getLeaderboard, getRank), and reward management (addReward, removeReward, getRewards). Includes xpNeededForLevel utility function and exported singleton instance.
Starboard System Services
packages/shared/src/services/StarboardService.ts
New StarboardService with methods for config CRUD (getConfig, upsertConfig, deleteConfig), entry management (getEntry, upsertEntry, getTopEntries), and exported singleton instance.
Services Exports
packages/shared/src/services/index.ts
Exports new LevelService and StarboardService with their associated types (LevelConfig, MemberXP, LevelReward, StarboardConfig, StarboardEntry) and utilities (xpNeededForLevel).
Level Command
packages/bot/src/functions/general/commands/level.ts, level.spec.ts
New level command with subcommands: rank (display user XP/level/rank), leaderboard (top 10 users), setup (configure xpPerMessage, cooldown, announceChannel), and reward group (add/remove level-based role rewards). Comprehensive test suite covering all scenarios and error paths.
Starboard Command
packages/bot/src/functions/general/commands/starboard.ts, starboard.spec.ts
New starboard command with subcommands: setup (configure channel, emoji, threshold, self-star), disable (remove config), top (display top 5 starred messages), status (show current config). Comprehensive test suite validating all subcommands and edge cases.
Message & Reaction Handlers
packages/bot/src/handlers/messageHandler.ts, reactionHandler.ts
Added handleXP function to messageHandler for processing XP grants with cooldown checks and level-up announcements. New reactionHandler module managing reaction events, starboard entry updates, and starboard message posting with emoji/threshold validation.
Event Handler Integration
packages/bot/src/handlers/eventHandler.ts, eventHandler.spec.ts
Imported and wired handleReactionEvents handler. Added jest mock for reaction event handler in tests.
Client Configuration
packages/bot/src/handlers/clientHandler/service.ts
Added GatewayIntentBits.GuildMessageReactions intent to enable message reaction event tracking.

Sequence Diagram(s)

sequenceDiagram
    participant User as User (Sends Message)
    participant MsgHandler as Message Handler
    participant LevelSvc as Level Service
    participant DB as Database
    participant BotClient as Bot Client
    
    User->>MsgHandler: Message received
    MsgHandler->>LevelSvc: getConfig(guildId)
    LevelSvc->>DB: Fetch LevelConfig
    DB-->>LevelSvc: Config data
    
    alt XP System Enabled
        MsgHandler->>MsgHandler: Check cooldown
        alt Cooldown elapsed
            MsgHandler->>LevelSvc: addXP(guildId, userId, amount)
            LevelSvc->>DB: Update MemberXP, check levelUp
            DB-->>LevelSvc: { member, leveledUp, newLevel }
            
            alt Level up occurred
                LevelSvc-->>MsgHandler: leveledUp = true
                MsgHandler->>BotClient: Send announcement to announceChannel
                MsgHandler->>LevelSvc: getRewards(guildId, newLevel)
                LevelSvc->>DB: Fetch LevelReward
                DB-->>LevelSvc: Role list
                MsgHandler->>BotClient: Assign roles to member
            else No level up
                LevelSvc-->>MsgHandler: leveledUp = false
            end
        end
    end
Loading
sequenceDiagram
    participant User as User (Reacts)
    participant ReactionEv as Reaction Event
    participant ReactionHlr as Reaction Handler
    participant StarboardSvc as Starboard Service
    participant DB as Database
    participant BotClient as Bot Client
    
    User->>ReactionEv: Add reaction to message
    ReactionEv->>ReactionHlr: handleReactionEvents triggered
    ReactionHlr->>StarboardSvc: getConfig(guildId)
    StarboardSvc->>DB: Fetch StarboardConfig
    DB-->>StarboardSvc: Config (emoji, threshold, channel)
    
    alt Emoji matches configured emoji
        ReactionHlr->>StarboardSvc: getEntry(guildId, messageId)
        StarboardSvc->>DB: Fetch StarboardEntry
        DB-->>StarboardSvc: Entry or null
        
        ReactionHlr->>StarboardSvc: upsertEntry with updated starCount
        StarboardSvc->>DB: Create/update entry
        DB-->>StarboardSvc: Updated entry
        
        alt Star count >= threshold
            ReactionHlr->>BotClient: Fetch starboard channel
            alt Entry already posted to starboard
                ReactionHlr->>BotClient: Edit existing starboard message
            else First time reaching threshold
                ReactionHlr->>BotClient: Post new starboard embed
                ReactionHlr->>StarboardSvc: upsertEntry with starboardMsgId
                StarboardSvc->>DB: Update entry with message reference
            end
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Suggested labels

size/xl

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive The PR implements core starboard requirements: /starboard command with setup/disable/leaderboard subcommands, Prisma models, StarboardService, and reaction handlers. However, only #308 objectives are fully addressed; #309 objectives (level system) are not detailed in linked issues. Verify that all /level command requirements from issue #309 are met by reviewing the full issue description, as only #308 objectives are provided for validation.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main changes: introduction of two new engagement commands (/starboard and /level) with broad scope covering both features.
Out of Scope Changes check ✅ Passed All changes are directly aligned with implementing the two engagement commands. Gateway intent addition and event handler integration are necessary supporting changes without introducing unrelated features.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/engagement-starboard-level
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
1 Security Hotspot

See analysis details on SonarQube Cloud

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/bot/src/handlers/clientHandler/service.ts (1)

20-28: ⚠️ Potential issue | 🟠 Major

Configure Partials for reaction event handling.

Line 26 enables GatewayIntentBits.GuildMessageReactions, but the client lacks corresponding Partials configuration. In discord.js, intents without matching Partials may prevent events from firing for uncached entities. Since reactionHandler.ts handles partial reactions/messages but relies on events being dispatched, uncached reactions will be silently dropped after restarts or cache eviction, breaking the starboard feature.

🔧 Suggested fix
-import { Client, GatewayIntentBits, REST, Routes, Collection } from 'discord.js'
+import {
+    Client,
+    GatewayIntentBits,
+    REST,
+    Routes,
+    Collection,
+    Partials,
+} from 'discord.js'
...
         const client = new Client({
             intents: [
                 GatewayIntentBits.Guilds,
                 GatewayIntentBits.GuildMessages,
                 GatewayIntentBits.GuildVoiceStates,
                 GatewayIntentBits.MessageContent,
                 GatewayIntentBits.GuildMessageReactions,
             ],
+            partials: [
+                Partials.Message,
+                Partials.Channel,
+                Partials.Reaction,
+            ],
         }) as CustomClient
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/handlers/clientHandler/service.ts` around lines 20 - 28, The
client is created as new Client(...) cast to CustomClient with reaction intents
enabled but no partials configured, which drops events for uncached
messages/reactions; update the Client constructor (the object passed to new
Client in service.ts) to include a partials array (e.g., Partials.Message,
Partials.Reaction, Partials.Channel, Partials.User) so reactionHandler.ts can
receive and resolve partial reaction/message events, ensuring starboard behavior
survives cache misses or restarts.
🧹 Nitpick comments (4)
packages/bot/src/handlers/eventHandler.spec.ts (1)

42-45: Add an assertion for the new reaction wiring.

This mock is never asserted, so removing handleReactionEvents(client) from packages/bot/src/handlers/eventHandler.ts would still leave the suite green. A small spec that verifies the handler is wired once would cover the new behavior.

🧪 Suggested test
 describe('eventHandler', () => {
     beforeEach(() => {
         jest.clearAllMocks()
         createUserFriendlyErrorMock.mockReturnValue('Friendly error')
     })
+
+    it('wires reaction events', () => {
+        const { client } = createMockClient()
+        handleEvents(client as unknown as never)
+        expect(handleReactionEventsMock).toHaveBeenCalledWith(client)
+    })

Based on learnings, "Add or adjust unit and integration tests when changing behavior; follow existing patterns in packages/*/tests and root tests/ directories."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/handlers/eventHandler.spec.ts` around lines 42 - 45, Add a
test assertion that verifies the reaction handler is actually wired by asserting
the mocked handleReactionEvents (handleReactionEventsMock) is called exactly
once with the client; update the spec where the mock is defined (the jest.mock
block) to include expect(handleReactionEventsMock).toHaveBeenCalledTimes(1) and
expect(handleReactionEventsMock).toHaveBeenCalledWith(client) (or the
appropriate client variable), following the existing test setup/teardown pattern
so the suite fails if someone removes handleReactionEvents(client) from
eventHandler.ts.
packages/bot/src/handlers/reactionHandler.ts (1)

13-74: Split handleStarboardReaction before adding more event paths.

This function is already over the repo's function-size limit, and Sonar is flagging the current complexity. Extract message resolution, allowed-star counting, and post/update behavior into helpers before the remove-flow lands.

As per coding guidelines, "Functions must be less than 50 lines with cyclomatic complexity less than 10."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/handlers/reactionHandler.ts` around lines 13 - 74, The
handleStarboardReaction function is too large/complex—extract three helpers: (1)
resolveMessage(reaction, user) to fetch partials, validate guild and bot checks,
and return the resolved Message and guildId (or null to abort); (2)
computeStarInfo(reaction, msg, user, config) to validate emoji, self-star rule,
compute starCount and return {starCount, shouldContinue} or null when below
threshold; and (3) postOrUpdateStarboard(client, guildId, msg, starCount, entry,
config) to fetch/text-cast the starboard channel, build the embed, and either
edit existing starboard message or send a new one and upsert the entry. Replace
the body of handleStarboardReaction with calls to these helpers, keeping only
orchestration and early returns; ensure helper names match resolveMessage,
computeStarInfo, and postOrUpdateStarboard so reviewers can locate them.
prisma/schema.prisma (1)

591-592: Add composite indexes for the leaderboard sorts.

StarboardEntry and MemberXP are queried by guildId and then ordered by starCount or xp. A lone guildId index is fine for filtering, but it still leaves the database sorting every matching row for each leaderboard request.

Index sketch
 model StarboardEntry {
@@
-  @@index([guildId])
+  @@index([guildId, starCount])
   @@map("starboard_entries")
 }

 model MemberXP {
@@
-  @@index([guildId])
+  @@index([guildId, xp])
   @@map("member_xp")
 }

Also applies to: 619-620

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/schema.prisma` around lines 591 - 592, The schema currently only
indexes guildId, causing leaderboard queries to filter by guildId then sort many
rows; update the Prisma schema to add composite indexes for the leaderboard
sorts by adding @@index([guildId, starCount]) on the StarboardEntry model and
@@index([guildId, xp]) on the MemberXP model (refer to the model names
StarboardEntry and MemberXP and the fields guildId, starCount, xp) so the DB can
both filter and order efficiently.
packages/bot/src/functions/general/commands/starboard.spec.ts (1)

75-75: Assert the final reply, not just helper calls.

Most cases only verify successEmbed / infoEmbed / errorEmbed invocations. These tests still pass if the command builds the right embed but never sends it, so they miss the actual Discord-facing behavior on interactionReply.

As per coding guidelines "Test behavior, not implementation details."

Also applies to: 96-96, 104-104, 110-110, 121-121, 127-127, 144-144

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/functions/general/commands/starboard.spec.ts` at line 75,
Tests currently only assert that helper functions like successEmbedMock /
infoEmbedMock / errorEmbedMock were called, which verifies implementation
details but not what was sent to Discord; update each spec in starboard.spec.ts
to assert the final reply instead: replace or augment assertions such as
expect(successEmbedMock).toHaveBeenCalledWith(...) with an assertion that the
interactionReply mock (or the reply/send mock used in these tests) was called
with the actual embed returned by the helper (e.g.,
expect(interactionReplyMock).toHaveBeenCalledWith(expect.objectContaining({
embeds: [expectedEmbed] })) or equivalent), and apply the same change for the
other cases that currently check infoEmbedMock/errorEmbedMock so the test
verifies the reply payload rather than only the helper invocation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/bot/src/functions/general/commands/level.spec.ts`:
- Around line 48-69: The test factory createInteraction currently returns "any";
replace that with a narrow mocked interaction type (e.g. a Partial/Mock of
ChatInputCommandInteraction or a custom interface MockInteraction that includes
guild, user and options with
getSubcommand/getSubcommandGroup/getUser/getChannel/getInteger/getRole typed) or
return "unknown" and explicitly narrow/cast at call sites so execute(...)
signatures are checked; update the return assertion from "as any" to "as
MockInteraction" (or proper Discord interaction type) and ensure the mocked
methods (getSubcommand, getUser, etc.) match the typed method signatures so the
spec catches signature drift.

In `@packages/bot/src/functions/general/commands/level.ts`:
- Around line 185-197: The catch block currently logs the full error with
errorLog but returns error.message to the user; instead keep logging the raw
error via errorLog({ message: 'Error in level command:', error }) and replace
the user-facing reply to use createUserFriendlyError from `@lucky/shared/general`
to generate a sanitized message, then pass that into interactionReply (still
using errorEmbed if needed) so no raw service/Prisma errors are sent to Discord;
update references in this block (the catch handler around interactionReply,
errorEmbed, errorLog) to call createUserFriendlyError(...) for the reply content
and retain the original error in logs.
- Around line 77-200: The execute function in level.ts is too large and should
be reduced to a dispatch-only method: extract each subcommand handler into its
own named async functions (e.g., handleRewardAdd, handleRewardRemove,
handleRank, handleLeaderboard, handleSetup) that accept the interaction and use
levelService, xpNeededForLevel, interactionReply,
successEmbed/infoEmbed/errorEmbed, and errorLog as needed; then simplify execute
to only parse subcommandGroup/subcommand and call the appropriate handler inside
a thin try/catch (or let each handler handle its own errors) so the main execute
method stays under the repo limits and improves testability and permission
separation. Ensure the new handler functions are exported or kept in the same
module and referenced by name in execute, and move any duplicated logic (like
fetching member, xpData, formatting embeds) into those handlers.
- Around line 75-76: The command currently calls
setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) at the top-level
(affecting the entire command), which prevents ordinary members from using the
read-only subcommands like "rank" and "leaderboard"; remove the top-level
setDefaultMemberPermissions call and instead apply ManageGuild only to the
permissioned subcommands (e.g., call
setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) on the
SubcommandBuilder for "setup" and each "reward/*" subcommand or enforce the same
check in the interaction handler for the Setup and Reward subcommand names) so
that "rank" and "leaderboard" remain usable by all members while "setup" and
reward management remain gated.

In `@packages/bot/src/functions/general/commands/starboard.ts`:
- Around line 148-160: Catch block currently logs the full error via errorLog
but sends error.message back to the user; replace the raw error leak with a
sanitized message using createUserFriendlyError from `@lucky/shared/general` and
pass that message into interactionReply (within the existing errorEmbed) while
keeping the original error object in errorLog; also ensure
createUserFriendlyError is imported and used instead of exposing error.message
in the catch block.
- Around line 48-52: The /starboard command only registers a single top
subcommand and hard-codes 5 results; update the command registration to expose
the leaderboard contract by adding either two subcommands (e.g., top overall and
top month) or add a required option/choice on the existing top subcommand for
mode with choices "overall" and "30d", and update the handler that currently
returns 5 results to return 10 (replace the hard-coded 5 with a constant or
parameter) and implement filtering when mode == "30d" to only consider stars
from the last 30 days; locate the subcommand setup using
setName('top')/setDescription('Show top 5 starred messages') and the handler
that builds the leaderboard (the code that currently limits results to 5) and
change it to accept the mode and limit 10, applying a date filter for the 30-day
view.

In `@packages/bot/src/handlers/messageHandler.ts`:
- Around line 273-279: The current cooldown check uses levelService.getMemberXP
followed by levelService.addXP, which allows a race where two messages both pass
the in-memory check before either write; move the cutoff logic into LevelService
and perform it atomically: modify LevelService (e.g., update the existing addXP
method or add addXPIfAllowed/addXPAtomic) so it reads the member record,
compares now against lastXpAt and, only if cooldown elapsed, increments XP and
updates lastXpAt in a single database/transactional write, returning whether XP
was granted; remove the client-side getMemberXP/now check in messageHandler and
call the new atomic method (same parameters: guildId, userId, xpPerMessage) so
cooldown enforcement is server-side and race-free.
- Around line 280-289: The current block gates both announcing and role
assignment on config.announceChannel; when a guild has leveling but no announce
channel rewards won't be granted. Keep the announcement logic behind the
config.announceChannel check (using message.client.channels.fetch and
rawChannel?.isTextBased() to send via TextChannel), then separately call
levelService.getRewards(guildId), find the matching reward with rewards.find((r:
{ level: number }) => r.level === result.newLevel), and if reward &&
message.member call message.member.roles.add(reward.roleId).catch(() => {}) so
role assignment runs even when announceChannel is not set while announcement
remains optional.

In `@packages/bot/src/handlers/reactionHandler.ts`:
- Around line 76-89: handleReactionEvents only listens for
Events.MessageReactionAdd so removals never update star counts; add a listener
for Events.MessageReactionRemove inside handleReactionEvents and invoke the same
reaction-processing logic (e.g., call handleStarboardReaction or a dedicated
removal handler) with the same parameter types (MessageReaction |
PartialMessageReaction, User | PartialUser) and error handling pattern
(errorLog) so starCount and /starboard top are decremented when users remove
reactions.
- Around line 30-35: Add a guard in the reaction handler to ignore reactions
that occur inside the starboard channel by returning early when msg.channelId
=== config.channelId; locate the code around reactionHandler's use of msg (the
fetched message) and before calling starboardService.upsertEntry(guildId,
msg.id, ...) check if msg.channelId === config.channelId and return to avoid
creating entries for reposts in the starboard channel and prevent recursive
reposting.
- Around line 32-40: reactionHandler currently uses reaction.count which counts
all reactors (including bots and the message author when config.selfStar is
false) leading to inflated star counts; replace reaction.count with the number
of users from reaction.users.fetch() filtered to exclude bots and, if
config.selfStar is false, the original message author, and pass that filtered
count into starboardService.upsertEntry (referencing reaction.users.fetch,
config.selfStar, and starboardService.upsertEntry to locate the change). Also
add a MessageReactionRemove handler (e.g., handleReactionRemove or register a
'messageReactionRemove' listener) to recompute the filtered count and call
starboardService.upsertEntry or starboardService.removeEntry to decrement/remove
entries as needed, and add a guard in both add/remove handlers to ignore
reactions on messages posted by the starboard channel to prevent recursive
updates.

In `@packages/shared/src/services/LevelService.ts`:
- Around line 58-62: The current two-step flow (LevelService.getMemberXP
followed by addXP) allows a race where concurrent messages both pass the
cooldown check and double-award XP; replace these with a single transactional
method (e.g., addXPWithCooldown or awardXPAtomically) in LevelService that
performs the find-or-create and cooldown check and the xp increment/update
within one Prisma transaction (use prisma.$transaction or a serializable/upsert
pattern with SELECT FOR UPDATE semantics where supported), returning whether XP
was awarded and any level-up; update callers to use this new atomic method
instead of calling getMemberXP then addXP.

In `@packages/shared/src/services/StarboardService.ts`:
- Around line 61-73: The service currently exposes low-level getEntry and
upsertEntry methods but lacks the higher-level API the feature needs; add a
single recordStar(guildId: string, messageId: string, reactorId: string,
timestamp: Date) method that encapsulates the upsert/increment logic for
recording a star reaction and returns the updated StarboardEntry, and add a
getLeaderboard(guildId: string, limit?: number, timeframe?: 'all' | '30d')
method that returns top entries either overall or filtered to the last 30 days;
implement recordStar to call the existing upsertEntry/generic Prisma upsert (or
replace upsertEntry) to increment counts and track lastStarAt, and implement
getLeaderboard to use Prisma queries that order by score and optionally filter
by createdAt/lastStarAt >= now() - 30 days so handlers can call a single clear
API instead of composing query logic.

---

Outside diff comments:
In `@packages/bot/src/handlers/clientHandler/service.ts`:
- Around line 20-28: The client is created as new Client(...) cast to
CustomClient with reaction intents enabled but no partials configured, which
drops events for uncached messages/reactions; update the Client constructor (the
object passed to new Client in service.ts) to include a partials array (e.g.,
Partials.Message, Partials.Reaction, Partials.Channel, Partials.User) so
reactionHandler.ts can receive and resolve partial reaction/message events,
ensuring starboard behavior survives cache misses or restarts.

---

Nitpick comments:
In `@packages/bot/src/functions/general/commands/starboard.spec.ts`:
- Line 75: Tests currently only assert that helper functions like
successEmbedMock / infoEmbedMock / errorEmbedMock were called, which verifies
implementation details but not what was sent to Discord; update each spec in
starboard.spec.ts to assert the final reply instead: replace or augment
assertions such as expect(successEmbedMock).toHaveBeenCalledWith(...) with an
assertion that the interactionReply mock (or the reply/send mock used in these
tests) was called with the actual embed returned by the helper (e.g.,
expect(interactionReplyMock).toHaveBeenCalledWith(expect.objectContaining({
embeds: [expectedEmbed] })) or equivalent), and apply the same change for the
other cases that currently check infoEmbedMock/errorEmbedMock so the test
verifies the reply payload rather than only the helper invocation.

In `@packages/bot/src/handlers/eventHandler.spec.ts`:
- Around line 42-45: Add a test assertion that verifies the reaction handler is
actually wired by asserting the mocked handleReactionEvents
(handleReactionEventsMock) is called exactly once with the client; update the
spec where the mock is defined (the jest.mock block) to include
expect(handleReactionEventsMock).toHaveBeenCalledTimes(1) and
expect(handleReactionEventsMock).toHaveBeenCalledWith(client) (or the
appropriate client variable), following the existing test setup/teardown pattern
so the suite fails if someone removes handleReactionEvents(client) from
eventHandler.ts.

In `@packages/bot/src/handlers/reactionHandler.ts`:
- Around line 13-74: The handleStarboardReaction function is too
large/complex—extract three helpers: (1) resolveMessage(reaction, user) to fetch
partials, validate guild and bot checks, and return the resolved Message and
guildId (or null to abort); (2) computeStarInfo(reaction, msg, user, config) to
validate emoji, self-star rule, compute starCount and return {starCount,
shouldContinue} or null when below threshold; and (3)
postOrUpdateStarboard(client, guildId, msg, starCount, entry, config) to
fetch/text-cast the starboard channel, build the embed, and either edit existing
starboard message or send a new one and upsert the entry. Replace the body of
handleStarboardReaction with calls to these helpers, keeping only orchestration
and early returns; ensure helper names match resolveMessage, computeStarInfo,
and postOrUpdateStarboard so reviewers can locate them.

In `@prisma/schema.prisma`:
- Around line 591-592: The schema currently only indexes guildId, causing
leaderboard queries to filter by guildId then sort many rows; update the Prisma
schema to add composite indexes for the leaderboard sorts by adding
@@index([guildId, starCount]) on the StarboardEntry model and @@index([guildId,
xp]) on the MemberXP model (refer to the model names StarboardEntry and MemberXP
and the fields guildId, starCount, xp) so the DB can both filter and order
efficiently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d81cef90-117a-4b87-91fd-7f550e4451e0

📥 Commits

Reviewing files that changed from the base of the PR and between de8ab6f and ff1be3b.

📒 Files selected for processing (13)
  • packages/bot/src/functions/general/commands/level.spec.ts
  • packages/bot/src/functions/general/commands/level.ts
  • packages/bot/src/functions/general/commands/starboard.spec.ts
  • packages/bot/src/functions/general/commands/starboard.ts
  • packages/bot/src/handlers/clientHandler/service.ts
  • packages/bot/src/handlers/eventHandler.spec.ts
  • packages/bot/src/handlers/eventHandler.ts
  • packages/bot/src/handlers/messageHandler.ts
  • packages/bot/src/handlers/reactionHandler.ts
  • packages/shared/src/services/LevelService.ts
  • packages/shared/src/services/StarboardService.ts
  • packages/shared/src/services/index.ts
  • prisma/schema.prisma

Comment on lines +48 to +69
function createInteraction(
subcommand: string,
opts: Record<string, unknown> = {},
subcommandGroup: string | null = null,
) {
return {
guild: {
id: 'guild-1',
members: {
fetch: jest.fn().mockResolvedValue({ toString: () => '<@user-1>' }),
},
},
user: { id: 'user-1' },
options: {
getSubcommand: jest.fn(() => subcommand),
getSubcommandGroup: jest.fn(() => subcommandGroup),
getUser: jest.fn((name: string) => opts[name] ?? null),
getChannel: jest.fn((name: string) => opts[name] ?? null),
getInteger: jest.fn((name: string) => opts[name] ?? null),
getRole: jest.fn((name: string) => opts[name] ?? null),
},
} as any

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Type the interaction factory instead of returning any.

Line 69 turns the whole test harness into an any escape hatch, and the downstream execute(...) calls stop checking the command contract. Use a narrow mocked interaction type or unknown plus explicit narrowing so this spec still catches signature drift.

As per coding guidelines, "Do not use any types - ESLint enforces this at error level."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/functions/general/commands/level.spec.ts` around lines 48 -
69, The test factory createInteraction currently returns "any"; replace that
with a narrow mocked interaction type (e.g. a Partial/Mock of
ChatInputCommandInteraction or a custom interface MockInteraction that includes
guild, user and options with
getSubcommand/getSubcommandGroup/getUser/getChannel/getInteger/getRole typed) or
return "unknown" and explicitly narrow/cast at call sites so execute(...)
signatures are checked; update the return assertion from "as any" to "as
MockInteraction" (or proper Discord interaction type) and ensure the mocked
methods (getSubcommand, getUser, etc.) match the typed method signatures so the
spec catches signature drift.

Comment on lines +75 to +76
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
category: 'general',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't require ManageGuild for rank and leaderboard.

setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) applies to the whole command, so ordinary members cannot use the two read-only subcommands. Only setup and reward/* should be permission-gated.

Possible direction
         .addSubcommandGroup((group) =>
             group
                 .setName('reward')
                 .setDescription('Manage level rewards')
                 .addSubcommand((sub) =>
@@
-        )
-        .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
+        ),
     category: 'general',
     execute: async ({ interaction }) => {
         if (!(await requireGuild(interaction))) return
         if (!interaction.guild) return

         const subcommandGroup = interaction.options.getSubcommandGroup(false)
         const subcommand = interaction.options.getSubcommand()
+        const adminOnly =
+            subcommand === 'setup' || subcommandGroup === 'reward'
+
+        if (
+            adminOnly &&
+            !interaction.memberPermissions?.has(
+                PermissionFlagsBits.ManageGuild,
+            )
+        ) {
+            await interactionReply({
+                interaction,
+                content: {
+                    embeds: [
+                        errorEmbed(
+                            'Missing Permissions',
+                            'You need Manage Server to use this subcommand.',
+                        ),
+                    ],
+                    ephemeral: true,
+                },
+            })
+            return
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
category: 'general',
export const levelCommand = {
data: new SlashCommandBuilder()
.setName('level')
.setDescription('Manage server levels and user ranks')
.addSubcommand((sub) =>
sub
.setName('rank')
.setDescription('Check your current level and rank')
)
.addSubcommand((sub) =>
sub
.setName('leaderboard')
.setDescription('View the server level leaderboard')
)
.addSubcommand((sub) =>
sub
.setName('setup')
.setDescription('Configure level settings')
.addNumberOption((opt) =>
opt
.setName('base_xp')
.setDescription('Base XP per message')
.setRequired(true)
)
.addNumberOption((opt) =>
opt
.setName('xp_multiplier')
.setDescription('XP multiplier per level')
.setRequired(true)
)
)
.addSubcommandGroup((group) =>
group
.setName('reward')
.setDescription('Manage level rewards')
.addSubcommand((sub) =>
sub
.setName('add')
.setDescription('Add a reward for a level')
.addNumberOption((opt) =>
opt
.setName('level')
.setDescription('Level threshold')
.setRequired(true)
)
.addRoleOption((opt) =>
opt
.setName('role')
.setDescription('Role to award')
.setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('remove')
.setDescription('Remove a reward')
.addNumberOption((opt) =>
opt
.setName('level')
.setDescription('Level threshold')
.setRequired(true)
)
)
),
category: 'general',
execute: async ({ interaction }) => {
if (!(await requireGuild(interaction))) return
if (!interaction.guild) return
const subcommandGroup = interaction.options.getSubcommandGroup(false)
const subcommand = interaction.options.getSubcommand()
const adminOnly =
subcommand === 'setup' || subcommandGroup === 'reward'
if (
adminOnly &&
!interaction.memberPermissions?.has(
PermissionFlagsBits.ManageGuild,
)
) {
await interactionReply({
interaction,
content: {
embeds: [
errorEmbed(
'Missing Permissions',
'You need Manage Server to use this subcommand.',
),
],
ephemeral: true,
},
})
return
}
// Command implementation continues...
},
} as const satisfies ICommand
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/functions/general/commands/level.ts` around lines 75 - 76,
The command currently calls
setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) at the top-level
(affecting the entire command), which prevents ordinary members from using the
read-only subcommands like "rank" and "leaderboard"; remove the top-level
setDefaultMemberPermissions call and instead apply ManageGuild only to the
permissioned subcommands (e.g., call
setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) on the
SubcommandBuilder for "setup" and each "reward/*" subcommand or enforce the same
check in the interaction handler for the Setup and Reward subcommand names) so
that "rank" and "leaderboard" remain usable by all members while "setup" and
reward management remain gated.

Comment on lines +77 to +200
execute: async ({ interaction }) => {
if (!(await requireGuild(interaction))) return
if (!interaction.guild) return

const subcommandGroup = interaction.options.getSubcommandGroup(false)
const subcommand = interaction.options.getSubcommand()

try {
if (subcommandGroup === 'reward') {
if (subcommand === 'add') {
const level = interaction.options.getInteger('level', true)
const role = interaction.options.getRole('role', true)
await levelService.addReward(interaction.guild.id, level, role.id)
await interactionReply({
interaction,
content: {
embeds: [
successEmbed(
'Reward Added',
`${role} will be granted when reaching level **${level}**.`,
),
],
},
})
} else if (subcommand === 'remove') {
const level = interaction.options.getInteger('level', true)
await levelService.removeReward(interaction.guild.id, level)
await interactionReply({
interaction,
content: {
embeds: [successEmbed('Reward Removed', `Reward for level **${level}** removed.`)],
},
})
}
return
}

if (subcommand === 'rank') {
const targetUser = interaction.options.getUser('user') ?? interaction.user
const guildId = interaction.guild.id
const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null)
const xpData = await levelService.getMemberXP(guildId, targetUser.id)
const rank = await levelService.getRank(guildId, targetUser.id)

const xp = xpData?.xp ?? 0
const level = xpData?.level ?? 0
const xpNeeded = xpNeededForLevel(level + 1)

const description = [
`**User:** ${member ?? targetUser.username}`,
`**Level:** ${level}`,
`**XP:** ${xp} / ${xpNeeded}`,
`**Rank:** #${rank}`,
].join('\n')

await interactionReply({
interaction,
content: {
embeds: [infoEmbed('Rank', description)],
},
})
} else if (subcommand === 'leaderboard') {
const entries = await levelService.getLeaderboard(interaction.guild.id, 10)

if (entries.length === 0) {
await interactionReply({
interaction,
content: {
embeds: [infoEmbed('Leaderboard', 'No XP recorded yet.')],
},
})
return
}

const lines = entries.map(
(e: { userId: string; level: number; xp: number }, i: number) => `**${i + 1}.** <@${e.userId}> — Level ${e.level} (${e.xp} XP)`,
)

await interactionReply({
interaction,
content: {
embeds: [infoEmbed('XP Leaderboard', lines.join('\n'))],
},
})
} else if (subcommand === 'setup') {
const xpPerMessage = interaction.options.getInteger('xp-per-message', true)
const cooldownSeconds = interaction.options.getInteger('cooldown-seconds', true)
const announceChannel = interaction.options.getChannel('announce-channel')

await levelService.upsertConfig(interaction.guild.id, {
xpPerMessage,
xpCooldownMs: cooldownSeconds * 1000,
announceChannel: announceChannel?.id ?? null,
})

const lines = [
`**XP per message:** ${xpPerMessage}`,
`**Cooldown:** ${cooldownSeconds}s`,
announceChannel ? `**Announce channel:** ${announceChannel}` : '**Announce channel:** None',
]

await interactionReply({
interaction,
content: {
embeds: [successEmbed('Level System Configured', lines.join('\n'))],
},
})
}
} catch (error) {
errorLog({ message: 'Error in level command:', error })
await interactionReply({
interaction,
content: {
embeds: [
errorEmbed(
'Error',
error instanceof Error ? error.message : 'An error occurred.',
),
],
ephemeral: true,
},
})
}
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Extract per-subcommand handlers from execute.

execute is already over the repo's function-size/complexity limits, and more /level features will make it harder to test and reason about. Keeping this method as dispatch-only will also simplify the permission split and error handling.

As per coding guidelines "Functions must be less than 50 lines with cyclomatic complexity less than 10."

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[failure] 77-77: Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Nexus&issues=AZz1CDByG93nPF4XH-Y9&open=AZz1CDByG93nPF4XH-Y9&pullRequest=314

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/functions/general/commands/level.ts` around lines 77 - 200,
The execute function in level.ts is too large and should be reduced to a
dispatch-only method: extract each subcommand handler into its own named async
functions (e.g., handleRewardAdd, handleRewardRemove, handleRank,
handleLeaderboard, handleSetup) that accept the interaction and use
levelService, xpNeededForLevel, interactionReply,
successEmbed/infoEmbed/errorEmbed, and errorLog as needed; then simplify execute
to only parse subcommandGroup/subcommand and call the appropriate handler inside
a thin try/catch (or let each handler handle its own errors) so the main execute
method stays under the repo limits and improves testability and permission
separation. Ensure the new handler functions are exported or kept in the same
module and referenced by name in execute, and move any duplicated logic (like
fetching member, xpData, formatting embeds) into those handlers.

Comment on lines +185 to +197
} catch (error) {
errorLog({ message: 'Error in level command:', error })
await interactionReply({
interaction,
content: {
embeds: [
errorEmbed(
'Error',
error instanceof Error ? error.message : 'An error occurred.',
),
],
ephemeral: true,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sanitize command failures before replying.

This path sends error.message straight to Discord. Service and Prisma errors should stay in logs; the user-facing reply should come from the shared friendly-error wrapper instead.

As per coding guidelines "Use interactionReply and createUserFriendlyError utilities from @lucky/shared/general utils for command replies and error handling."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/functions/general/commands/level.ts` around lines 185 - 197,
The catch block currently logs the full error with errorLog but returns
error.message to the user; instead keep logging the raw error via errorLog({
message: 'Error in level command:', error }) and replace the user-facing reply
to use createUserFriendlyError from `@lucky/shared/general` to generate a
sanitized message, then pass that into interactionReply (still using errorEmbed
if needed) so no raw service/Prisma errors are sent to Discord; update
references in this block (the catch handler around interactionReply, errorEmbed,
errorLog) to call createUserFriendlyError(...) for the reply content and retain
the original error in logs.

Comment on lines +48 to +52
.addSubcommand((sub) =>
sub.setName('top').setDescription('Show top 5 starred messages'),
)
.addSubcommand((sub) =>
sub.setName('status').setDescription('Show current starboard configuration'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The leaderboard contract from issue #308 isn't exposed yet.

The command only offers /starboard top, and Line 96 hard-codes 5 results. The linked objective calls for top 10 messages with overall and last-30-days views, so the required leaderboard modes are still missing.

Also applies to: 95-118

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/functions/general/commands/starboard.ts` around lines 48 -
52, The /starboard command only registers a single top subcommand and hard-codes
5 results; update the command registration to expose the leaderboard contract by
adding either two subcommands (e.g., top overall and top month) or add a
required option/choice on the existing top subcommand for mode with choices
"overall" and "30d", and update the handler that currently returns 5 results to
return 10 (replace the hard-coded 5 with a constant or parameter) and implement
filtering when mode == "30d" to only consider stars from the last 30 days;
locate the subcommand setup using setName('top')/setDescription('Show top 5
starred messages') and the handler that builds the leaderboard (the code that
currently limits results to 5) and change it to accept the mode and limit 10,
applying a date filter for the 30-day view.

Comment on lines +30 to +35
const msg = reaction.message.partial ? await reaction.message.fetch() : reaction.message

if (!config.selfStar && msg.author?.id === user.id) return

const starCount = reaction.count ?? 1
const entry = await starboardService.upsertEntry(guildId, msg.id, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Ignore reactions that happen in the starboard channel itself.

There's no guard for msg.channelId === config.channelId. If users react to the repost inside the starboard channel, the handler creates a second entry for the repost and can recurse indefinitely.

🔧 Suggested fix
     const msg = reaction.message.partial ? await reaction.message.fetch() : reaction.message
+
+    if (msg.channelId === config.channelId) return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const msg = reaction.message.partial ? await reaction.message.fetch() : reaction.message
if (!config.selfStar && msg.author?.id === user.id) return
const starCount = reaction.count ?? 1
const entry = await starboardService.upsertEntry(guildId, msg.id, {
const msg = reaction.message.partial ? await reaction.message.fetch() : reaction.message
if (msg.channelId === config.channelId) return
if (!config.selfStar && msg.author?.id === user.id) return
const starCount = reaction.count ?? 1
const entry = await starboardService.upsertEntry(guildId, msg.id, {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/handlers/reactionHandler.ts` around lines 30 - 35, Add a
guard in the reaction handler to ignore reactions that occur inside the
starboard channel by returning early when msg.channelId === config.channelId;
locate the code around reactionHandler's use of msg (the fetched message) and
before calling starboardService.upsertEntry(guildId, msg.id, ...) check if
msg.channelId === config.channelId and return to avoid creating entries for
reposts in the starboard channel and prevent recursive reposting.

Comment on lines +32 to +40
if (!config.selfStar && msg.author?.id === user.id) return

const starCount = reaction.count ?? 1
const entry = await starboardService.upsertEntry(guildId, msg.id, {
channelId: msg.channelId,
authorId: msg.author?.id ?? '',
content: msg.content ?? undefined,
starCount,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Locate and read the reaction handler file
git ls-files packages/bot/src/handlers/ | head -20

Repository: LucasSantana-Dev/Lucky

Length of output: 1028


🏁 Script executed:

# Check file size and read the file
if [ -f "packages/bot/src/handlers/reactionHandler.ts" ]; then
  wc -l packages/bot/src/handlers/reactionHandler.ts
  cat -n packages/bot/src/handlers/reactionHandler.ts
fi

Repository: LucasSantana-Dev/Lucky

Length of output: 3721


🏁 Script executed:

# Search for any message reaction handling in the codebase
rg "MessageReactionAdd|MessageReactionRemove" packages/bot/src --type ts -A 5 -B 2

Repository: LucasSantana-Dev/Lucky

Length of output: 1379


🏁 Script executed:

# Read the spec file to understand the expected behavior
cat -n packages/bot/src/handlers/reactionHandler.spec.ts

Repository: LucasSantana-Dev/Lucky

Length of output: 7067


🏁 Script executed:

# Search for any references to reaction.users.fetch in the codebase
rg "reaction\.users\.fetch|users\.fetch\(" packages/bot --type ts -B 2 -A 2

Repository: LucasSantana-Dev/Lucky

Length of output: 529


🏁 Script executed:

# Check if there's any handling of MessageReactionRemove or removal logic
rg "MessageReactionRemove|remove|delete.*reaction" packages/bot --type ts -A 3 -B 1

Repository: LucasSantana-Dev/Lucky

Length of output: 45533


🏁 Script executed:

# Look for documentation or comments about starboard functionality
rg "starboard|star.*count|reaction.*count" packages/ --type ts -B 2 -A 2 | head -80

Repository: LucasSantana-Dev/Lucky

Length of output: 6377


Filter reaction count to exclude disallowed reactors.

Line 34 uses reaction.count which includes all reactions, even those from the author when selfStar is false. If the author reacts first and then another user reacts, the count crosses the threshold artificially early because it includes the author's disallowed reaction. Fetch and filter reactors to exclude bots and (when config.selfStar is false) the message author:

🔧 Suggested fix
-    const starCount = reaction.count ?? 1
+    const reactors = await reaction.users.fetch()
+    const starCount = reactors.filter(
+        (reactor) =>
+            !reactor.bot &&
+            (config.selfStar || reactor.id !== msg.author?.id),
+    ).size

Additionally, add a MessageReactionRemove handler to decrement starboard entries when reactions are removed, otherwise counts will drift upward. Consider guarding against recursive reactions on messages already in the starboard channel.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!config.selfStar && msg.author?.id === user.id) return
const starCount = reaction.count ?? 1
const entry = await starboardService.upsertEntry(guildId, msg.id, {
channelId: msg.channelId,
authorId: msg.author?.id ?? '',
content: msg.content ?? undefined,
starCount,
})
if (!config.selfStar && msg.author?.id === user.id) return
const reactors = await reaction.users.fetch()
const starCount = reactors.filter(
(reactor) =>
!reactor.bot &&
(config.selfStar || reactor.id !== msg.author?.id),
).size
const entry = await starboardService.upsertEntry(guildId, msg.id, {
channelId: msg.channelId,
authorId: msg.author?.id ?? '',
content: msg.content ?? undefined,
starCount,
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/handlers/reactionHandler.ts` around lines 32 - 40,
reactionHandler currently uses reaction.count which counts all reactors
(including bots and the message author when config.selfStar is false) leading to
inflated star counts; replace reaction.count with the number of users from
reaction.users.fetch() filtered to exclude bots and, if config.selfStar is
false, the original message author, and pass that filtered count into
starboardService.upsertEntry (referencing reaction.users.fetch, config.selfStar,
and starboardService.upsertEntry to locate the change). Also add a
MessageReactionRemove handler (e.g., handleReactionRemove or register a
'messageReactionRemove' listener) to recompute the filtered count and call
starboardService.upsertEntry or starboardService.removeEntry to decrement/remove
entries as needed, and add a guard in both add/remove handlers to ignore
reactions on messages posted by the starboard channel to prevent recursive
updates.

Comment on lines +76 to +89
export function handleReactionEvents(client: Client): void {
client.on(
Events.MessageReactionAdd,
async (
reaction: MessageReaction | PartialMessageReaction,
user: User | PartialUser,
) => {
try {
await handleStarboardReaction(reaction, user, client)
} catch (error) {
errorLog({ message: 'Error handling reaction:', error })
}
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Track MessageReactionRemove too.

Only MessageReactionAdd is registered here. When users remove stars, starCount and /starboard top never decrease, so leaderboards drift and entries can stay above threshold indefinitely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/handlers/reactionHandler.ts` around lines 76 - 89,
handleReactionEvents only listens for Events.MessageReactionAdd so removals
never update star counts; add a listener for Events.MessageReactionRemove inside
handleReactionEvents and invoke the same reaction-processing logic (e.g., call
handleStarboardReaction or a dedicated removal handler) with the same parameter
types (MessageReaction | PartialMessageReaction, User | PartialUser) and error
handling pattern (errorLog) so starCount and /starboard top are decremented when
users remove reactions.

Comment on lines +58 to +62
async getMemberXP(guildId: string, userId: string): Promise<MemberXP | null> {
return await prisma.memberXP.findUnique({
where: { guildId_userId: { guildId, userId } },
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make XP eligibility and awarding one atomic DB operation.

The current message-handler flow does getMemberXP(), checks lastXpAt, and only then calls addXP(). Two concurrent messages can pass that gap before either write lands, which double-awards XP and can emit duplicate level-up events. Expose a single transactional service method that applies the cooldown gate and increment together.

Also applies to: 64-91

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared/src/services/LevelService.ts` around lines 58 - 62, The
current two-step flow (LevelService.getMemberXP followed by addXP) allows a race
where concurrent messages both pass the cooldown check and double-award XP;
replace these with a single transactional method (e.g., addXPWithCooldown or
awardXPAtomically) in LevelService that performs the find-or-create and cooldown
check and the xp increment/update within one Prisma transaction (use
prisma.$transaction or a serializable/upsert pattern with SELECT FOR UPDATE
semantics where supported), returning whether XP was awarded and any level-up;
update callers to use this new atomic method instead of calling getMemberXP then
addXP.

Comment on lines +61 to +73
async getEntry(guildId: string, messageId: string): Promise<StarboardEntry | null> {
return await prisma.starboardEntry.findUnique({
where: { guildId_messageId: { guildId, messageId } },
})
}

async upsertEntry(guildId: string, messageId: string, data: UpsertEntryData): Promise<StarboardEntry> {
return await prisma.starboardEntry.upsert({
where: { guildId_messageId: { guildId, messageId } },
create: { guildId, messageId, ...data },
update: data,
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Expose a proper starboard write/query API here.

The linked issue needs a single recordStar-style mutation and leaderboards "overall or within the last 30 days". getEntry/upsertEntry plus an all-time getTopEntries() leave that behavior in handlers, so this service still doesn't provide the feature contract this PR is supposed to add.

Also applies to: 75-80

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared/src/services/StarboardService.ts` around lines 61 - 73, The
service currently exposes low-level getEntry and upsertEntry methods but lacks
the higher-level API the feature needs; add a single recordStar(guildId: string,
messageId: string, reactorId: string, timestamp: Date) method that encapsulates
the upsert/increment logic for recording a star reaction and returns the updated
StarboardEntry, and add a getLeaderboard(guildId: string, limit?: number,
timeframe?: 'all' | '30d') method that returns top entries either overall or
filtered to the last 30 days; implement recordStar to call the existing
upsertEntry/generic Prisma upsert (or replace upsertEntry) to increment counts
and track lastStarAt, and implement getLeaderboard to use Prisma queries that
order by score and optionally filter by createdAt/lastStarAt >= now() - 30 days
so handlers can call a single clear API instead of composing query logic.

@sonarqubecloud

Copy link
Copy Markdown

@LucasSantana-Dev
LucasSantana-Dev merged commit 946b520 into main Mar 16, 2026
15 checks passed
@LucasSantana-Dev
LucasSantana-Dev deleted the feat/engagement-starboard-level branch March 16, 2026 05:30
LucasSantana-Dev added a commit that referenced this pull request May 13, 2026
* feat(bot): add /starboard and /level engagement commands (#308, #309)

- Add StarboardConfig, StarboardEntry, LevelConfig, MemberXP, LevelReward Prisma models
- Implement StarboardService with getConfig/upsertConfig/deleteConfig/getEntry/upsertEntry/getTopEntries
- Implement LevelService with XP, level-up detection, leaderboard, rank, and role rewards
- Add /starboard command: setup, disable, top, status subcommands
- Add /level command: rank, leaderboard, setup, reward add/remove subcommands
- Wire reaction handler for starboard emoji tracking (MessageReactionAdd)
- Add XP logic to messageHandler with cooldown and level-up announcements
- Add GuildMessageReactions intent to Discord client
- Export new services from packages/shared/src/services/index.ts
- 18 passing unit tests across starboard and level commands

* test(bot): mock reactionHandler in eventHandler spec

* test(bot): add reactionHandler and messageHandler XP coverage

---------

Co-authored-by: LucasSantana-Dev <LucasSantana-Dev@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview — 789bc157 Deployed Mar 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(bot): /starboard — pin popular messages to a dedicated channel

1 participant