• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 22667312941

04 Mar 2026 11:25AM UTC coverage: 87.864% (+0.07%) from 87.794%
22667312941

push

github

web-flow
refactor: modularize events.js and add missing tests (#240)

5819 of 7025 branches covered (82.83%)

Branch coverage included in aggregate %.

258 of 322 new or added lines in 7 files covered. (80.12%)

42 existing lines in 5 files now uncovered.

9979 of 10955 relevant lines covered (91.09%)

236.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.33
/src/modules/events/messageCreate.js
1
/**
2
 * MessageCreate Event Handler
3
 * Handles incoming Discord messages
4
 */
5

6
import { Events } from 'discord.js';
7
import { error as logError, warn } from '../../logger.js';
8
import { getUserFriendlyMessage } from '../../utils/errors.js';
9
import { safeReply } from '../../utils/safeSend.js';
10
import { handleAfkMentions } from '../afkHandler.js';
11
import { isChannelBlocked } from '../ai.js';
12
import { checkAiAutoMod } from '../aiAutoMod.js';
13
import { getConfig } from '../config.js';
14
import { trackMessage } from '../engagement.js';
15
import { checkLinks } from '../linkFilter.js';
16
import { handleQuietCommand, isQuietMode } from '../quietMode.js';
17
import { checkRateLimit } from '../rateLimit.js';
18
import { handleXpGain } from '../reputation.js';
19
import { isSpam, sendSpamAlert } from '../spam.js';
20
import { accumulateMessage, evaluateNow } from '../triage.js';
21
import { recordCommunityActivity } from '../welcome.js';
22

23
/**
24
 * Register the MessageCreate event handler that processes incoming messages
25
 * for spam detection, community activity recording, and triage-based AI routing.
26
 *
27
 * Flow:
28
 * 1. Ignore bots/DMs
29
 * 2. Spam detection
30
 * 3. Community activity tracking
31
 * 4. @mention/reply → evaluateNow (triage classifies + responds internally)
32
 * 5. Otherwise → accumulateMessage (buffer for periodic triage eval)
33
 *
34
 * @param {Client} client - Discord client instance
35
 * @param {Object} _config - Unused (kept for API compatibility); handler resolves per-guild config via getConfig().
36
 * @param {Object} healthMonitor - Optional health monitor for metrics
37
 */
38
export function registerMessageCreateHandler(client, _config, healthMonitor) {
39
  client.on(Events.MessageCreate, async (message) => {
32✔
40
    // Ignore bots and DMs
41
    if (message.author.bot) return;
31✔
42
    if (!message.guild) return;
30✔
43

44
    // Resolve per-guild config so feature gates respect guild overrides
45
    const guildConfig = getConfig(message.guild.id);
29✔
46

47
    // AFK handler — check if sender is AFK or if any mentioned user is AFK
48
    try {
29✔
49
      await handleAfkMentions(message);
29✔
50
    } catch (afkErr) {
51
      logError('AFK handler failed', {
1✔
52
        channelId: message.channel.id,
53
        userId: message.author.id,
54
        error: afkErr?.message,
55
      });
56
    }
57

58
    // Rate limit + link filter — both gated on moderation.enabled.
59
    // Each check is isolated so a failure in one doesn't prevent the other from running.
60
    if (guildConfig.moderation?.enabled) {
29✔
61
      try {
27✔
62
        const { limited } = await checkRateLimit(message, guildConfig);
27✔
63
        if (limited) return;
26✔
64
      } catch (rlErr) {
65
        logError('Rate limit check failed', {
1✔
66
          channelId: message.channel.id,
67
          userId: message.author.id,
68
          error: rlErr?.message,
69
        });
70
      }
71

72
      try {
25✔
73
        const { blocked } = await checkLinks(message, guildConfig);
25✔
74
        if (blocked) return;
24✔
75
      } catch (lfErr) {
76
        logError('Link filter check failed', {
1✔
77
          channelId: message.channel.id,
78
          userId: message.author.id,
79
          error: lfErr?.message,
80
        });
81
      }
82
    }
83

84
    // Spam detection
85
    if (guildConfig.moderation?.enabled && isSpam(message.content)) {
25✔
86
      warn('Spam detected', { userId: message.author.id, contentPreview: '[redacted]' });
1✔
87
      try {
1✔
88
        await sendSpamAlert(message, client, guildConfig);
1✔
89
      } catch (alertErr) {
NEW
90
        logError('Failed to send spam alert', {
×
91
          channelId: message.channel.id,
92
          userId: message.author.id,
93
          error: alertErr?.message,
94
        });
95
      }
96
      return;
1✔
97
    }
98

99
    // AI Auto-Moderation — analyze message with Claude for toxicity/spam/harassment
100
    // Runs after basic spam check; gated on aiAutoMod.enabled in config
101
    try {
24✔
102
      const { flagged } = await checkAiAutoMod(message, client, guildConfig);
24✔
103
      if (flagged) return;
24!
104
    } catch (aiModErr) {
NEW
105
      logError('AI auto-mod check failed', {
×
106
        channelId: message.channel.id,
107
        userId: message.author.id,
108
        error: aiModErr?.message,
109
      });
110
    }
111

112
    // Feed welcome-context activity tracker
113
    recordCommunityActivity(message, guildConfig);
24✔
114

115
    // Engagement tracking (fire-and-forget, non-blocking)
116
    void (async () => {
24✔
117
      try {
24✔
118
        await trackMessage(message);
24✔
119
      } catch (err) {
NEW
120
        logError('Engagement tracking failed', {
×
121
          channelId: message.channel.id,
122
          userId: message.author.id,
123
          error: err?.message,
124
        });
125
      }
126
    })();
127

128
    // XP gain (fire-and-forget, non-blocking)
129
    void (async () => {
24✔
130
      try {
24✔
131
        await handleXpGain(message);
24✔
132
      } catch (err) {
NEW
133
        logError('XP gain handler failed', {
×
134
          userId: message.author.id,
135
          guildId: message.guild.id,
136
          error: err?.message,
137
        });
138
      }
139
    })();
140

141
    // AI chat — @mention or reply to bot → instant triage evaluation
142
    if (guildConfig.ai?.enabled) {
24✔
143
      const isMentioned = message.mentions.has(client.user);
22✔
144

145
      // Detect replies to the bot. The mentions.repliedUser check covers the
146
      // common case, but fails when the user toggles off "mention on reply"
147
      // in Discord. Fall back to fetching the referenced message directly.
148
      let isReply = false;
22✔
149
      if (message.reference?.messageId) {
22✔
150
        if (message.mentions.repliedUser?.id === client.user.id) {
4✔
151
          isReply = true;
1✔
152
        } else {
153
          try {
3✔
154
            const ref = await message.channel.messages.fetch(message.reference.messageId);
3✔
155
            isReply = ref.author.id === client.user.id;
2✔
156
          } catch (fetchErr) {
157
            warn('Could not fetch referenced message for reply detection', {
1✔
158
              channelId: message.channel.id,
159
              messageId: message.reference.messageId,
160
              error: fetchErr?.message,
161
            });
162
          }
163
        }
164
      }
165

166
      // Check if in allowed channel (if configured)
167
      // When inside a thread, check the parent channel ID against the allowlist
168
      // so thread replies aren't blocked by the whitelist.
169
      const allowedChannels = guildConfig.ai?.channels || [];
22✔
170
      const channelIdToCheck = message.channel.isThread?.()
22✔
171
        ? message.channel.parentId
172
        : message.channel.id;
173
      const isAllowedChannel =
174
        allowedChannels.length === 0 || allowedChannels.includes(channelIdToCheck);
22✔
175

176
      // Check blocklist — blocked channels never get AI responses.
177
      // For threads, parentId is also checked so blocking the parent channel
178
      // blocks all its child threads.
179
      const parentId = message.channel.isThread?.() ? message.channel.parentId : null;
22✔
180
      if (isChannelBlocked(message.channel.id, parentId, message.guild.id)) return;
22✔
181

182
      if ((isMentioned || isReply) && isAllowedChannel) {
19✔
183
        // Quiet mode: handle commands first (even during quiet mode so users can unquiet)
184
        if (isMentioned) {
9✔
185
          try {
6✔
186
            const wasQuietCommand = await handleQuietCommand(message, guildConfig);
6✔
187
            if (wasQuietCommand) return;
6!
188
          } catch (qmErr) {
NEW
189
            logError('Quiet mode command handler failed', {
×
190
              channelId: message.channel.id,
191
              userId: message.author.id,
192
              error: qmErr?.message,
193
            });
194
          }
195
        }
196

197
        // Quiet mode: suppress AI responses when quiet mode is active (gated on feature enabled)
198
        if (guildConfig.quietMode?.enabled) {
9!
NEW
199
          try {
×
NEW
200
            if (await isQuietMode(message.guild.id, message.channel.id)) return;
×
201
          } catch (qmErr) {
NEW
202
            logError('Quiet mode check failed', {
×
203
              channelId: message.channel.id,
204
              error: qmErr?.message,
205
            });
206
          }
207
        }
208

209
        // Accumulate the message into the triage buffer (for context).
210
        // Even bare @mentions with no text go through triage so the classifier
211
        // can use recent channel history to produce a meaningful response.
212
        // Await to ensure message is in buffer before forced triage.
213
        try {
9✔
214
          await accumulateMessage(message, guildConfig);
9✔
215
        } catch (accErr) {
NEW
216
          logError('Failed to accumulate message for triage', {
×
217
            channelId: message.channel.id,
218
            error: accErr?.message,
219
          });
NEW
220
          return;
×
221
        }
222

223
        // Show typing indicator immediately so the user sees feedback
224
        void (async () => {
9✔
225
          try {
9✔
226
            await message.channel.sendTyping();
9✔
227
          } catch {
228
            // Silently ignore typing indicator failures
229
          }
230
        })();
231

232
        // Force immediate triage evaluation — triage owns the full response lifecycle
233
        try {
9✔
234
          await evaluateNow(message.channel.id, guildConfig, client, healthMonitor);
9✔
235
        } catch (err) {
236
          logError('Triage evaluation failed for mention', {
2✔
237
            channelId: message.channel.id,
238
            error: err.message,
239
          });
240
          try {
2✔
241
            await safeReply(message, getUserFriendlyMessage(err));
2✔
242
          } catch (replyErr) {
243
            warn('safeReply failed for error fallback', {
1✔
244
              channelId: message.channel.id,
245
              userId: message.author.id,
246
              error: replyErr?.message,
247
            });
248
          }
249
        }
250

251
        return; // Don't accumulate again below
9✔
252
      }
253
    }
254

255
    // Triage: accumulate message for periodic evaluation (fire-and-forget)
256
    // Gated on ai.enabled — this is the master kill-switch for all AI responses.
257
    // accumulateMessage also checks triage.enabled internally.
258
    // Skip accumulation when quiet mode is active in this channel (gated on feature enabled).
259
    if (guildConfig.ai?.enabled) {
12✔
260
      if (guildConfig.quietMode?.enabled) {
10!
NEW
261
        try {
×
NEW
262
          if (await isQuietMode(message.guild.id, message.channel.id)) return;
×
263
        } catch (qmErr) {
NEW
264
          logError('Quiet mode check failed (accumulate)', {
×
265
            channelId: message.channel.id,
266
            error: qmErr?.message,
267
          });
268
        }
269
      }
270
      void (async () => {
10✔
271
        try {
10✔
272
          await accumulateMessage(message, guildConfig);
10✔
273
        } catch (err) {
274
          logError('Triage accumulate error', { error: err?.message });
2✔
275
        }
276
      })();
277
    }
278
  });
279
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc