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

VolvoxLLC / volvox-bot / 25426562232

06 May 2026 09:12AM UTC coverage: 90.19% (+0.004%) from 90.186%
25426562232

Pull #757

github

web-flow
Merge 04a7829cc into 6f6a8e8f4
Pull Request #757: ci: upload bot and web coverage to SonarCloud

10082 of 11816 branches covered (85.32%)

Branch coverage included in aggregate %.

15890 of 16981 relevant lines covered (93.58%)

169.34 hits per line

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

83.11
/src/modules/welcomeDynamicContext.js
1
/**
2
 * Welcome Dynamic Context
3
 *
4
 * Computes dynamic context variables for welcome message templates,
5
 * including time-of-day greetings, community activity snapshots,
6
 * channel suggestions, and member milestones.
7
 */
8

9
import { randomInt } from 'node:crypto';
10

11
/** Default activity window in minutes for community activity tracking */
12
export const DEFAULT_ACTIVITY_WINDOW_MINUTES = 45;
50✔
13

14
/** Notable member-count milestones (hoisted to avoid allocation per welcome event) */
15
export const NOTABLE_MILESTONES = new Set([10, 25, 50, 100, 250, 500, 1000]);
50✔
16

17
/**
18
 * Compute dynamic context variables for template rendering.
19
 *
20
 * Returns an object whose keys map to template placeholders:
21
 *   {{greeting}}      – Time-of-day greeting line
22
 *   {{vibeLine}}      – Community activity description
23
 *   {{ctaLine}}       – Suggested channels call-to-action
24
 *   {{milestoneLine}} – Member milestone or "rolled in as member #N"
25
 *   {{timeOfDay}}     – morning, afternoon, evening, or night
26
 *   {{activityLevel}} – quiet, light, steady, busy, or hype
27
 *   {{topChannels}}   – Most active channel mentions
28
 *
29
 * @param {Object} member - Discord guild member
30
 * @param {Object} config - Bot configuration
31
 * @param {Map} guildActivity - Guild activity map (guildId → Map<channelId, timestamps>)
32
 * @returns {Object} Dynamic template variables
33
 */
34
export function computeDynamicContext(member, config, guildActivity) {
35
  const welcomeDynamic = config?.welcome?.dynamic || {};
25!
36
  const timezone = welcomeDynamic.timezone || 'America/New_York';
25✔
37

38
  const memberContext = {
25✔
39
    id: member.id,
40
    username: member.user?.username || 'Unknown',
25!
41
    server: member.guild?.name || 'the server',
25!
42
    memberCount: member.guild?.memberCount || 0,
25!
43
  };
44

45
  const timeOfDay = getTimeOfDay(timezone);
25✔
46
  const snapshot = getCommunitySnapshot(member.guild, welcomeDynamic, guildActivity);
25✔
47
  const suggestedChannels = getSuggestedChannels(member, config, snapshot);
25✔
48

49
  const milestone = getMilestoneLine(memberContext.memberCount, welcomeDynamic);
25✔
50

51
  return {
25✔
52
    greeting: pickFrom(getGreetingTemplates(timeOfDay), memberContext),
53
    vibeLine: buildVibeLine(snapshot, suggestedChannels),
54
    ctaLine: buildCtaLine(suggestedChannels),
55
    milestoneLine: milestone || `You just rolled in as member **#${memberContext.memberCount}**.`,
42✔
56
    timeOfDay,
57
    activityLevel: snapshot.level,
58
    topChannels: suggestedChannels.slice(0, 3).join(', '),
59
  };
60
}
61

62
/**
63
 * Get activity snapshot for the guild.
64
 *
65
 * **Side-effect:** mutates `guildActivity` — prunes stale per-channel timestamp
66
 * arrays, deletes empty channel entries, and removes the guild key entirely when
67
 * no channels remain.
68
 *
69
 * @param {Object} guild - Discord guild
70
 * @param {Object} settings - welcome.dynamic settings
71
 * @param {Map} guildActivity - Guild activity map (guildId → Map<channelId, timestamps>)
72
 * @returns {{messageCount:number,activeTextChannels:number,topChannelIds:string[],voiceParticipants:number,voiceChannels:number,level:string}}
73
 */
74
export function getCommunitySnapshot(guild, settings, guildActivity) {
75
  const activityMap = guildActivity.get(guild.id) || new Map();
25✔
76
  const now = Date.now();
25✔
77
  const windowMs = getActivityWindowMs(settings);
25✔
78
  const cutoff = now - windowMs;
25✔
79

80
  let messageCount = 0;
25✔
81
  const channelCounts = [];
25✔
82

83
  for (const [channelId, timestamps] of activityMap.entries()) {
25✔
84
    const recent = timestamps.filter((t) => t >= cutoff);
107✔
85

86
    if (!recent.length) {
5!
87
      activityMap.delete(channelId);
×
88
      continue;
×
89
    }
90

91
    // Write the pruned array back so stale entries don't accumulate forever
92
    activityMap.set(channelId, recent);
5✔
93

94
    messageCount += recent.length;
5✔
95
    channelCounts.push({ channelId, count: recent.length });
5✔
96
  }
97

98
  // Evict guild entry if no channels remain
99
  if (activityMap.size === 0) {
25✔
100
    guildActivity.delete(guild.id);
20✔
101
  }
102

103
  const topChannelIds = channelCounts
25✔
104
    .sort((a, b) => b.count - a.count)
×
105
    .slice(0, 3)
106
    .map((entry) => entry.channelId);
5✔
107

108
  const activeVoiceChannels = guild.channels.cache.filter(
25✔
109
    (channel) => channel?.isVoiceBased?.() && channel.members?.size > 0,
16✔
110
  );
111

112
  const voiceChannels = activeVoiceChannels.size;
25✔
113
  const voiceParticipants = [...activeVoiceChannels.values()].reduce(
25✔
114
    (sum, channel) => sum + (channel.members?.size || 0),
3!
115
    0,
116
  );
117

118
  const level = getActivityLevel(messageCount, voiceParticipants);
25✔
119

120
  return {
25✔
121
    messageCount,
122
    activeTextChannels: channelCounts.length,
123
    topChannelIds,
124
    voiceParticipants,
125
    voiceChannels,
126
    level,
127
  };
128
}
129

130
/**
131
 * Get activity level from message + voice activity.
132
 * @param {number} messageCount - Messages in rolling window
133
 * @param {number} voiceParticipants - Active users in voice channels
134
 * @returns {'quiet'|'light'|'steady'|'busy'|'hype'}
135
 */
136
export function getActivityLevel(messageCount, voiceParticipants) {
137
  if (messageCount >= 60 || voiceParticipants >= 15) return 'hype';
25✔
138
  if (messageCount >= 25 || voiceParticipants >= 8) return 'busy';
24✔
139
  if (messageCount >= 8 || voiceParticipants >= 3) return 'steady';
23✔
140
  if (messageCount >= 1 || voiceParticipants >= 1) return 'light';
20✔
141
  return 'quiet';
19✔
142
}
143

144
/**
145
 * Build vibe line from current community activity.
146
 * @param {Object} snapshot - Community snapshot
147
 * @param {string[]} suggestedChannels - Channel mentions
148
 * @returns {string}
149
 */
150
export function buildVibeLine(snapshot, suggestedChannels) {
151
  const topChannels = snapshot.topChannelIds.map((id) => `<#${id}>`);
25✔
152
  const channelList = (topChannels.length ? topChannels : suggestedChannels).slice(0, 2);
25✔
153
  const channelText = channelList.join(' + ');
25✔
154
  const hasChannels = channelList.length > 0;
25✔
155

156
  switch (snapshot.level) {
25✔
157
    case 'hype':
158
      return hasChannels
1!
159
        ? `The place is buzzing right now - big energy in ${channelText}.`
160
        : `The place is buzzing right now - big energy everywhere.`;
161
    case 'busy':
162
      return hasChannels
1!
163
        ? `Good timing: chat is active (${snapshot.messageCount} messages recently), especially in ${channelText}.`
164
        : `Good timing: the server is active right now (${snapshot.messageCount} messages recently${snapshot.voiceParticipants > 0 ? `, ${snapshot.voiceParticipants} in voice` : ''}).`;
×
165
    case 'steady':
166
      return hasChannels
3✔
167
        ? `Things are moving at a healthy pace in ${channelText}, so you'll fit right in.`
168
        : `Things are moving at a healthy pace, so you'll fit right in.`;
169
    case 'light':
170
      if (snapshot.voiceChannels > 0 && !hasChannels) {
1!
171
        return `${snapshot.voiceParticipants} ${snapshot.voiceParticipants === 1 ? 'person is' : 'people are'} hanging out in voice right now — jump in anytime.`;
×
172
      }
173
      if (snapshot.voiceChannels > 0) {
1!
174
        return `${snapshot.voiceParticipants} ${snapshot.voiceParticipants === 1 ? 'person is' : 'people are'} hanging out in voice right now, and ${channelText} is waking up.`;
1!
175
      }
176
      return hasChannels
×
177
        ? `It's a chill moment, but ${channelText} is where people are checking in.`
178
        : `It's a chill moment — perfect time to say hello.`;
179
    default:
180
      return `You're catching us in a quiet window - perfect time to introduce yourself before the chaos starts.`;
19✔
181
  }
182
}
183

184
/**
185
 * Build CTA line with channel suggestions.
186
 * @param {string[]} channels - Channel mentions
187
 * @returns {string}
188
 */
189
export function buildCtaLine(channels) {
190
  const [first, second, third] = channels;
25✔
191

192
  if (first && second && third) {
25✔
193
    return `Start in ${first}, check out ${second}, and browse ${third}.`;
3✔
194
  }
195
  if (first && second) {
22✔
196
    return `Start in ${first} or check out ${second}.`;
1✔
197
  }
198
  if (first) {
21✔
199
    return `Head over to ${first} to get started.`;
6✔
200
  }
201

202
  return 'Say hey and introduce yourself — we\u0027re glad you\u0027re here.';
15✔
203
}
204

205
/**
206
 * Build milestone line when member count hits notable threshold.
207
 * @param {number} memberCount - Current member count
208
 * @param {Object} settings - welcome.dynamic settings
209
 * @returns {string|null}
210
 */
211
export function getMilestoneLine(memberCount, settings) {
212
  if (!memberCount) return null;
25!
213

214
  const parsedInterval = Number(settings.milestoneInterval);
25✔
215
  const interval = Number.isFinite(parsedInterval) ? parsedInterval : 25;
25✔
216

217
  if (NOTABLE_MILESTONES.has(memberCount) || (interval > 0 && memberCount % interval === 0)) {
25✔
218
    return `🎉 Perfect timing - you're our **#${memberCount}** member milestone!`;
8✔
219
  }
220

221
  return null;
17✔
222
}
223

224
/**
225
 * Determine time of day for greeting.
226
 * @param {string} timezone - IANA timezone
227
 * @returns {'morning'|'afternoon'|'evening'|'night'}
228
 */
229
export function getTimeOfDay(timezone) {
230
  const hour = getHourInTimezone(timezone);
25✔
231

232
  if (hour >= 5 && hour < 12) return 'morning';
25✔
233
  if (hour >= 12 && hour < 17) return 'afternoon';
2!
234
  if (hour >= 17 && hour < 22) return 'evening';
2✔
235
  return 'night';
1✔
236
}
237

238
/**
239
 * Get hour in timezone.
240
 * @param {string} timezone - IANA timezone
241
 * @returns {number}
242
 */
243
export function getHourInTimezone(timezone) {
244
  try {
25✔
245
    const hourString = new Intl.DateTimeFormat('en-US', {
25✔
246
      hour: '2-digit',
247
      hour12: false,
248
      timeZone: timezone,
249
    }).format(new Date());
250

251
    const hour = Number(hourString);
25✔
252
    return Number.isFinite(hour) ? hour : new Date().getHours();
25!
253
  } catch {
254
    return new Date().getHours();
×
255
  }
256
}
257

258
/**
259
 * Get greeting templates by time of day.
260
 * @param {'morning'|'afternoon'|'evening'|'night'} timeOfDay - Time context
261
 * @returns {Array<(ctx:Object)=>string>}
262
 */
263
export function getGreetingTemplates(timeOfDay) {
264
  const templates = {
25✔
265
    morning: [
266
      (ctx) => `☀️ Morning and welcome to **${ctx.server}**, <@${ctx.id}>!`,
8✔
267
      (ctx) => `Hey <@${ctx.id}> - great way to start the day. Welcome to **${ctx.server}**!`,
5✔
268
      (ctx) => `Good morning <@${ctx.id}> 👋 You just joined **${ctx.server}**.`,
10✔
269
    ],
270
    afternoon: [
271
      (ctx) => `👋 Welcome to **${ctx.server}**, <@${ctx.id}>!`,
×
272
      (ctx) =>
273
        `Nice timing, <@${ctx.id}> - welcome to the **${ctx.server}** corner of the internet.`,
×
274
      (ctx) => `Hey <@${ctx.id}>! Glad you made it into **${ctx.server}**.`,
×
275
    ],
276
    evening: [
277
      (ctx) => `🌆 Evening crew just got better - welcome, <@${ctx.id}>!`,
1✔
278
      (ctx) => `Welcome to **${ctx.server}**, <@${ctx.id}>. Prime build-hours energy right now.`,
×
279
      (ctx) => `Hey <@${ctx.id}> 👋 Great time to join the party at **${ctx.server}**.`,
×
280
    ],
281
    night: [
282
      (ctx) => `🌙 Night owl spotted. Welcome to **${ctx.server}**, <@${ctx.id}>!`,
×
283
      (ctx) => `Late-night builders are active - welcome in, <@${ctx.id}>.`,
1✔
284
      (ctx) => `Welcome <@${ctx.id}>! The night shift at **${ctx.server}** is undefeated.`,
×
285
    ],
286
  };
287

288
  return templates[timeOfDay] || templates.afternoon;
25!
289
}
290

291
/**
292
 * Pick channels to suggest based on active channels, configured highlights, and legacy template links.
293
 * @param {Object} member - Discord guild member
294
 * @param {Object} config - Bot configuration
295
 * @param {Object} snapshot - Community snapshot
296
 * @returns {string[]} Channel mentions
297
 */
298
export function getSuggestedChannels(member, config, snapshot) {
299
  const dynamic = config?.welcome?.dynamic || {};
25!
300
  const configured = Array.isArray(dynamic.highlightChannels) ? dynamic.highlightChannels : [];
25✔
301
  const legacy = extractChannelIdsFromTemplate(config?.welcome?.message || '');
25✔
302
  const top = snapshot.topChannelIds || [];
25!
303

304
  const channelIds = [...new Set([...top, ...configured, ...legacy])]
25✔
305
    .filter(Boolean)
306
    .filter((id) => member.guild.channels.cache.has(id))
22✔
307
    .slice(0, 3);
308

309
  return channelIds.map((id) => `<#${id}>`);
25✔
310
}
311

312
/**
313
 * Extract channel IDs from legacy message template (<#...> format)
314
 * @param {string} template - Legacy welcome template
315
 * @returns {string[]} Channel IDs
316
 */
317
export function extractChannelIdsFromTemplate(template) {
318
  return Array.from(template.matchAll(/<#(\d+)>/g), (m) => m[1]);
25✔
319
}
320

321
/**
322
 * Calculate activity window in ms.
323
 * @param {Object} settings - welcome.dynamic settings
324
 * @returns {number}
325
 */
326
export function getActivityWindowMs(settings) {
327
  const minutes = Number(settings.activityWindowMinutes) || DEFAULT_ACTIVITY_WINDOW_MINUTES;
244✔
328
  return Math.max(5, minutes) * 60 * 1000;
244✔
329
}
330

331
/**
332
 * Pick one function from template list and execute with context.
333
 * @param {Array<(ctx:Object)=>string>} templates - Template fns
334
 * @param {Object} context - Template context
335
 * @returns {string}
336
 */
337
export function pickFrom(templates, context) {
338
  if (!templates.length) return `Welcome, <@${context.id}>!`;
25!
339
  const index = randomInt(templates.length);
25✔
340
  return templates[index](context);
25✔
341
}
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