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

VolvoxLLC / volvox-bot / 23614014456

26 Mar 2026 07:30PM UTC coverage: 90.689% (+0.03%) from 90.656%
23614014456

push

github

web-flow
feat(xp): sendDm, announce, addReaction action types (#397)

* feat(xp): add sendDm, announce, addReaction action types (Closes #368)

* fix: use safeSend in sendDm, extract shared payload builder

6633 of 7756 branches covered (85.52%)

Branch coverage included in aggregate %.

78 of 83 new or added lines in 5 files covered. (93.98%)

8 existing lines in 3 files now uncovered.

11318 of 12038 relevant lines covered (94.02%)

221.14 hits per line

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

96.58
/src/api/utils/configValidation.js
1
/**
2
 * Shared config validation utilities.
3
 *
4
 * Centralises CONFIG_SCHEMA, validateValue, and validateSingleValue so that
5
 * both route handlers and util modules can import from a single source of
6
 * truth without creating an inverted dependency (utils → routes).
7
 */
8

9
/** Module-level cache for compiled regex patterns used during validation. */
10
const _compiledPatterns = new Map();
55✔
11

12
/** Maximum number of distinct patterns to keep in the cache. */
13
const _MAX_PATTERN_CACHE = 100;
55✔
14

15
/**
16
 * Return a cached compiled RegExp for the given pattern string.
17
 * Avoids re-compiling the same pattern on every config validation call.
18
 * The cache is capped at _MAX_PATTERN_CACHE entries to prevent unbounded growth
19
 * in environments with dynamic schema patterns.
20
 *
21
 * @param {string} pattern
22
 * @returns {RegExp}
23
 */
24
function getCompiledPattern(pattern) {
25
  let re = _compiledPatterns.get(pattern);
4✔
26
  if (!re) {
4✔
27
    if (_compiledPatterns.size >= _MAX_PATTERN_CACHE) {
1!
28
      // Evict the oldest entry (Map preserves insertion order).
29
      _compiledPatterns.delete(_compiledPatterns.keys().next().value);
×
30
    }
31
    re = new RegExp(pattern);
1✔
32
    _compiledPatterns.set(pattern, re);
1✔
33
  }
34
  return re;
4✔
35
}
36

37
/**
38
 * Schema definitions for writable config sections.
39
 * Used to validate types before persisting changes.
40
 */
41
export const CONFIG_SCHEMA = {
55✔
42
  ai: {
43
    type: 'object',
44
    properties: {
45
      enabled: { type: 'boolean' },
46
      systemPrompt: { type: 'string', maxLength: 4000 },
47
      channels: { type: 'array' },
48
      blockedChannelIds: { type: 'array' },
49
      historyLength: { type: 'number', min: 1, max: 100 },
50
      historyTTLDays: { type: 'number', min: 1, max: 365 },
51
      threadMode: {
52
        type: 'object',
53
        properties: {
54
          enabled: { type: 'boolean' },
55
          autoArchiveMinutes: { type: 'number', min: 60, max: 10080 },
56
          reuseWindowMinutes: { type: 'number', min: 1, max: 1440 },
57
        },
58
      },
59
      channelModes: { type: 'object', openProperties: true },
60
      defaultChannelMode: { type: 'string', enum: ['off', 'mention', 'vibe'] },
61
    },
62
  },
63
  welcome: {
64
    type: 'object',
65
    properties: {
66
      enabled: { type: 'boolean' },
67
      channelId: { type: 'string', nullable: true },
68
      message: { type: 'string' },
69
      variants: {
70
        type: 'array',
71
        items: { type: 'string' },
72
      },
73
      channels: {
74
        type: 'array',
75
        items: {
76
          type: 'object',
77
          properties: {
78
            channelId: { type: 'string' },
79
            message: { type: 'string' },
80
            variants: { type: 'array', items: { type: 'string' } },
81
          },
82
          required: ['channelId'],
83
        },
84
      },
85
      dynamic: {
86
        type: 'object',
87
        properties: {
88
          enabled: { type: 'boolean' },
89
          timezone: { type: 'string' },
90
          activityWindowMinutes: { type: 'number', min: 1, max: 10080 },
91
          milestoneInterval: { type: 'number', min: 1, max: 10000 },
92
          highlightChannels: { type: 'array' },
93
          excludeChannels: { type: 'array' },
94
        },
95
      },
96
      rulesChannel: { type: 'string', nullable: true },
97
      verifiedRole: { type: 'string', nullable: true },
98
      introChannel: { type: 'string', nullable: true },
99
      roleMenu: {
100
        type: 'object',
101
        properties: {
102
          enabled: { type: 'boolean' },
103
          options: { type: 'array', items: { type: 'object', required: ['label', 'roleId'] } },
104
        },
105
      },
106
      dmSequence: {
107
        type: 'object',
108
        properties: {
109
          enabled: { type: 'boolean' },
110
          steps: { type: 'array', items: { type: 'string' } },
111
        },
112
      },
113
    },
114
  },
115
  spam: {
116
    type: 'object',
117
    properties: {
118
      enabled: { type: 'boolean' },
119
    },
120
  },
121
  moderation: {
122
    type: 'object',
123
    properties: {
124
      enabled: { type: 'boolean' },
125
      alertChannelId: { type: 'string', nullable: true },
126
      autoDelete: { type: 'boolean' },
127
      dmNotifications: {
128
        type: 'object',
129
        properties: {
130
          warn: { type: 'boolean' },
131
          timeout: { type: 'boolean' },
132
          kick: { type: 'boolean' },
133
          ban: { type: 'boolean' },
134
        },
135
      },
136
      escalation: {
137
        type: 'object',
138
        properties: {
139
          enabled: { type: 'boolean' },
140
          thresholds: { type: 'array' },
141
        },
142
      },
143
      logging: {
144
        type: 'object',
145
        properties: {
146
          channels: {
147
            type: 'object',
148
            properties: {
149
              default: { type: 'string', nullable: true },
150
              warns: { type: 'string', nullable: true },
151
              bans: { type: 'string', nullable: true },
152
              kicks: { type: 'string', nullable: true },
153
              timeouts: { type: 'string', nullable: true },
154
              purges: { type: 'string', nullable: true },
155
              locks: { type: 'string', nullable: true },
156
            },
157
          },
158
        },
159
      },
160
      protectRoles: {
161
        type: 'object',
162
        properties: {
163
          enabled: { type: 'boolean' },
164
          roleIds: { type: 'array', items: { type: 'string' } },
165
          includeAdmins: { type: 'boolean' },
166
          includeModerators: { type: 'boolean' },
167
          includeServerOwner: { type: 'boolean' },
168
        },
169
      },
170
    },
171
  },
172
  triage: {
173
    type: 'object',
174
    properties: {
175
      enabled: { type: 'boolean' },
176
      defaultInterval: { type: 'number', min: 1, max: 3600 },
177
      maxBufferSize: { type: 'number', min: 1, max: 1000 },
178
      triggerWords: { type: 'array' },
179
      moderationKeywords: { type: 'array' },
180
      classifyModel: { type: 'string' },
181
      classifyBudget: { type: 'number', min: 0, max: 100000 },
182
      respondModel: { type: 'string' },
183
      respondBudget: { type: 'number', min: 0, max: 100000 },
184
      thinkingTokens: { type: 'number', min: 0, max: 100000 },
185
      classifyBaseUrl: { type: 'string', nullable: true },
186
      classifyApiKey: { type: 'string', nullable: true },
187
      respondBaseUrl: { type: 'string', nullable: true },
188
      respondApiKey: { type: 'string', nullable: true },
189
      streaming: { type: 'boolean' },
190
      tokenRecycleLimit: { type: 'number', min: 0, max: 1000000 },
191
      contextMessages: { type: 'number', min: 0, max: 100 },
192
      timeout: { type: 'number', min: 1000, max: 300000 },
193
      moderationResponse: { type: 'boolean' },
194
      channels: { type: 'array' },
195
      excludeChannels: { type: 'array' },
196
      debugFooter: { type: 'boolean' },
197
      debugFooterLevel: { type: 'string', nullable: true },
198
      moderationLogChannel: { type: 'string', nullable: true },
199
    },
200
  },
201
  auditLog: {
202
    type: 'object',
203
    properties: {
204
      enabled: { type: 'boolean' },
205
      retentionDays: { type: 'number', min: 1, max: 365 },
206
    },
207
  },
208
  botStatus: {
209
    type: 'object',
210
    properties: {
211
      enabled: { type: 'boolean' },
212
      status: { type: 'string', enum: ['online', 'idle', 'dnd', 'invisible'] },
213
      activityType: {
214
        type: 'string',
215
        enum: ['Playing', 'Watching', 'Listening', 'Competing', 'Streaming', 'Custom'],
216
      },
217
      activities: { type: 'array', items: { type: 'string' } },
218
      rotateIntervalMs: { type: 'number' },
219
      rotation: {
220
        type: 'object',
221
        properties: {
222
          enabled: { type: 'boolean' },
223
          intervalMinutes: { type: 'number' },
224
          messages: {
225
            type: 'array',
226
            items: {
227
              type: 'object',
228
              properties: {
229
                type: {
230
                  type: 'string',
231
                  enum: ['Playing', 'Watching', 'Listening', 'Competing', 'Streaming', 'Custom'],
232
                },
233
                text: { type: 'string', minLength: 1, pattern: '\\S' },
234
              },
235
              required: ['text'],
236
            },
237
          },
238
        },
239
      },
240
    },
241
  },
242
  reminders: {
243
    type: 'object',
244
    properties: {
245
      enabled: { type: 'boolean' },
246
      maxPerUser: { type: 'number', min: 1, max: 100 },
247
    },
248
  },
249
  quietMode: {
250
    type: 'object',
251
    properties: {
252
      enabled: { type: 'boolean' },
253
      maxDurationMinutes: { type: 'number', min: 1, max: 10080 },
254
      allowedRoles: { type: 'array' },
255
    },
256
  },
257
  voice: {
258
    type: 'object',
259
    properties: {
260
      enabled: { type: 'boolean' },
261
      xpPerMinute: { type: 'number', min: 0, max: 1000 },
262
      dailyXpCap: { type: 'number', min: 0, max: 1000000 },
263
      logChannel: { type: 'string', nullable: true },
264
    },
265
  },
266
  permissions: {
267
    type: 'object',
268
    properties: {
269
      enabled: { type: 'boolean' },
270
      usePermissions: { type: 'boolean' },
271
      adminRoleIds: { type: 'array', items: { type: 'string' } },
272
      moderatorRoleIds: { type: 'array', items: { type: 'string' } },
273
      // Legacy singular fields — kept for backward compat during migration
274
      adminRoleId: { type: 'string', nullable: true },
275
      moderatorRoleId: { type: 'string', nullable: true },
276
      modRoles: { type: 'array', items: { type: 'string' } },
277
      botOwners: { type: 'array', items: { type: 'string' } },
278
      // allowedCommands is a freeform map of command → permission level — no fixed property list
279
      allowedCommands: { type: 'object', openProperties: true },
280
    },
281
  },
282
  tldr: {
283
    type: 'object',
284
    properties: {
285
      enabled: { type: 'boolean' },
286
      systemPrompt: { type: 'string', maxLength: 4000 },
287
      defaultMessages: { type: 'number', min: 1, max: 200 },
288
      maxMessages: { type: 'number', min: 1, max: 200 },
289
      cooldownSeconds: { type: 'number', min: 0, max: 3600 },
290
    },
291
  },
292
  xp: {
293
    type: 'object',
294
    properties: {
295
      enabled: { type: 'boolean' },
296
      levelThresholds: {
297
        type: 'array',
298
        items: { type: 'number', min: 0 },
299
      },
300
      levelActions: {
301
        type: 'array',
302
        items: {
303
          type: 'object',
304
          required: ['level', 'actions'],
305
          properties: {
306
            level: { type: 'number', min: 1, max: 1000 },
307
            actions: {
308
              type: 'array',
309
              items: {
310
                type: 'object',
311
                required: ['type'],
312
                properties: {
313
                  type: { type: 'string' },
314
                  roleId: { type: 'string', nullable: true },
315
                },
316
                openProperties: true,
317
              },
318
            },
319
          },
320
        },
321
      },
322
      defaultActions: {
323
        type: 'array',
324
        items: {
325
          type: 'object',
326
          required: ['type'],
327
          properties: {
328
            type: { type: 'string' },
329
            roleId: { type: 'string', nullable: true },
330
          },
331
          openProperties: true,
332
        },
333
      },
334
      roleRewards: {
335
        type: 'object',
336
        properties: {
337
          stackRoles: { type: 'boolean' },
338
          removeOnLevelDown: { type: 'boolean' },
339
        },
340
      },
341
    },
342
  },
343
};
344

345
/**
346
 * Validate a value against a schema fragment and collect any validation errors.
347
 *
348
 * @param {*} value - The value to validate.
349
 * @param {Object} schema - Schema fragment describing the expected shape; may include `type` (boolean|string|number|array|object), `nullable`, and `properties` for object children.
350
 * @param {string} path - Dot-notation path used to prefix validation error messages.
351
 * @returns {string[]} Array of validation error messages; empty if the value is valid for the provided schema.
352
 */
353
export function validateValue(value, schema, path) {
354
  const errors = [];
268✔
355

356
  if (value === null) {
268✔
357
    if (!schema.nullable) {
10✔
358
      errors.push(`${path}: must not be null`);
4✔
359
    }
360
    return errors;
10✔
361
  }
362

363
  if (value === undefined) {
258!
UNCOV
364
    return errors;
×
365
  }
366

367
  switch (schema.type) {
258✔
368
    case 'boolean':
369
      if (typeof value !== 'boolean') {
51✔
370
        errors.push(`${path}: expected boolean, got ${typeof value}`);
12✔
371
      }
372
      break;
51✔
373
    case 'string':
374
      if (typeof value !== 'string') {
62✔
375
        errors.push(`${path}: expected string, got ${typeof value}`);
1✔
376
      } else {
377
        if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
61✔
378
          errors.push(`${path}: must be at least ${schema.minLength} characters`);
2✔
379
        }
380
        if (schema.enum && !schema.enum.includes(value)) {
61✔
381
          errors.push(`${path}: must be one of [${schema.enum.join(', ')}], got "${value}"`);
4✔
382
        }
383
        if (schema.maxLength != null && value.length > schema.maxLength) {
61✔
384
          errors.push(`${path}: exceeds max length of ${schema.maxLength}`);
2✔
385
        }
386
        if (schema.pattern && !getCompiledPattern(schema.pattern).test(value)) {
61✔
387
          errors.push(`${path}: does not match required pattern`);
1✔
388
        }
389
      }
390
      break;
62✔
391
    case 'number':
392
      if (typeof value !== 'number' || !Number.isFinite(value)) {
44✔
393
        errors.push(`${path}: expected finite number, got ${typeof value}`);
6✔
394
      } else {
395
        if (schema.min != null && value < schema.min) {
38✔
396
          errors.push(`${path}: must be >= ${schema.min}`);
7✔
397
        }
398
        if (schema.max != null && value > schema.max) {
38✔
399
          errors.push(`${path}: must be <= ${schema.max}`);
5✔
400
        }
401
      }
402
      break;
44✔
403
    case 'array':
404
      if (!Array.isArray(value)) {
34✔
405
        errors.push(`${path}: expected array, got ${typeof value}`);
3✔
406
      } else if (schema.items) {
31✔
407
        for (let i = 0; i < value.length; i++) {
29✔
408
          errors.push(...validateValue(value[i], schema.items, `${path}[${i}]`));
33✔
409
        }
410
      }
411
      break;
34✔
412
    case 'object':
413
      if (typeof value !== 'object' || Array.isArray(value)) {
67✔
414
        errors.push(
1✔
415
          `${path}: expected object, got ${Array.isArray(value) ? 'array' : typeof value}`,
1!
416
        );
417
      } else {
418
        if (schema.required) {
66✔
419
          for (const key of schema.required) {
26✔
420
            if (!Object.hasOwn(value, key)) {
35✔
421
              errors.push(`${path}: missing required key "${key}"`);
6✔
422
            }
423
          }
424
        }
425

426
        if (schema.properties) {
66✔
427
          for (const [key, val] of Object.entries(value)) {
62✔
428
            if (Object.hasOwn(schema.properties, key)) {
101✔
429
              errors.push(...validateValue(val, schema.properties[key], `${path}.${key}`));
94✔
430
            } else if (!schema.openProperties) {
7✔
431
              errors.push(`${path}.${key}: unknown config key`);
6✔
432
            }
433
            // openProperties: true — freeform map, unknown keys are allowed
434
          }
435
        }
436
      }
437
      break;
67✔
438
  }
439

440
  return errors;
258✔
441
}
442

443
/**
444
 * Validate a single configuration path and its value against the writable config schema.
445
 *
446
 * @param {string} path - Dot-notation config path (e.g. "ai.enabled").
447
 * @param {*} value - The value to validate for the given path.
448
 * @returns {string[]} Array of validation error messages (empty if valid).
449
 */
450
export function validateSingleValue(path, value) {
451
  const segments = path.split('.');
105✔
452
  const section = segments[0];
105✔
453

454
  const schema = CONFIG_SCHEMA[section];
105✔
455
  if (!schema) return []; // unknown section — let SAFE_CONFIG_KEYS guard handle it
105✔
456

457
  // Walk the schema tree to find the leaf schema for this path
458
  let currentSchema = schema;
103✔
459
  for (let i = 1; i < segments.length; i++) {
103✔
460
    if (currentSchema.properties && Object.hasOwn(currentSchema.properties, segments[i])) {
116✔
461
      currentSchema = currentSchema.properties[segments[i]];
109✔
462
    } else if (currentSchema.openProperties) {
7✔
463
      // Dynamic keys (e.g. channelModes.<channelId>) — validate as leaf value
464
      break;
1✔
465
    } else {
466
      return [`Unknown config path: ${path}`];
6✔
467
    }
468
  }
469

470
  return validateValue(value, currentSchema, path);
97✔
471
}
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