@helyx/bot 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +100 -28
  2. package/README.md +65 -11
  3. package/dist/channel-audit-configuration.d.ts +7 -0
  4. package/dist/channel-audit-configuration.js +40 -0
  5. package/dist/channel-audit-control.d.ts +14 -0
  6. package/dist/channel-audit-control.js +61 -0
  7. package/dist/channel-audit-permissions.d.ts +56 -0
  8. package/dist/channel-audit-permissions.js +417 -0
  9. package/dist/channel-audit-routes.d.ts +4 -0
  10. package/dist/channel-audit-routes.js +42 -0
  11. package/dist/cloud-connector.d.ts +11 -4
  12. package/dist/cloud-connector.js +54 -15
  13. package/dist/cloud-control-dispatcher.d.ts +8 -3
  14. package/dist/cloud-control-dispatcher.js +84 -4
  15. package/dist/control-errors.d.ts +12 -0
  16. package/dist/control-errors.js +32 -0
  17. package/dist/control-server-schemas.d.ts +84 -0
  18. package/dist/control-server-schemas.js +101 -0
  19. package/dist/control-server.d.ts +25 -8
  20. package/dist/control-server.js +101 -152
  21. package/dist/core-retention.d.ts +26 -0
  22. package/dist/core-retention.js +49 -0
  23. package/dist/creator-guild-join-service.d.ts +25 -0
  24. package/dist/creator-guild-join-service.js +218 -0
  25. package/dist/creator-invite-service.d.ts +37 -0
  26. package/dist/creator-invite-service.js +686 -0
  27. package/dist/creator-onboarding-control.d.ts +12 -0
  28. package/dist/creator-onboarding-control.js +65 -0
  29. package/dist/creator-onboarding-platform.d.ts +20 -0
  30. package/dist/creator-onboarding-platform.js +58 -0
  31. package/dist/dashboard-action-control.d.ts +2 -2
  32. package/dist/dashboard-action-control.js +2 -3
  33. package/dist/database-bootstrap.d.ts +36 -0
  34. package/dist/database-bootstrap.js +101 -0
  35. package/dist/developer-operations-module.d.ts +1 -0
  36. package/dist/developer-operations-module.js +34 -16
  37. package/dist/discord-channel-audit.d.ts +9 -0
  38. package/dist/discord-channel-audit.js +195 -0
  39. package/dist/discord-guild-health.d.ts +2 -2
  40. package/dist/discord-owned-message-service.d.ts +1 -0
  41. package/dist/discord-owned-message-service.js +3 -0
  42. package/dist/discord-role-safety.d.ts +4 -0
  43. package/dist/discord-role-safety.js +30 -0
  44. package/dist/events.d.ts +2 -0
  45. package/dist/events.js +5 -20
  46. package/dist/guild-command-read-service.d.ts +8 -3
  47. package/dist/guild-command-read-service.js +14 -7
  48. package/dist/guild-command-release.d.ts +15 -8
  49. package/dist/guild-command-release.js +10 -3
  50. package/dist/guild-command-worker.d.ts +17 -4
  51. package/dist/guild-command-worker.js +17 -6
  52. package/dist/guilds.d.ts +24 -3
  53. package/dist/guilds.js +185 -64
  54. package/dist/hosted-server-moderation.js +10 -4
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.js +200 -82
  57. package/dist/interactions.js +16 -3
  58. package/dist/module-configuration.d.ts +1 -1
  59. package/dist/module-configuration.js +5 -2
  60. package/dist/module-logging.d.ts +8 -3
  61. package/dist/module-logging.js +13 -7
  62. package/dist/modules.d.ts +1 -0
  63. package/dist/modules.js +8 -1
  64. package/dist/mysql-runtime-persistence.d.ts +46 -0
  65. package/dist/mysql-runtime-persistence.js +91 -0
  66. package/dist/oauth-module.js +2 -2
  67. package/dist/oauth-service.d.ts +7 -0
  68. package/dist/oauth-service.js +3 -0
  69. package/dist/scheduled-task-runner.d.ts +9 -85
  70. package/dist/scheduled-task-runner.js +1 -1
  71. package/dist/shard-contracts.d.ts +222 -0
  72. package/dist/shard-contracts.js +52 -1
  73. package/dist/shard-router.d.ts +4 -1
  74. package/dist/shard-router.js +19 -0
  75. package/dist/shard-supervisor.js +43 -9
  76. package/dist/shard-worker.d.ts +5 -2
  77. package/dist/shard-worker.js +41 -5
  78. package/dist/supervisor-cloud-directory.d.ts +9 -3
  79. package/dist/supervisor-cloud-directory.js +8 -12
  80. package/dist/trivia-data-provider.d.ts +11 -0
  81. package/dist/trivia-data-provider.js +46 -26
  82. package/dist/verification-service.d.ts +2 -2
  83. package/dist/verification-service.js +6 -3
  84. package/documentation/README.md +5 -3
  85. package/documentation/examples/.env.example +15 -11
  86. package/documentation/guides/getting-started.md +29 -12
  87. package/documentation/guides/privacy-and-support.md +6 -5
  88. package/documentation/guides/self-hosting.md +16 -11
  89. package/documentation/modules/auto-moderation.md +7 -5
  90. package/documentation/modules/building-a-module.md +48 -2
  91. package/documentation/modules/moderation.md +5 -5
  92. package/documentation/modules/publishing-a-module.md +6 -4
  93. package/documentation/modules/testing-a-module.md +39 -4
  94. package/documentation/reference/environment.md +43 -9
  95. package/documentation/runbooks/backup-and-recovery.md +26 -7
  96. package/documentation/runbooks/installation-and-upgrades.md +97 -27
  97. package/package.json +20 -16
@@ -0,0 +1,417 @@
1
+ import { CHANNEL_AUDIT_LIMITS } from "@helyx/control-protocol/channel-audit";
2
+ export const AUDIT_PERMISSIONS = {
3
+ Administrator: 1n << 3n,
4
+ ManageGuild: 1n << 5n,
5
+ ViewChannel: 1n << 10n,
6
+ SendMessages: 1n << 11n,
7
+ ReadMessageHistory: 1n << 16n,
8
+ Connect: 1n << 20n,
9
+ Speak: 1n << 21n,
10
+ ManageRoles: 1n << 28n,
11
+ ManageChannels: 1n << 4n,
12
+ KickMembers: 1n << 1n,
13
+ BanMembers: 1n << 2n,
14
+ RequestToSpeak: 1n << 32n,
15
+ SendMessagesInThreads: 1n << 38n,
16
+ };
17
+ /** Discord's role union, everyone overwrite, then aggregate role deny/allow. */
18
+ export function effectiveAuditPermissions(guildId, roles, selectedRoleIds, channel) {
19
+ if (channel.obfuscated)
20
+ return null;
21
+ const selected = new Set([guildId, ...selectedRoleIds]);
22
+ const roleMap = new Map(roles.map((role) => [role.id, role]));
23
+ let permissions = 0n;
24
+ for (const id of selected) {
25
+ const role = roleMap.get(id);
26
+ if (!role)
27
+ return null;
28
+ permissions |= role.permissions;
29
+ }
30
+ if (has(permissions, AUDIT_PERMISSIONS.Administrator))
31
+ return (1n << 64n) - 1n;
32
+ const everyone = channel.overwrites.find((overwrite) => overwrite.type === 0 && overwrite.id === guildId);
33
+ if (everyone)
34
+ permissions = (permissions & ~everyone.deny) | everyone.allow;
35
+ let allow = 0n, deny = 0n;
36
+ for (const overwrite of channel.overwrites) {
37
+ if (overwrite.type !== 0 ||
38
+ overwrite.id === guildId ||
39
+ !selected.has(overwrite.id))
40
+ continue;
41
+ allow |= overwrite.allow;
42
+ deny |= overwrite.deny;
43
+ }
44
+ return (permissions & ~deny) | allow;
45
+ }
46
+ function has(permissions, permission) {
47
+ return permissions !== null && (permissions & permission) === permission;
48
+ }
49
+ const channelTypes = new Map([
50
+ [0, "Text"],
51
+ [5, "Announcement"],
52
+ [15, "Forum"],
53
+ [16, "Media"],
54
+ [2, "Voice"],
55
+ [13, "Stage"],
56
+ [4, "Category"],
57
+ [10, "Announcement thread"],
58
+ [11, "Public thread"],
59
+ [12, "Private thread"],
60
+ ]);
61
+ const activityTypes = new Set([0, 5, 15, 16, 2, 13]);
62
+ /** Raw bit presence is insufficient: view/connect prerequisites gate actions. */
63
+ export function auditActions(permissions, type) {
64
+ if (!activityTypes.has(type) ||
65
+ !has(permissions, AUDIT_PERMISSIONS.ViewChannel))
66
+ return [];
67
+ const actions = ["View"];
68
+ if ([0, 5].includes(type)) {
69
+ if (has(permissions, AUDIT_PERMISSIONS.ReadMessageHistory))
70
+ actions.push("Read existing panel");
71
+ if (has(permissions, AUDIT_PERMISSIONS.SendMessages))
72
+ actions.push("Post messages");
73
+ }
74
+ else if ([15, 16].includes(type)) {
75
+ if (has(permissions, AUDIT_PERMISSIONS.SendMessages))
76
+ actions.push("Create posts");
77
+ if (has(permissions, AUDIT_PERMISSIONS.SendMessagesInThreads))
78
+ actions.push("Reply in accessible threads");
79
+ }
80
+ else if (has(permissions, AUDIT_PERMISSIONS.Connect)) {
81
+ actions.push("Connect");
82
+ if (type === 2 && has(permissions, AUDIT_PERMISSIONS.Speak))
83
+ actions.push("Speak");
84
+ if (type === 13 && has(permissions, AUDIT_PERMISSIONS.RequestToSpeak))
85
+ actions.push("Request to speak");
86
+ }
87
+ return actions;
88
+ }
89
+ const panelUsable = (permissions, type) => [0, 5].includes(type) &&
90
+ has(permissions, AUDIT_PERMISSIONS.ViewChannel | AUDIT_PERMISSIONS.ReadMessageHistory);
91
+ export function buildChannelAuditReport(snapshot, moduleId, assumptions, observedAt) {
92
+ const findings = [];
93
+ const channels = [];
94
+ const add = (finding) => findings.push(finding);
95
+ const roles = new Map(snapshot.roles.map((role) => [role.id, role]));
96
+ const requiredRoles = new Set([
97
+ ...assumptions.beforeRoleIds,
98
+ assumptions.afterRoleId,
99
+ ]);
100
+ for (const roleId of requiredRoles) {
101
+ if (!roles.has(roleId))
102
+ add(snapshot.rolesComplete
103
+ ? {
104
+ code: "role_not_in_guild",
105
+ category: "blocked",
106
+ title: "Selected role is not in this server",
107
+ detail: "The fresh server role list does not contain this role. Select a current role from this server.",
108
+ roleId,
109
+ }
110
+ : {
111
+ code: "role_unknown",
112
+ category: "unknown",
113
+ title: "Could not inspect a selected role",
114
+ detail: "The current server role list could not be fetched. This is not evidence that the role was deleted.",
115
+ roleId,
116
+ });
117
+ }
118
+ const afterRole = roles.get(assumptions.afterRoleId);
119
+ if (afterRole?.assignmentPolicyReason)
120
+ add({
121
+ code: "after_role_policy_blocked",
122
+ category: "blocked",
123
+ title: "Helyx's safety policy blocks the after-check role",
124
+ detail: afterRole.assignmentPolicyReason,
125
+ roleId: afterRole.id,
126
+ });
127
+ for (const roleId of assumptions.beforeRoleIds) {
128
+ const beforeRole = roles.get(roleId);
129
+ if (beforeRole?.assignmentPolicyReason)
130
+ add({
131
+ code: "before_role_removal_policy_risk",
132
+ category: "review",
133
+ title: "Safety policy may prevent removing a before-check role",
134
+ detail: `${beforeRole.assignmentPolicyReason} If the entry workflow removes this role, that removal is also blocked.`,
135
+ roleId,
136
+ });
137
+ if (beforeRole?.assignable === false)
138
+ add({
139
+ code: "before_role_removal_risk",
140
+ category: "review",
141
+ title: "Review removal of a selected before-check role",
142
+ detail: `${beforeRole.assignmentReason ?? "Helyx cannot currently manage this role."} If the entry workflow is configured to remove it, that transition may fail.`,
143
+ roleId,
144
+ });
145
+ else if (beforeRole?.assignable === null)
146
+ add({
147
+ code: "before_role_removal_unknown",
148
+ category: "unknown",
149
+ title: "Before-role removal capability is unknown",
150
+ detail: "Helyx's current role-management capability could not be established. No removal is attempted by this audit.",
151
+ roleId,
152
+ });
153
+ }
154
+ if (afterRole &&
155
+ (afterRole.assignable === false ||
156
+ afterRole.managed ||
157
+ afterRole.id === snapshot.guildId))
158
+ add({
159
+ code: "after_role_unassignable",
160
+ category: "blocked",
161
+ title: "Helyx cannot assign the after-check role",
162
+ detail: afterRole.assignmentReason ??
163
+ "The role is managed, everyone, above Helyx, or Helyx lacks Manage Roles. This audit changes none of them.",
164
+ roleId: afterRole.id,
165
+ });
166
+ else if (afterRole?.assignable === null)
167
+ add({
168
+ code: "after_role_unknown",
169
+ category: "unknown",
170
+ title: "Role assignment capability could not be checked",
171
+ detail: "Helyx's current member and role-management permissions are unavailable.",
172
+ roleId: afterRole.id,
173
+ });
174
+ const dangerous = Object.entries(AUDIT_PERMISSIONS).filter(([name]) => [
175
+ "Administrator",
176
+ "ManageGuild",
177
+ "ManageRoles",
178
+ "ManageChannels",
179
+ "KickMembers",
180
+ "BanMembers",
181
+ ].includes(name));
182
+ for (const roleId of new Set([snapshot.guildId, ...requiredRoles])) {
183
+ const role = roles.get(roleId);
184
+ const names = role
185
+ ? dangerous
186
+ .filter(([, bit]) => has(role.permissions, bit))
187
+ .map(([name]) => name)
188
+ : [];
189
+ if (names.length)
190
+ add({
191
+ code: "high_impact_role",
192
+ category: "review",
193
+ title: "Review elevated role permissions",
194
+ detail: `Role grants ${names.join(", ")}. These permissions can undermine a restricted entrance.`,
195
+ roleId,
196
+ });
197
+ }
198
+ const selectedIds = new Set([
199
+ assumptions.entryChannelId,
200
+ ...assumptions.allowedChannelIds,
201
+ ...(assumptions.reviewChannelId ? [assumptions.reviewChannelId] : []),
202
+ ]);
203
+ const ordered = [...snapshot.channels].sort((left, right) => Number(selectedIds.has(right.id)) - Number(selectedIds.has(left.id)) ||
204
+ left.id.localeCompare(right.id));
205
+ const inspected = ordered.slice(0, CHANNEL_AUDIT_LIMITS.channels);
206
+ let unknownChannels = 0, unsupportedChannels = 0, broadAccess = 0;
207
+ const rolePaths = assumptions.beforeRoleIds.map((roleId) => ({
208
+ roleId,
209
+ entranceUsable: null,
210
+ visibleChannels: 0,
211
+ }));
212
+ for (const channel of inspected) {
213
+ if (channel.obfuscated) {
214
+ unknownChannels += 1;
215
+ continue;
216
+ }
217
+ if (!activityTypes.has(channel.type)) {
218
+ unsupportedChannels += 1;
219
+ continue;
220
+ }
221
+ const before = effectiveAuditPermissions(snapshot.guildId, snapshot.roles, assumptions.beforeRoleIds, channel);
222
+ const everyone = effectiveAuditPermissions(snapshot.guildId, snapshot.roles, [], channel);
223
+ const after = effectiveAuditPermissions(snapshot.guildId, snapshot.roles, [assumptions.afterRoleId], channel);
224
+ const retained = effectiveAuditPermissions(snapshot.guildId, snapshot.roles, [...assumptions.beforeRoleIds, assumptions.afterRoleId], channel);
225
+ const row = {
226
+ id: channel.id,
227
+ name: channel.name.slice(0, 100),
228
+ type: channelTypes.get(channel.type) ?? "Unsupported",
229
+ beforeActions: auditActions(before, channel.type),
230
+ afterActions: auditActions(after, channel.type),
231
+ retainedActions: auditActions(retained, channel.type),
232
+ limitations: [],
233
+ };
234
+ if (before === null || after === null)
235
+ row.limitations.push("Selected role assumptions could not all be evaluated.");
236
+ if (channel.overwrites.some((overwrite) => overwrite.type === 1))
237
+ row.limitations.push("Member-specific overwrites can change an individual's access.");
238
+ if ([15, 16].includes(channel.type))
239
+ row.limitations.push("Thread membership, archive/lock and thread-specific state are not inspected.");
240
+ if (channels.length < CHANNEL_AUDIT_LIMITS.reportChannels)
241
+ channels.push(row);
242
+ for (const path of rolePaths) {
243
+ const permissions = effectiveAuditPermissions(snapshot.guildId, snapshot.roles, [path.roleId], channel);
244
+ if (has(permissions, AUDIT_PERMISSIONS.ViewChannel))
245
+ path.visibleChannels += 1;
246
+ if (channel.id === assumptions.entryChannelId)
247
+ path.entranceUsable =
248
+ permissions === null ? null : panelUsable(permissions, channel.type);
249
+ }
250
+ if (channel.id === assumptions.entryChannelId) {
251
+ if (before === null || everyone === null)
252
+ add({
253
+ code: "entrance_unknown",
254
+ category: "unknown",
255
+ title: "Could not evaluate the entrance",
256
+ detail: "One or more required role assumptions are unavailable.",
257
+ channelId: channel.id,
258
+ });
259
+ else if (!panelUsable(before, channel.type))
260
+ add({
261
+ code: "entrance_unusable",
262
+ category: "blocked",
263
+ title: "Assumed entrants cannot reliably reach the panel",
264
+ detail: "A supported text or announcement entrance needs View Channel and Read Message History. Send Messages is not required to press a panel button.",
265
+ channelId: channel.id,
266
+ });
267
+ else if (assumptions.entrance === "public" &&
268
+ !panelUsable(everyone, channel.type))
269
+ add({
270
+ code: "public_entrance_unusable",
271
+ category: "blocked",
272
+ title: "Everyone cannot use the intended public entrance",
273
+ detail: "The public path needs View Channel and Read Message History without relying on selected extra roles.",
274
+ channelId: channel.id,
275
+ });
276
+ else {
277
+ add({
278
+ code: "entrance_working",
279
+ category: "working",
280
+ title: "Entrance is reachable under the selected assumptions",
281
+ detail: "Role-only permissions allow reading the entrance. This does not prove a panel exists, is active or will accept a particular member.",
282
+ channelId: channel.id,
283
+ });
284
+ if (assumptions.entrance === "restricted" &&
285
+ has(everyone, AUDIT_PERMISSIONS.ViewChannel))
286
+ add({
287
+ code: "entrance_public",
288
+ category: "review",
289
+ title: "The restricted entrance is visible to everyone",
290
+ detail: "Review whether the selected public/restricted intent matches your server design. No lockdown or permission change is applied.",
291
+ channelId: channel.id,
292
+ });
293
+ }
294
+ const publishing = AUDIT_PERMISSIONS.ViewChannel | AUDIT_PERMISSIONS.SendMessages;
295
+ if (channel.botPermissions === null)
296
+ add({
297
+ code: "bot_publish_unknown",
298
+ category: "unknown",
299
+ title: "Helyx publishing access could not be checked",
300
+ detail: "Current bot-specific permissions are unavailable.",
301
+ channelId: channel.id,
302
+ });
303
+ else if (!has(channel.botPermissions, publishing))
304
+ add({
305
+ code: "bot_cannot_publish",
306
+ category: "blocked",
307
+ title: "Helyx cannot publish an entrance panel here",
308
+ detail: "Helyx needs View Channel and Send Messages in the selected entrance. Existing publication safeguards still apply.",
309
+ channelId: channel.id,
310
+ });
311
+ }
312
+ else if (has(before, AUDIT_PERMISSIONS.ViewChannel) &&
313
+ !assumptions.allowedChannelIds.includes(channel.id))
314
+ broadAccess += 1;
315
+ if (channel.id === assumptions.reviewChannelId &&
316
+ has(before, AUDIT_PERMISSIONS.ViewChannel))
317
+ add({
318
+ code: "review_exposure",
319
+ category: "review",
320
+ title: "Assumed entrants can view the selected staff-review channel",
321
+ detail: "Review this explicitly selected staff-review destination separately from entrance visibility.",
322
+ channelId: channel.id,
323
+ });
324
+ }
325
+ for (const channelId of selectedIds) {
326
+ const invalid = snapshot.invalidSelectedChannels?.find((item) => item.id === channelId);
327
+ if (invalid) {
328
+ add({
329
+ code: "selected_channel_invalid",
330
+ category: "blocked",
331
+ title: "Selected channel is unavailable in this server",
332
+ detail: invalid.reason === "wrong_guild"
333
+ ? "The fresh channel lookup identified a different server. Choose a channel from this server."
334
+ : "Discord returned Unknown Channel for this selected ID. It may be deleted or invalid; this does not establish when or why it became unavailable.",
335
+ channelId,
336
+ });
337
+ continue;
338
+ }
339
+ const channel = inspected.find((item) => item.id === channelId);
340
+ if (!channel || channel.obfuscated)
341
+ add({
342
+ code: "selected_channel_unknown",
343
+ category: "unknown",
344
+ title: "A selected channel could not be inspected",
345
+ detail: "Discord may omit or obfuscate channels Helyx cannot view. Absence from this inspection is not proof of deletion or of entrant denial.",
346
+ channelId,
347
+ });
348
+ else if (!activityTypes.has(channel.type))
349
+ add({
350
+ code: "selected_channel_unsupported",
351
+ category: "unknown",
352
+ title: "Selected channel type is outside this audit",
353
+ detail: "Categories, individual threads and other unsupported types are not counted as a usable entrance or as a pass.",
354
+ channelId,
355
+ });
356
+ }
357
+ if (broadAccess)
358
+ add({
359
+ code: "wider_access",
360
+ category: "review",
361
+ title: "Review wider before-check access",
362
+ detail: `The combined before-role assumption can view ${broadAccess} inspected channels beyond the entrance and allowed exceptions. Wider access is advisory.`,
363
+ });
364
+ if (!assumptions.reviewChannelId)
365
+ add({
366
+ code: "review_context_unknown",
367
+ category: "unknown",
368
+ title: "Staff-review exposure has no selected context",
369
+ detail: "Select a staff-review channel or explicitly Load config to assess that destination. Hidden channels are not assumed to be staff-only.",
370
+ });
371
+ const limitations = [
372
+ "Role assumptions exclude member overwrites, screening, timeouts and actual member assignment; no member directory or messages are read.",
373
+ moduleId === "helyx.account-gate"
374
+ ? "Access Control grants the access role, then attempts removal of its configured temporary roles. Selected before roles are assumptions, and delivery can be partial."
375
+ : "Verification grants the verified role and removes its configured removal role when present. It does not assign the before roles, and other member roles can remain.",
376
+ "After-check view assumes selected before roles were removed; the retained view shows their combination with the after role. Actual transitions can differ or partially complete.",
377
+ "The retained view includes all selected before roles; it does not enumerate every partially retained role combination.",
378
+ ...snapshot.fetchLimitations,
379
+ ];
380
+ if (!snapshot.totalChannelsKnown)
381
+ limitations.push("Coverage is limited to Discord's bot-visible channel list and known inventory; other inaccessible channels may be omitted and their total is unknown.");
382
+ const channelsTruncated = snapshot.channels.length > inspected.length ||
383
+ inspected.filter((channel) => !channel.obfuscated && activityTypes.has(channel.type)).length > channels.length;
384
+ if (unknownChannels || unsupportedChannels || channelsTruncated)
385
+ add({
386
+ code: "coverage_incomplete",
387
+ category: "unknown",
388
+ title: "Some channel coverage is incomplete",
389
+ detail: `${unknownChannels} observed channels are unknown/obfuscated; ${unsupportedChannels} types are unsupported. ${channelsTruncated ? "Inspection or displayed channel rows were capped." : "Unsupported channels are not passed."}`,
390
+ });
391
+ // Keep failures and uncertainty before successful findings if the output cap is reached.
392
+ const priority = { blocked: 0, unknown: 1, review: 2, working: 3 };
393
+ findings.sort((left, right) => priority[left.category] - priority[right.category]);
394
+ return {
395
+ guildId: snapshot.guildId,
396
+ moduleId,
397
+ observedAt,
398
+ assumptions: structuredClone(assumptions),
399
+ simulation: false,
400
+ findings: findings.slice(0, CHANNEL_AUDIT_LIMITS.findings),
401
+ channels,
402
+ rolePaths,
403
+ coverage: {
404
+ fetchedChannels: snapshot.fetchedChannels,
405
+ totalChannelsKnown: snapshot.totalChannelsKnown,
406
+ inspectedChannels: inspected.length,
407
+ returnedChannels: channels.length,
408
+ unknownChannels,
409
+ unsupportedChannels,
410
+ channelsTruncated,
411
+ findingsTruncated: findings.length > CHANNEL_AUDIT_LIMITS.findings,
412
+ roleOnly: true,
413
+ limitations: limitations.slice(0, 12),
414
+ },
415
+ };
416
+ }
417
+ //# sourceMappingURL=channel-audit-permissions.js.map
@@ -0,0 +1,4 @@
1
+ import type { ChannelAuditControl } from "@helyx/sdk";
2
+ import type { FastifyInstance } from "fastify";
3
+ export declare function registerChannelAuditRoutes(server: FastifyInstance, control: ChannelAuditControl): void;
4
+ //# sourceMappingURL=channel-audit-routes.d.ts.map
@@ -0,0 +1,42 @@
1
+ import { channelAuditModuleIdSchema, channelAuditInputSchema, channelAuditConfigurationSchema, channelAuditReportSchema, } from "@helyx/control-protocol/channel-audit";
2
+ import { z } from "zod";
3
+ import { BotControlTransportError } from "./control-errors.js";
4
+ const parameters = z
5
+ .object({
6
+ guildId: z.string().regex(/^\d{17,20}$/u),
7
+ moduleId: channelAuditModuleIdSchema,
8
+ })
9
+ .strict();
10
+ const actor = z
11
+ .object({
12
+ actorUserId: z.string().regex(/^\d{17,20}$/u),
13
+ correlationId: z.uuid(),
14
+ })
15
+ .strict();
16
+ export function registerChannelAuditRoutes(server, control) {
17
+ server.post("/v1/guilds/:guildId/modules/:moduleId/channel-audit/config", async (request) => {
18
+ const input = {
19
+ ...parameters.parse(request.params),
20
+ ...actor.parse(request.body),
21
+ };
22
+ if (!control.readChannelAuditConfiguration)
23
+ throw unsupported();
24
+ return channelAuditConfigurationSchema.parse(await control.readChannelAuditConfiguration(input));
25
+ });
26
+ server.post("/v1/guilds/:guildId/modules/:moduleId/channel-audit/check", async (request) => {
27
+ const input = {
28
+ ...parameters.parse(request.params),
29
+ ...actor
30
+ .extend({ assumptions: channelAuditInputSchema })
31
+ .strict()
32
+ .parse(request.body),
33
+ };
34
+ if (!control.checkChannelAudit)
35
+ throw unsupported();
36
+ return channelAuditReportSchema.parse(await control.checkChannelAudit(input));
37
+ });
38
+ }
39
+ function unsupported() {
40
+ return new BotControlTransportError(503, "This bot version does not support Channel audit. Update the bot before trying again.");
41
+ }
42
+ //# sourceMappingURL=channel-audit-routes.js.map
@@ -4,6 +4,8 @@ import { type CloudConnectorConsentRecord, type DatabasePool } from "@helyx/data
4
4
  import type { Client } from "discord.js";
5
5
  import type { Logger } from "pino";
6
6
  import type { ConfiguredModulePackage } from "./modules.js";
7
+ import type { GuildCommandReadHandler } from "./guild-command-read-service.js";
8
+ import { type CloudConnectorStore } from "./cloud-control-dispatcher.js";
7
9
  import { type BotControlHandler } from "./control-server.js";
8
10
  type ConnectorState = "disabled" | "unpaired" | "connecting" | "online" | "offline" | "recovery_required" | "revoked" | "stopped";
9
11
  export interface CloudConnectorStatus {
@@ -27,7 +29,7 @@ export interface CloudDiscordDirectory {
27
29
  snapshot(): Promise<CloudDiscordSnapshot>;
28
30
  }
29
31
  export interface CloudConnector {
30
- setControlHandler(handler: BotControlHandler): void;
32
+ setControlHandler(handler: BotControlHandler, commands?: GuildCommandReadHandler): void;
31
33
  start(): Promise<void>;
32
34
  status(): CloudConnectorStatus;
33
35
  getSupportConsent(): Promise<CloudConnectorConsentRecord>;
@@ -54,13 +56,18 @@ export declare class CloudConnectorController implements CloudConnector {
54
56
  private readonly input;
55
57
  constructor(input: {
56
58
  environment: BotEnvironment;
57
- database: DatabasePool;
58
59
  discord: CloudDiscordDirectory;
59
60
  modules: readonly ConfiguredModulePackage[];
60
61
  coreVersion: string;
61
62
  logger: Logger;
62
- });
63
- setControlHandler(handler: BotControlHandler): void;
63
+ } & ({
64
+ database: DatabasePool;
65
+ repository?: never;
66
+ } | {
67
+ repository: CloudConnectorStore;
68
+ database?: never;
69
+ }));
70
+ setControlHandler(handler: BotControlHandler, commands?: GuildCommandReadHandler): void;
64
71
  start(): Promise<void>;
65
72
  status(): CloudConnectorStatus;
66
73
  getSupportConsent(): Promise<CloudConnectorConsentRecord>;
@@ -87,7 +87,8 @@ export class CloudConnectorController {
87
87
  #pendingSupport = new Map();
88
88
  constructor(input) {
89
89
  this.input = input;
90
- this.#repository = new CloudConnectorRepository(input.database);
90
+ this.#repository =
91
+ input.repository ?? new CloudConnectorRepository(input.database);
91
92
  this.#state = input.environment.HELYX_CLOUD_CONNECT_ENABLED
92
93
  ? "unpaired"
93
94
  : "disabled";
@@ -95,8 +96,10 @@ export class CloudConnectorController {
95
96
  ? Buffer.from(input.environment.HELYX_DATA_ENCRYPTION_KEY, "base64")
96
97
  : undefined;
97
98
  }
98
- setControlHandler(handler) {
99
- this.#dispatcher = new CloudControlDispatcher(this.input.database, handler);
99
+ setControlHandler(handler, commands) {
100
+ this.#dispatcher = this.input.database
101
+ ? new CloudControlDispatcher(this.input.database, handler, commands)
102
+ : new CloudControlDispatcher({ repository: this.#repository }, handler, commands);
100
103
  }
101
104
  async start() {
102
105
  if (!this.input.environment.HELYX_CLOUD_CONNECT_ENABLED)
@@ -414,6 +417,8 @@ export class CloudConnectorController {
414
417
  let outgoingSequence = 0;
415
418
  let heartbeat;
416
419
  let plannedReconnect;
420
+ let activeControlRequests = 0;
421
+ let draining = false;
417
422
  const privateKey = decryptPrivateKey(identity.privateKey, this.#dataKey, identity.installationId);
418
423
  const finished = new Promise((resolveFinished, rejectFinished) => {
419
424
  const fail = (error) => {
@@ -475,10 +480,32 @@ export class CloudConnectorController {
475
480
  installationId: identity.installationId,
476
481
  connectionEpoch: accepted.data.connectionEpoch,
477
482
  sequence: ++outgoingSequence,
478
- payload: { state: "online" },
483
+ payload: { state: draining ? "draining" : "online" },
479
484
  })));
480
485
  }, accepted.data.heartbeatIntervalSeconds * 1_000);
481
- plannedReconnect = setTimeout(() => socket.close(1012, "Planned Cloud Connect session rotation"), accepted.data.reconnectBeforeSeconds * 1_000);
486
+ plannedReconnect = setTimeout(() => {
487
+ if (socket.readyState !== WebSocket.OPEN)
488
+ return;
489
+ draining = true;
490
+ socket.send(JSON.stringify(connectedMessage({
491
+ type: "connector.draining",
492
+ installationId: identity.installationId,
493
+ connectionEpoch: accepted.data.connectionEpoch,
494
+ sequence: ++outgoingSequence,
495
+ payload: { reason: "session_rotation" },
496
+ })));
497
+ const deadline = Date.now() + 45_000;
498
+ const closeWhenIdle = () => {
499
+ if (socket.readyState !== WebSocket.OPEN)
500
+ return;
501
+ if (activeControlRequests === 0 || Date.now() >= deadline) {
502
+ socket.close(1012, "Planned Cloud Connect session rotation");
503
+ return;
504
+ }
505
+ plannedReconnect = setTimeout(closeWhenIdle, 250);
506
+ };
507
+ plannedReconnect = setTimeout(closeWhenIdle, 1_000);
508
+ }, accepted.data.reconnectBeforeSeconds * 1_000);
482
509
  return;
483
510
  }
484
511
  const rejected = connectorRejectedSchema.safeParse(payload);
@@ -518,16 +545,22 @@ export class CloudConnectorController {
518
545
  }
519
546
  if (message.type !== "control.request")
520
547
  return;
521
- const response = await this.#dispatch(message.payload, message.correlationId);
522
- if (socket.readyState === WebSocket.OPEN)
523
- socket.send(JSON.stringify(connectedMessage({
524
- type: "control.response",
525
- installationId: identity.installationId,
526
- connectionEpoch: message.connectionEpoch,
527
- sequence: ++outgoingSequence,
528
- correlationId: message.correlationId,
529
- payload: response,
530
- })));
548
+ activeControlRequests += 1;
549
+ try {
550
+ const response = await this.#dispatch(message.payload, message.correlationId);
551
+ if (socket.readyState === WebSocket.OPEN)
552
+ socket.send(JSON.stringify(connectedMessage({
553
+ type: "control.response",
554
+ installationId: identity.installationId,
555
+ connectionEpoch: message.connectionEpoch,
556
+ sequence: ++outgoingSequence,
557
+ correlationId: message.correlationId,
558
+ payload: response,
559
+ })));
560
+ }
561
+ finally {
562
+ activeControlRequests -= 1;
563
+ }
531
564
  }
532
565
  catch (error) {
533
566
  this.input.logger.warn({
@@ -722,10 +755,16 @@ function connectorCapabilities() {
722
755
  "guild.dashboard.access.read.v1",
723
756
  "guild.access-policy.read.v1",
724
757
  "guild.access-policy.write.v1",
758
+ "guild.commands.status.read.v1",
759
+ "guild.commands.history.read.v1",
760
+ "guild.commands.status.refresh.v1",
761
+ "guild.commands.rebuild.v1",
725
762
  "guild.module.public.read.v1",
726
763
  "guild.module.status.write.v1",
727
764
  "guild.module.configuration.write.v1",
728
765
  "guild.module.configuration.preview.v1",
766
+ "guild.module.channel-audit.config.read.v1",
767
+ "guild.module.channel-audit.check.v1",
729
768
  "guild.module.permissions.write.v1",
730
769
  "guild.module.logging.write.v1",
731
770
  "guild.module.action.execute.v1",
@@ -1,11 +1,16 @@
1
1
  import { type CloudControlRequestPayload } from "@helyx/control-protocol";
2
- import { type DatabasePool } from "@helyx/database";
2
+ import { CloudConnectorRepository, type DatabasePool } from "@helyx/database";
3
3
  import type { BotControlHandler, BotControlState } from "./control-server.js";
4
+ import type { GuildCommandReadHandler } from "./guild-command-read-service.js";
5
+ export type CloudConnectorStore = Pick<CloudConnectorRepository, "getIdentity" | "createIdentity" | "rotateIdentity" | "markConnected" | "revoke" | "getConsent" | "updateConsent" | "appendAudit" | "cleanupAudit">;
4
6
  export declare class CloudControlDispatcher {
5
7
  #private;
6
- private readonly database;
7
8
  private readonly control;
8
- constructor(database: DatabasePool, control: BotControlHandler);
9
+ private readonly commands?;
10
+ constructor(database: DatabasePool, control: BotControlHandler, commands?: GuildCommandReadHandler);
11
+ constructor(input: {
12
+ repository: CloudConnectorStore;
13
+ }, control: BotControlHandler, commands?: GuildCommandReadHandler);
9
14
  execute(request: CloudControlRequestPayload, correlationId: string): Promise<unknown>;
10
15
  }
11
16
  export declare class CloudControlDeniedError extends Error {