@helyx/bot 1.0.1 → 1.2.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 (88) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +27 -2
  3. package/dist/auto-moderation-scanner-registry.d.ts +27 -0
  4. package/dist/auto-moderation-scanner-registry.js +172 -0
  5. package/dist/cloud-connector.js +3 -0
  6. package/dist/cloud-control-dispatcher.js +32 -0
  7. package/dist/command-path-contract.js +1 -0
  8. package/dist/control-permissions.d.ts +2 -2
  9. package/dist/control-permissions.js +4 -2
  10. package/dist/control-server.d.ts +72 -16
  11. package/dist/control-server.js +150 -250
  12. package/dist/dashboard-action-control.d.ts +25 -0
  13. package/dist/dashboard-action-control.js +73 -0
  14. package/dist/dashboard-action-routes.d.ts +9 -0
  15. package/dist/dashboard-action-routes.js +36 -0
  16. package/dist/dashboard-feature-control.d.ts +37 -0
  17. package/dist/dashboard-feature-control.js +206 -0
  18. package/dist/dashboard-module-projection.d.ts +9 -0
  19. package/dist/dashboard-module-projection.js +15 -0
  20. package/dist/dashboard-preview-import-routes.d.ts +6 -0
  21. package/dist/dashboard-preview-import-routes.js +121 -0
  22. package/dist/developer-operations-module.d.ts +2 -0
  23. package/dist/developer-operations-module.js +136 -2
  24. package/dist/discord-analytics.d.ts +3 -0
  25. package/dist/discord-analytics.js +49 -5
  26. package/dist/discord-auto-moderation-service.d.ts +21 -0
  27. package/dist/discord-auto-moderation-service.js +425 -0
  28. package/dist/discord-guild-health.d.ts +2 -2
  29. package/dist/discord-member-enforcement-service.d.ts +26 -0
  30. package/dist/discord-member-enforcement-service.js +590 -0
  31. package/dist/discord-message-enforcement-service.d.ts +9 -0
  32. package/dist/discord-message-enforcement-service.js +102 -0
  33. package/dist/discord-violation-thread-service.d.ts +33 -0
  34. package/dist/discord-violation-thread-service.js +789 -0
  35. package/dist/ecko-evidence-receiver.d.ts +32 -0
  36. package/dist/ecko-evidence-receiver.js +173 -0
  37. package/dist/ecko-transport-auth.d.ts +19 -0
  38. package/dist/ecko-transport-auth.js +80 -0
  39. package/dist/ecko-transport-routes.d.ts +19 -0
  40. package/dist/ecko-transport-routes.js +130 -0
  41. package/dist/embedded-auto-moderation-pool.d.ts +46 -0
  42. package/dist/embedded-auto-moderation-pool.js +324 -0
  43. package/dist/embedded-auto-moderation-scanner.d.ts +34 -0
  44. package/dist/embedded-auto-moderation-scanner.js +263 -0
  45. package/dist/embedded-auto-moderation-worker-runtime.d.ts +37 -0
  46. package/dist/embedded-auto-moderation-worker-runtime.js +37 -0
  47. package/dist/embedded-auto-moderation-worker.d.ts +2 -0
  48. package/dist/embedded-auto-moderation-worker.js +13 -0
  49. package/dist/events.d.ts +5 -1
  50. package/dist/events.js +275 -9
  51. package/dist/guilds.d.ts +2 -1
  52. package/dist/guilds.js +164 -5
  53. package/dist/hosted-server-moderation.d.ts +100 -0
  54. package/dist/hosted-server-moderation.js +226 -0
  55. package/dist/index.d.ts +1 -1
  56. package/dist/index.js +101 -12
  57. package/dist/moderation-action-registry.d.ts +11 -0
  58. package/dist/moderation-action-registry.js +375 -0
  59. package/dist/moderation-case-disassociation.d.ts +13 -0
  60. package/dist/moderation-case-disassociation.js +83 -0
  61. package/dist/moderation-platform-services.d.ts +27 -0
  62. package/dist/moderation-platform-services.js +47 -0
  63. package/dist/modules.d.ts +6 -0
  64. package/dist/modules.js +65 -3
  65. package/dist/premium-activation-module.js +2 -0
  66. package/dist/privileged-message-metadata-cache.d.ts +35 -0
  67. package/dist/privileged-message-metadata-cache.js +70 -0
  68. package/dist/scheduled-task-runner.js +24 -1
  69. package/dist/shard-contracts.d.ts +151 -1
  70. package/dist/shard-contracts.js +142 -1
  71. package/dist/shard-coordinator.d.ts +1 -1
  72. package/dist/shard-ecko-scanner-registry.d.ts +38 -0
  73. package/dist/shard-ecko-scanner-registry.js +96 -0
  74. package/dist/shard-router.d.ts +23 -0
  75. package/dist/shard-router.js +69 -5
  76. package/dist/shard-supervisor.d.ts +11 -0
  77. package/dist/shard-supervisor.js +72 -6
  78. package/dist/shard-worker.d.ts +16 -1
  79. package/dist/shard-worker.js +108 -10
  80. package/dist/transient-violation-evidence.d.ts +39 -0
  81. package/dist/transient-violation-evidence.js +132 -0
  82. package/documentation/README.md +2 -0
  83. package/documentation/examples/.env.example +13 -0
  84. package/documentation/guides/self-hosting.md +17 -1
  85. package/documentation/modules/auto-moderation.md +51 -0
  86. package/documentation/modules/moderation.md +42 -0
  87. package/documentation/reference/environment.md +11 -0
  88. package/package.json +17 -5
@@ -4,7 +4,7 @@ import { SHARD_CONTROL_TIMEOUT_MS, assertShardMessageSize, shardOperationsHealth
4
4
  import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
5
5
  const DEVELOPER_MODULE_ID = "helyx.developer-operations";
6
6
  const DEVELOPER_MODULE_NAME = "Helyx Developer Operations";
7
- const DEVELOPER_MODULE_VERSION = "0.2.0";
7
+ const DEVELOPER_MODULE_VERSION = "0.3.0";
8
8
  const MAX_MODULE_LINES = 8;
9
9
  export function createDeveloperOperationsModule(input) {
10
10
  const approvedRoles = new Set(input.approvedRoleIds);
@@ -15,13 +15,24 @@ export function createDeveloperOperationsModule(input) {
15
15
  id: DEVELOPER_MODULE_ID,
16
16
  name: DEVELOPER_MODULE_NAME,
17
17
  version: DEVELOPER_MODULE_VERSION,
18
- description: "Private read-only development operations for Helyx support.",
18
+ description: "Private development and hosted-server operations for Helyx staff.",
19
19
  entry: "./dist/developer-operations-module.js",
20
20
  framework: "^0.1.0",
21
21
  dependencies: [],
22
22
  permissions: [],
23
23
  contributions: { commands: ["dev"], events: [] },
24
24
  },
25
+ defaultPermissions: {
26
+ module: restrictedPolicy(),
27
+ commands: Object.fromEntries([
28
+ "dev.shards",
29
+ "dev.bot",
30
+ "dev.proc",
31
+ "dev.trivia-data",
32
+ "dev.leave",
33
+ "dev.blacklist",
34
+ ].map((id) => [id, restrictedPolicy()])),
35
+ },
25
36
  interactions: {
26
37
  commands: [
27
38
  {
@@ -33,6 +44,7 @@ export function createDeveloperOperationsModule(input) {
33
44
  subcommands: [
34
45
  {
35
46
  name: "shards",
47
+ permissionId: "dev.shards",
36
48
  description: "Show bounded read-only shard health",
37
49
  acknowledgement: "deferred-ephemeral",
38
50
  authorization: "self-authorized",
@@ -83,6 +95,7 @@ export function createDeveloperOperationsModule(input) {
83
95
  },
84
96
  {
85
97
  name: "bot",
98
+ permissionId: "dev.bot",
86
99
  description: "Show the bot build and installed module versions",
87
100
  acknowledgement: "immediate-response",
88
101
  authorization: "self-authorized",
@@ -137,6 +150,7 @@ export function createDeveloperOperationsModule(input) {
137
150
  },
138
151
  {
139
152
  name: "proc",
153
+ permissionId: "dev.proc",
140
154
  description: "Show safe process resource and uptime information",
141
155
  acknowledgement: "immediate-response",
142
156
  authorization: "self-authorized",
@@ -171,6 +185,7 @@ export function createDeveloperOperationsModule(input) {
171
185
  },
172
186
  {
173
187
  name: "trivia-data",
188
+ permissionId: "dev.trivia-data",
174
189
  description: "Show question counts from the Trivia database",
175
190
  acknowledgement: "deferred-ephemeral",
176
191
  authorization: "self-authorized",
@@ -220,6 +235,100 @@ export function createDeveloperOperationsModule(input) {
220
235
  });
221
236
  },
222
237
  },
238
+ ...(input.moderation
239
+ ? ["leave", "blacklist"].map((action) => ({
240
+ name: action,
241
+ permissionId: `dev.${action}`,
242
+ description: action === "leave"
243
+ ? "Make hosted Helyx leave a server"
244
+ : "Blacklist a server and make hosted Helyx leave",
245
+ acknowledgement: "deferred-ephemeral",
246
+ authorization: "self-authorized",
247
+ rateLimit: {
248
+ scope: "user",
249
+ limit: 3,
250
+ windowSeconds: 60,
251
+ },
252
+ options: [
253
+ {
254
+ type: "string",
255
+ name: "server_id",
256
+ description: "Discord server ID",
257
+ required: true,
258
+ minLength: 17,
259
+ maxLength: 20,
260
+ },
261
+ {
262
+ type: "string",
263
+ name: "reason",
264
+ description: "Private reason for this action",
265
+ required: true,
266
+ minLength: 3,
267
+ maxLength: 500,
268
+ },
269
+ ],
270
+ async execute(context) {
271
+ if (!(await authorizeDeveloperCommand(context)))
272
+ return;
273
+ const guildId = context
274
+ .getStringOption("server_id")
275
+ ?.trim();
276
+ const reason = context.getStringOption("reason")?.trim();
277
+ if (!guildId ||
278
+ !/^\d{17,20}$/u.test(guildId) ||
279
+ !reason ||
280
+ reason.length < 3 ||
281
+ reason.length > 500 ||
282
+ /[\r\n\0]/u.test(reason)) {
283
+ await context.reply({
284
+ ephemeral: true,
285
+ componentsV2: {
286
+ accentColor: 0xdc2626,
287
+ text: [
288
+ {
289
+ content: "The server ID or private reason is invalid.",
290
+ },
291
+ ],
292
+ },
293
+ });
294
+ return;
295
+ }
296
+ try {
297
+ const result = await input.moderation.moderate({
298
+ action,
299
+ guildId,
300
+ actorUserId: context.userId,
301
+ reason,
302
+ idempotencyKey: `discord:${context.interactionId}`,
303
+ });
304
+ await context.reply({
305
+ ephemeral: true,
306
+ componentsV2: {
307
+ accentColor: 0x2563eb,
308
+ text: [
309
+ {
310
+ content: `## Hosted server ${action}\n${moderationOutcome(result.outcome)}\nOperation: \`${result.operationId}\`${result.paidSlotsReleased ? `\nPremium slots released: ${result.paidSlotsReleased}` : ""}`,
311
+ },
312
+ ],
313
+ },
314
+ });
315
+ }
316
+ catch {
317
+ await context.reply({
318
+ ephemeral: true,
319
+ componentsV2: {
320
+ accentColor: 0xdc2626,
321
+ text: [
322
+ {
323
+ content: "The hosted moderation service is unavailable. The request was not reported as complete; retry with the same command when service is restored.",
324
+ },
325
+ ],
326
+ },
327
+ });
328
+ }
329
+ },
330
+ }))
331
+ : []),
223
332
  ],
224
333
  },
225
334
  ],
@@ -247,6 +356,31 @@ export function createDeveloperOperationsModule(input) {
247
356
  return false;
248
357
  }
249
358
  }
359
+ function restrictedPolicy() {
360
+ return {
361
+ everyone: false,
362
+ requiredPermissions: ["ADMINISTRATOR"],
363
+ allowedRoleIds: [],
364
+ deniedRoleIds: [],
365
+ allowedUserIds: [],
366
+ deniedUserIds: [],
367
+ };
368
+ }
369
+ function moderationOutcome(outcome) {
370
+ if (outcome === "queued")
371
+ return "Accepted. Helyx will leave through the durable delivery queue.";
372
+ if (outcome === "already_blacklisted")
373
+ return "No new blacklist was created; this server is already blacklisted.";
374
+ if (outcome === "not_currently_installed")
375
+ return "Recorded. Helyx is not currently installed in that server.";
376
+ if (outcome === "accepted_for_retry")
377
+ return "Accepted for a safe retry.";
378
+ if (outcome === "requires_review")
379
+ return "Recorded and flagged for staff review.";
380
+ if (outcome === "completed")
381
+ return "Completed.";
382
+ return "The request was recorded but did not complete.";
383
+ }
250
384
  function safeInlineCode(value) {
251
385
  return `\`${value.replaceAll("`", "'").slice(0, 120)}\``;
252
386
  }
@@ -1,6 +1,7 @@
1
1
  import type { DiscordAnalyticsInspectionService } from "@helyx/sdk";
2
2
  import { type Client } from "discord.js";
3
3
  export declare class DiscordAnalyticsInspection implements DiscordAnalyticsInspectionService {
4
+ #private;
4
5
  private readonly client;
5
6
  constructor(client: Client);
6
7
  listChannels(input: {
@@ -25,6 +26,8 @@ export declare class DiscordAnalyticsInspection implements DiscordAnalyticsInspe
25
26
  limit: number;
26
27
  includeArchived: boolean;
27
28
  includePrivate: boolean;
29
+ signal?: AbortSignal;
28
30
  }): ReturnType<DiscordAnalyticsInspectionService["listThreads"]>;
31
+ private activeThreads;
29
32
  }
30
33
  //# sourceMappingURL=discord-analytics.d.ts.map
@@ -2,8 +2,10 @@ import { ChannelType, GatewayIntentBits, PermissionFlagsBits, } from "discord.js
2
2
  const MAX_MESSAGE_PAGE = 100;
3
3
  const MAX_MEMBER_PAGE = 1_000;
4
4
  const MAX_THREAD_PAGE = 100;
5
+ const ACTIVE_THREAD_CACHE_MS = 15_000;
5
6
  export class DiscordAnalyticsInspection {
6
7
  client;
8
+ #activeThreads = new WeakMap();
7
9
  constructor(client) {
8
10
  this.client = client;
9
11
  }
@@ -147,12 +149,12 @@ export class DiscordAnalyticsInspection {
147
149
  if (input.cursor && !cursor)
148
150
  return unavailable("not_supported");
149
151
  try {
150
- const parents = await threadParents(guild, input.parentChannelId);
152
+ const parents = await abortable(threadParents(guild, input.parentChannelId), input.signal);
151
153
  const privateCoverage = privateThreadCoverage(guild);
152
154
  const current = cursor ?? { phase: "active", offset: 0 };
153
155
  if (current.phase === "active") {
154
- const active = await guild.channels.fetchActiveThreads(false);
155
- const facts = [...active.threads.values()]
156
+ const active = await abortable(this.activeThreads(guild), input.signal);
157
+ const facts = active
156
158
  .filter((thread) => !input.parentChannelId ||
157
159
  thread.parentId === input.parentChannelId)
158
160
  .sort((left, right) => left.id.localeCompare(right.id))
@@ -183,12 +185,12 @@ export class DiscordAnalyticsInspection {
183
185
  const phase = phases[phaseIndex];
184
186
  while (parentIndex < parents.length) {
185
187
  const parent = parents[parentIndex];
186
- const page = await parent.threads.fetchArchived({
188
+ const page = await abortable(parent.threads.fetchArchived({
187
189
  type: phase,
188
190
  fetchAll: phase === "private" && privateCoverage === "all",
189
191
  limit,
190
192
  ...(before ? { before } : {}),
191
- }, false);
193
+ }, false), input.signal);
192
194
  const threads = [...page.threads.values()].sort((left, right) => left.id.localeCompare(right.id));
193
195
  const items = threads.map((thread) => threadFact(guild, thread));
194
196
  if (page.hasMore && threads.length > 0) {
@@ -228,6 +230,23 @@ export class DiscordAnalyticsInspection {
228
230
  return unavailable("discord_unavailable");
229
231
  }
230
232
  }
233
+ activeThreads(guild) {
234
+ const cached = this.#activeThreads.get(guild);
235
+ if (cached && cached.expiresAt > Date.now())
236
+ return cached.promise;
237
+ const promise = guild.channels
238
+ .fetchActiveThreads(false)
239
+ .then((response) => [...response.threads.values()]);
240
+ this.#activeThreads.set(guild, {
241
+ expiresAt: Date.now() + ACTIVE_THREAD_CACHE_MS,
242
+ promise,
243
+ });
244
+ void promise.catch(() => {
245
+ if (this.#activeThreads.get(guild)?.promise === promise)
246
+ this.#activeThreads.delete(guild);
247
+ });
248
+ return promise;
249
+ }
231
250
  }
232
251
  function analyticsChannelType(channel) {
233
252
  switch (channel.type) {
@@ -246,6 +265,10 @@ function analyticsChannelType(channel) {
246
265
  }
247
266
  }
248
267
  async function threadParents(guild, parentChannelId) {
268
+ if (parentChannelId) {
269
+ const channel = await guild.channels.fetch(parentChannelId);
270
+ return channel && "threads" in channel ? [channel] : [];
271
+ }
249
272
  const channels = await guild.channels.fetch();
250
273
  return [...channels.values()]
251
274
  .filter((channel) => channel !== null &&
@@ -334,4 +357,25 @@ function snowflakeTimestamp(id) {
334
357
  function unavailable(code) {
335
358
  return { available: false, code };
336
359
  }
360
+ function abortable(promise, signal) {
361
+ if (!signal)
362
+ return promise;
363
+ return new Promise((resolve, reject) => {
364
+ const abort = () => reject(signal.reason instanceof Error
365
+ ? signal.reason
366
+ : new Error("Discord Analytics inspection was aborted"));
367
+ if (signal.aborted) {
368
+ abort();
369
+ return;
370
+ }
371
+ signal.addEventListener("abort", abort, { once: true });
372
+ void promise.then((value) => {
373
+ signal.removeEventListener("abort", abort);
374
+ resolve(value);
375
+ }, (error) => {
376
+ signal.removeEventListener("abort", abort);
377
+ reject(error instanceof Error ? error : new Error("Discord read failed"));
378
+ });
379
+ });
380
+ }
337
381
  //# sourceMappingURL=discord-analytics.js.map
@@ -0,0 +1,21 @@
1
+ import type { DiscordAutoModerationAction, DiscordAutoModerationMutationResult, DiscordAutoModerationRuleSnapshot, DiscordAutoModerationService } from "@helyx/sdk";
2
+ import { type AutoModerationAction, type AutoModerationRule, type Client } from "discord.js";
3
+ /** Discord REST adapter scoped to guilds owned by this client/shard. */
4
+ export declare class DiscordAutoModerationAdapter implements DiscordAutoModerationService {
5
+ #private;
6
+ private readonly client;
7
+ constructor(client: Client);
8
+ list(input: {
9
+ guildId: string;
10
+ }): Promise<readonly DiscordAutoModerationRuleSnapshot[]>;
11
+ read(input: {
12
+ guildId: string;
13
+ ruleId: string;
14
+ }): Promise<DiscordAutoModerationRuleSnapshot | null>;
15
+ create(input: Parameters<DiscordAutoModerationService["create"]>[0]): Promise<DiscordAutoModerationMutationResult>;
16
+ update(input: Parameters<DiscordAutoModerationService["update"]>[0]): Promise<DiscordAutoModerationMutationResult>;
17
+ delete(input: Parameters<DiscordAutoModerationService["delete"]>[0]): Promise<DiscordAutoModerationMutationResult>;
18
+ }
19
+ export declare function toRuleSnapshot(rule: AutoModerationRule): DiscordAutoModerationRuleSnapshot;
20
+ export declare function fromDiscordAction(action: AutoModerationAction): DiscordAutoModerationAction;
21
+ //# sourceMappingURL=discord-auto-moderation-service.d.ts.map