Skip to content
GitHub

Reactions and other messages

MeoCord 4.2 · Messages and events · page 16 of 41 · since 4.1.0

Run a handler when a reaction is added or removed, or for every message a user sends.

You'll learn

  • Handle one emoji, a custom emoji, or every reaction
  • Tell an added reaction from a removed one, and who reacted
  • Run a listener on every message, beside message commands

Before this

@ReactionHandler('⭐') runs when someone adds or removes a ⭐ on a message. @MessageHandler() with no pattern runs for every message a user sends. Both are controller methods with guards, interceptors and filters, like any other handler.

When to use it

Use a reaction handler to act on reactions: a starboard, a poll, a role menu, or approving something with ✅. For clicks, buttons are usually better: each click has a customId and gets an answer. See Buttons, selects and modals.

Use a listener, @MessageHandler(), for work on all chat, such as logging, auto-moderation or counting activity. For a command a user types, give @MessageHandler a pattern, as in Message commands.

Example

controllers/reaction/star.reaction.controller.ts
import { type MessageReaction, type PartialMessageReaction } from 'discord.js'
import { Controller, ReactionHandler } from 'meocord/decorator'
import { ReactionHandlerAction } from 'meocord/enum'
import { type ReactionEvent } from 'meocord/interface'

@Controller()
export class StarReactionController {
  @ReactionHandler('⭐')
  async star(reaction: MessageReaction | PartialMessageReaction, { user, action }: ReactionEvent) {
    if (action !== ReactionHandlerAction.ADD || user.bot) return
    await reaction.message.reply(`${user.username} starred this.`)
  }
}
Dispatches reaction ⭐ on 'Ship it'

The second argument, a ReactionEvent, says who reacted, user, and whether the reaction was added or removed, action.

How it works

  1. Match. A reaction's emoji is compared with each handler's. Every handler that matches runs: in each controller, those for the emoji first, then those for every emoji.
  2. Fetch. reaction.message is the copy of the message the gateway keeps current. When the bot holds the message only by its id, as for one sent before it started, MeoCord fetches it before your handlers run, so it is complete. A reaction that arrives without its count, with Partials.Reaction, is fetched too, so reaction.count is a number. The cached copy can lag Discord only rarely: after a reconnect that could not resume, which misses the edits made meanwhile, or for a poll's counts when the bot lacks the GuildMessagePolls intent. A handler that needs the message straight from Discord calls await reaction.message.fetch() itself. A reaction to a message the bot can no longer read, deleted or in a channel it lost access to, is skipped.
  3. Pipeline. Each handler's guards, interceptors and filters run around it, as they do for a command.

Reactions from bots, the bot's own included, reach no handler, as messages from bots don't. A bot that seeds a poll with its own reactions doesn't count them as votes, and never answers itself.

Which emoji

WriteMatches
'👍'That standard emoji, by its character
'1234567890123456789'That one custom emoji, by its id
'<:party:1234567890…>'The same, as Discord shows it when you send \:party:
'party'Every custom emoji named party, one from each server the bot is in
nothingEvery reaction

Use a custom emoji's id when the bot is in more than one server, so another server's party doesn't count.

Added and removed

action is ReactionHandlerAction.ADD or REMOVE. A handler that only cares about one returns early for the other, as the example does. user is who added or removed it.

Bots' reactions

A handler that wants bots' reactions sets bots: true:

controllers/reaction/pin.reaction.controller.ts
// A 📌 pins the message, whoever adds it: a bot's reaction counts too
@ReactionHandler('📌', { bots: true })
async pin(reaction: MessageReaction | PartialMessageReaction, { action }: ReactionEvent) {
  if (action === ReactionHandlerAction.ADD) await reaction.message.pin()
}

// Every reaction, from users and bots alike, for an audit log
@ReactionHandler({ bots: true })
audit(reaction: MessageReaction | PartialMessageReaction, { user, action }: ReactionEvent) {
  this.log.push(`${user.username} ${action} ${reaction.emoji.name}`)
}

To skip a bot's reaction for the handlers that leave bots out, a user discord.js holds only in part is fetched; if that fails, only the handlers with bots: true run.

Every message

@MessageHandler() with no pattern is a listener. It runs for every message a user sends, after the one patterned handler the message matched, if any:

controllers/message/keyword.message.controller.ts
import { type Message } from 'discord.js'
import { Controller, MessageHandler } from 'meocord/decorator'

@Controller()
export class KeywordMessageController {
  private seen = 0

  // !ping, with the app's prefix, in any case: the whole message, word for word
  @MessageHandler('ping')
  async ping(message: Message) {
    await message.reply('Pong!')
  }

  // Runs for every message, after the one patterned handler it matched, if any
  @MessageHandler()
  count() {
    this.seen += 1
  }

  get messagesSeen() {
    return this.seen
  }
}
Dispatches message ping
  • It never runs for a message from a bot, or for one with no text.
  • Its guards only filter what it takes: a denial gets no reply, and one a guard throws as GuardDeniedError is logged at debug level.
  • It reads the message's text, so the bot needs the MessageContent intent, as the gotchas below explain.

Testing

module.invoke runs one handler with the arguments dispatch would pass. For a reaction, those are the reaction and its ReactionEvent, { user, action }:

controllers/reaction/star.reaction.controller.spec.ts
describe('StarReactionController', () => {
  const module = MeoCordTestingModule.create({ controllers: [StarReactionController] }).compile()
  const user = createMockInteraction(User, { username: 'mika', bot: false })

  it('replies when a star is added, and not when one is removed', async () => {
    const message = createMockMessage()
    const reaction = createMockInteraction(MessageReaction, { message })

    await module.invoke(StarReactionController, 'star', reaction, { user, action: ReactionHandlerAction.ADD })
    await module.invoke(StarReactionController, 'star', reaction, { user, action: ReactionHandlerAction.REMOVE })

    expect(message.reply).toHaveBeenCalledTimes(1)
    expect(message.reply).toHaveBeenCalledWith('mika starred this.')
  })
})

module.dispatch(reaction, { user, action }) sends the reaction through matching and every handler, as the bot does.

Gotchas

  • Nothing runs. Reactions need the GuildMessageReactions intent, or DirectMessageReactions in DMs. For reactions on messages sent before the bot started, add the Message and Reaction partials.
  • A reaction in a DM. It needs DirectMessageReactions. Since discord.js 14.26.2, discord.js drops a reaction in a DM channel it has not cached, because Discord's reaction event names the channel by its id alone, with no type. MeoCord fetches that channel once, on its first reaction, and delivers the reaction to your handlers and to any messageReactionAdd or messageReactionRemove listener of your own. Later reactions in that DM need no request. One window remains: a DM reaction that arrives before the bot is ready, in the seconds while discord.js waits for its servers, is still dropped.
  • A listener gets empty text. Without MessageContent, Discord sends a message's text only when it mentions the bot, is in a DM, or was sent by the bot. Enable the intent in clientOptions and in the developer portal.
  • A name matches too much. 'party' matches a party emoji from every server. Use the id.

Build it

When the bot files feedback from chat it replies Filed as feedback #3. Thank you!. Staff can decide it by reacting to that reply: ✅ approves, ❌ rejects.

tutorial/review.reaction.controller.ts
// Staff decide feedback by reacting to a reply of the bot's that names it as #3: ✅ approves, ❌ rejects
@Controller()
export class ReviewReactionController {
  constructor(
    private readonly feedback: FeedbackService,
    private readonly settings: FeedbackSettings,
  ) {}

  @ReactionHandler('✅')
  async approve(reaction: MessageReaction | PartialMessageReaction, options: ReactionEvent) {
    await this.decide(reaction, options, 'approved')
  }

  @ReactionHandler('❌')
  async reject(reaction: MessageReaction | PartialMessageReaction, options: ReactionEvent) {
    await this.decide(reaction, options, 'rejected')
  }

  private async decide(
    reaction: MessageReaction | PartialMessageReaction,
    { user, action }: ReactionEvent,
    status: 'approved' | 'rejected',
  ) {
    // MeoCord fetched the message before this ran, so its author and text are there
    const { message } = reaction
    const { guild } = message
    // Only a reaction added to the bot's own reply counts, such as its filing reply, and only from the staff
    if (action !== ReactionHandlerAction.ADD || !guild || message.author?.id !== message.client.user.id) return
    const member = await guild.members.fetch(user.id)
    if (!member.roles.cache.has(this.settings.staffRoleId)) return
    const id = /#(\d+)/.exec(message.content ?? '')?.[1]
    if (!id) return
    try {
      const feedback = this.feedback.decide(id, status)
      await message.reply(`Feedback #${feedback.id} is ${feedback.status}.`)
    } catch (error) {
      // A reply naming feedback the bot doesn't hold, such as "There is no feedback #9.", decides nothing
      if (!(error instanceof FeedbackNotFoundError)) throw error
    }
  }
}

The handler acts only on an added reaction, only on a reply of the bot's that names feedback, where it reads the feedback's number, and only for a member holding the staff role from FeedbackSettings; anyone else's reaction changes nothing. A status reply names feedback too, so staff can decide from either; one naming feedback the bot doesn't hold, such as There is no feedback #9., decides nothing. In the app, add the controller, the GuildMessageReactions intent, and the Message and Reaction partials, so reactions to replies sent before the bot restarted still arrive:

tutorial/app.ts
@MeoCord({
  controllers: [
    // The slash command and its form, and the review buttons
    FeedbackController,
    ReviewController,
    FeedbackMessageController,
    ReviewReactionController,
  ],
  clientOptions: {
    intents: [
      // Interactions arrive with Guilds alone, and DMs are sent, not read
      GatewayIntentBits.Guilds,
      // Messages in servers; a mention of the bot carries its text without MessageContent
      GatewayIntentBits.GuildMessages,
      GatewayIntentBits.GuildMessageReactions,
    ],
    // So reactions to messages sent before the bot started still arrive
    partials: [Partials.Message, Partials.Reaction],
  },
  // A message command starts with a mention of the bot, never a prefix
  messages: { mention: 'only' },
  presenter: FeedbackPresenter,
})
export default class App {}

Next steps