@helyx/bot 1.2.1 → 1.3.1

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.
@@ -1,6 +1,7 @@
1
1
  import { isCustomerSelectableModule, } from "@helyx/module-manifest";
2
2
  import { defineModule, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
3
3
  import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
4
+ import { validateDiscordUserSettingSelections } from "./dashboard-discord-settings.js";
4
5
  const MODULE_SELECT_ID = "helyx.management.module";
5
6
  const SETTING_SELECT_ID = "helyx.management.setting";
6
7
  const VALUE_SELECT_ID = "helyx.management.value";
@@ -129,7 +130,9 @@ export function createManagementModule(configurableModules, resources) {
129
130
  const setting = selectedSetting(module, context.selectedValues[0]);
130
131
  const state = { ...session.state, selectedSettingKey: setting.key };
131
132
  await updateSession(context, session, state);
132
- if (setting.type === "string" || setting.type === "integer") {
133
+ if (setting.type === "string" ||
134
+ setting.type === "integer" ||
135
+ setting.type === "discord-user") {
133
136
  const now = Date.now();
134
137
  for (const [key, prepared] of preparedValueModals) {
135
138
  if (prepared.expiresAt <= now)
@@ -222,6 +225,7 @@ export function createManagementModule(configurableModules, resources) {
222
225
  return;
223
226
  }
224
227
  try {
228
+ await validateDiscordUserSettingSelections(context.services, context.serverId, module.manifest.dashboard?.settings ?? [], value);
225
229
  const validated = module.configuration?.validate(value);
226
230
  if (validated && module.configuration?.validateUpdate) {
227
231
  await module.configuration.validateUpdate({
@@ -353,6 +357,15 @@ export function createManagementModule(configurableModules, resources) {
353
357
  return;
354
358
  const module = selectedModule(session.state, byId);
355
359
  const setting = selectedSetting(module, stringState(session.state, "selectedSettingKey"));
360
+ if (setting.type === "discord-user") {
361
+ const userIds = [...context.getSelectedUserIds(VALUE_FIELD_ID)];
362
+ const updated = await updateSession(context, session, {
363
+ ...session.state,
364
+ draft: { ...draft(session.state), [setting.key]: userIds },
365
+ });
366
+ await respond(context, configurationResponse(module, updated.state));
367
+ return;
368
+ }
356
369
  if (setting.type !== "string" && setting.type !== "integer") {
357
370
  throw new Error("Selected setting does not use text input");
358
371
  }
@@ -593,7 +606,7 @@ function textSettingResponse(setting) {
593
606
  text: [{ content: `## ${setting.label}\n${setting.description}` }],
594
607
  },
595
608
  buttons: [
596
- button(OPEN_VALUE_MODAL_ID, "Enter value", "primary"),
609
+ button(OPEN_VALUE_MODAL_ID, setting.type === "discord-user" ? "Choose users" : "Enter value", "primary"),
597
610
  button(CONFIGURE_ID, "Back to settings", "secondary"),
598
611
  button(CANCEL_ID, "Cancel", "secondary"),
599
612
  ],
@@ -680,6 +693,25 @@ function preparedModalKey(serverId, userId) {
680
693
  }
681
694
  function settingModal(setting, values) {
682
695
  const current = values[setting.key];
696
+ if (setting.type === "discord-user")
697
+ return {
698
+ customId: VALUE_MODAL_ID,
699
+ title: truncate(setting.label, 45),
700
+ text: [{ content: setting.description, markdown: false }],
701
+ fields: [
702
+ {
703
+ type: "discord-user",
704
+ customId: VALUE_FIELD_ID,
705
+ label: setting.label,
706
+ required: setting.required,
707
+ minValues: Math.max(setting.required ? 1 : 0, setting.minItems ?? 0),
708
+ maxValues: setting.maxItems ?? 25,
709
+ defaultValues: Array.isArray(current)
710
+ ? current.filter((id) => typeof id === "string")
711
+ : [],
712
+ },
713
+ ],
714
+ };
683
715
  return {
684
716
  customId: VALUE_MODAL_ID,
685
717
  title: truncate(setting.label, 45),
@@ -724,6 +756,8 @@ function initialDraft(module, existing) {
724
756
  return result;
725
757
  }
726
758
  function emptyValue(setting) {
759
+ if (setting.type === "discord-user")
760
+ return [];
727
761
  if (setting.type === "discord-channel" && setting.multiple)
728
762
  return [];
729
763
  if (setting.type === "discord-role" && setting.multiple)
@@ -105,6 +105,7 @@ export class BotModerationActionRegistry {
105
105
  const evidenceClaims = {
106
106
  caseIds: new Set(),
107
107
  threadIds: new Set(),
108
+ openingMessageIds: new Set(),
108
109
  };
109
110
  const requestServices = createRequestBoundServices(this.services, evidenceClaims);
110
111
  return await this.#actionProvider.execute({
@@ -117,15 +118,30 @@ export class BotModerationActionRegistry {
117
118
  !OPERATION_KEY_PATTERN.test(delivery.caseId) ||
118
119
  !OPERATION_KEY_PATTERN.test(delivery.operationKey) ||
119
120
  !evidenceClaims.caseIds.has(delivery.caseId) ||
120
- !evidenceClaims.threadIds.has(delivery.threadId))
121
+ !evidenceClaims.threadIds.has(delivery.threadId) ||
122
+ (delivery.openingMessageId !== undefined &&
123
+ (!ID_PATTERN.test(delivery.openingMessageId) ||
124
+ !evidenceClaims.openingMessageIds.has(`${delivery.threadId}:${delivery.openingMessageId}`))))
121
125
  return { outcome: "evidence_unavailable" };
122
126
  if (!this.evidenceDelivery)
123
127
  return { outcome: "evidence_unavailable" };
124
128
  return await this.evidenceDelivery.deliverEvidence({
125
129
  guildId: delivery.guildId,
126
130
  threadId: delivery.threadId,
131
+ ...(delivery.openingMessageId
132
+ ? { openingMessageId: delivery.openingMessageId }
133
+ : {}),
127
134
  evidenceReference: input.request.evidenceReference,
128
135
  operationKey: delivery.operationKey,
136
+ context: {
137
+ severity: input.request.severity,
138
+ occurredAt: input.request.occurredAt,
139
+ ...(policy.ruleName ? { ruleName: policy.ruleName } : {}),
140
+ confidenceClass: input.request.confidenceClass,
141
+ ...(input.request.confidenceScore !== undefined
142
+ ? { confidenceScore: input.request.confidenceScore }
143
+ : {}),
144
+ },
129
145
  });
130
146
  },
131
147
  },
@@ -168,6 +184,11 @@ function isValidRequest(request) {
168
184
  SAFE_CODE_PATTERN.test(request.reasonCode) &&
169
185
  ["low", "medium", "high", "critical"].includes(request.severity) &&
170
186
  ["exact", "native_equivalent", "high", "medium", "low"].includes(request.confidenceClass) &&
187
+ (request.confidenceScore === undefined ||
188
+ (request.detectionSource === "enhanced" &&
189
+ Number.isInteger(request.confidenceScore) &&
190
+ request.confidenceScore >= 0 &&
191
+ request.confidenceScore <= 100)) &&
171
192
  (request.sourceMessage === null ||
172
193
  (ID_PATTERN.test(request.sourceMessage.channelId) &&
173
194
  (request.sourceMessage.messageId === null ||
@@ -234,9 +255,20 @@ function createRequestBoundServices(services, claims) {
234
255
  },
235
256
  addMember: (input) => service.addMember(input),
236
257
  removeMember: (input) => service.removeMember(input),
237
- send: (input) => service.send(input),
258
+ send: async (input) => {
259
+ const result = await service.send(input);
260
+ if (claims.threadIds.has(input.threadId) &&
261
+ (result.outcome === "sent" || result.outcome === "already_sent"))
262
+ claims.openingMessageIds.add(`${input.threadId}:${result.messageId}`);
263
+ return result;
264
+ },
238
265
  close: (input) => service.close(input),
239
266
  reconcile: (input) => service.reconcile(input),
267
+ ...(service.editControls
268
+ ? {
269
+ editControls: (input) => service.editControls(input),
270
+ }
271
+ : {}),
240
272
  };
241
273
  return bound;
242
274
  }
@@ -254,6 +286,12 @@ function isValidPolicy(policy) {
254
286
  policy.windowStartsAt instanceof Date &&
255
287
  !Number.isNaN(policy.windowStartsAt.getTime()) &&
256
288
  typeof policy.openViolationThread === "boolean" &&
289
+ (policy.deleteSourceMessage === undefined ||
290
+ typeof policy.deleteSourceMessage === "boolean") &&
291
+ (policy.ruleName === undefined ||
292
+ (typeof policy.ruleName === "string" &&
293
+ policy.ruleName.trim().length > 0 &&
294
+ policy.ruleName.length <= 100)) &&
257
295
  validRoleIds(policy.globalIgnoredRoleIds, MODERATION_CONTRACT_LIMITS.ignoredRolesPerScope) &&
258
296
  validRoleIds(policy.ruleIgnoredRoleIds, MODERATION_CONTRACT_LIMITS.ignoredRolesPerScope) &&
259
297
  Boolean(policy.actionPolicy) &&
@@ -325,6 +363,7 @@ function samePolicy(expected, current) {
325
363
  expected.configurationRevision === current.configurationRevision &&
326
364
  expected.windowStartsAt.getTime() === current.windowStartsAt.getTime() &&
327
365
  expected.openViolationThread === current.openViolationThread &&
366
+ expected.deleteSourceMessage === current.deleteSourceMessage &&
328
367
  sameStrings(expected.globalIgnoredRoleIds, current.globalIgnoredRoleIds) &&
329
368
  sameStrings(expected.ruleIgnoredRoleIds, current.ruleIgnoredRoleIds) &&
330
369
  sameAction(expected.actionPolicy.first, current.actionPolicy.first) &&
@@ -1,4 +1,4 @@
1
- import type { ModuleDefinition, PrivacyProvider, PrivacyProviderExecutor, ServiceAccess } from "@helyx/sdk";
1
+ import { type ModuleDefinition, type PrivacyProvider, type PrivacyProviderExecutor, type ServiceAccess } from "@helyx/sdk";
2
2
  export declare function createPrivacyProviderRegistry(modules: readonly ModuleDefinition[], executor: PrivacyProviderExecutor | undefined, services: ServiceAccess): ReadonlyMap<string, PrivacyProvider>;
3
3
  export declare function describePrivacyProviders(providers: ReadonlyMap<string, PrivacyProvider>): readonly {
4
4
  id: string;
@@ -1,12 +1,41 @@
1
+ import { createPrivacyProvider, } from "@helyx/sdk";
1
2
  export function createPrivacyProviderRegistry(modules, executor, services) {
2
3
  if (!executor)
3
4
  return new Map();
4
- return new Map(modules.flatMap((module) => {
5
- if (!module.privacy)
6
- return [];
7
- const provider = module.privacy.create(executor, services);
8
- return [[provider.id, provider]];
9
- }));
5
+ const configurationDeclaration = {
6
+ containsPersonalData: true,
7
+ providerVersion: 1,
8
+ collections: [
9
+ {
10
+ name: "moderation_notification_memberships",
11
+ createdAtField: "updated_at",
12
+ subjectFields: [
13
+ {
14
+ field: "notification_user_id",
15
+ role: "staff-notification-recipient",
16
+ erasure: "set-null",
17
+ },
18
+ ],
19
+ },
20
+ ],
21
+ };
22
+ const configurationProvider = createPrivacyProvider({
23
+ id: "helyx.configuration",
24
+ declaration: configurationDeclaration,
25
+ executor,
26
+ });
27
+ if (modules.some(({ manifest }) => manifest.id === configurationProvider.id)) {
28
+ throw new Error("Core configuration privacy identity cannot be a module identity");
29
+ }
30
+ return new Map([
31
+ [configurationProvider.id, configurationProvider],
32
+ ...modules.flatMap((module) => {
33
+ if (!module.privacy)
34
+ return [];
35
+ const provider = module.privacy.create(executor, services);
36
+ return [[provider.id, provider]];
37
+ }),
38
+ ]);
10
39
  }
11
40
  export function describePrivacyProviders(providers) {
12
41
  return [...providers.values()]
@@ -8,7 +8,7 @@ import { SHARD_CONTROL_TIMEOUT_MS, controlOperationGuildId, guildIdToShardId, sh
8
8
  // deadline turned healthy leases into deterministic 503s. Keep the check
9
9
  // bounded while allowing normal network variance.
10
10
  const LEASE_VALIDATION_TIMEOUT_MS = 3_000;
11
- const ECKO_RESULT_TIMEOUT_MS = AUTOMOD_SCANNER_LIMITS.actionDeadlineSeconds * 1_000;
11
+ const ECKO_RESULT_TIMEOUT_MS = AUTOMOD_SCANNER_LIMITS.resultAcknowledgementSeconds * 1_000;
12
12
  export class ShardControlRouter {
13
13
  input;
14
14
  constructor(input) {
@@ -30,7 +30,7 @@ export class ShardControlRouter {
30
30
  }
31
31
  /** Routes an authenticated Ecko result to the current guild owner. */
32
32
  acceptHostedEckoResult(input) {
33
- return this.#route({ kind: "automod.enhanced.result.accept", ...input }, remainingEnhancedDeadline(input.result));
33
+ return this.#route({ kind: "automod.enhanced.result.accept", ...input }, ECKO_RESULT_TIMEOUT_MS);
34
34
  }
35
35
  /** Reads the fenced leader's current Enhanced configuration snapshot. */
36
36
  getHostedEckoConfigurationPage(input) {
@@ -336,17 +336,6 @@ async function executeLeaderOperation(input, operation) {
336
336
  throw new BotControlTransportError(503, "The shard leader is temporarily unavailable");
337
337
  return executePrivacy(input.privacy, operation);
338
338
  }
339
- function remainingEnhancedDeadline(result) {
340
- if (!result || typeof result !== "object")
341
- return ECKO_RESULT_TIMEOUT_MS;
342
- const deadlineAt = result["deadlineAt"];
343
- if (typeof deadlineAt !== "string")
344
- return ECKO_RESULT_TIMEOUT_MS;
345
- const parsed = Date.parse(deadlineAt);
346
- if (!Number.isFinite(parsed))
347
- return ECKO_RESULT_TIMEOUT_MS;
348
- return Math.max(1, Math.min(ECKO_RESULT_TIMEOUT_MS, parsed - Date.now()));
349
- }
350
339
  async function executePrivacy(privacy, operation) {
351
340
  if (operation.kind === "privacy.providers.list")
352
341
  return (await privacy.listPrivacyProviders());
@@ -223,6 +223,11 @@ export async function startShardWorker(processEnvironment = process.env, identit
223
223
  const serviceOptions = platformServiceOptions(environment, publicModules);
224
224
  for (const [name, service] of createPlatformServices(database, publicModules, {
225
225
  ...serviceOptions,
226
+ recordRecoveryOwnership: {
227
+ mode: "shard",
228
+ shardId: identity.shardId,
229
+ shardCount: identity.shardCount,
230
+ },
226
231
  activationServices: serviceAccess,
227
232
  })) {
228
233
  platformServices.set(name, service);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/bot",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "Production Discord runtime for the modular Helyx platform.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -53,11 +53,11 @@
53
53
  "@helyx/config": ">=1.0.0 <2.0.0",
54
54
  "@helyx/control-protocol": ">=0.5.0 <0.6.0",
55
55
  "@helyx/core": ">=0.1.0 <0.2.0",
56
- "@helyx/database": ">=2.0.0 <3.0.0",
56
+ "@helyx/database": ">=2.3.0",
57
57
  "@helyx/framework": ">=0.1.0 <0.2.0",
58
- "@helyx/module-manifest": ">=0.10.0 <0.11.0",
59
- "@helyx/platform": ">=1.0.0 <2.0.0",
60
- "@helyx/sdk": ">=0.12.0 <0.13.0",
58
+ "@helyx/module-manifest": ">=0.11.0 <0.12.0",
59
+ "@helyx/platform": ">=1.2.0",
60
+ "@helyx/sdk": ">=0.14.0",
61
61
  "@helyx/status-client": ">=0.2.0 <0.3.0",
62
62
  "@helyx/verification-contracts": ">=0.3.0 <0.4.0",
63
63
  "discord.js": "14.27.0",
@@ -69,8 +69,8 @@
69
69
  "zod": "4.4.3"
70
70
  },
71
71
  "peerDependencies": {
72
- "@helyx/module-automod": ">=1.0.0 <2.0.0",
73
- "@helyx/module-moderation": ">=1.0.0 <2.0.0"
72
+ "@helyx/module-automod": ">=1.0.2",
73
+ "@helyx/module-moderation": ">=1.0.2"
74
74
  },
75
75
  "peerDependenciesMeta": {
76
76
  "@helyx/module-automod": {