@helyx/module-automod 1.0.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +725 -0
  3. package/README.md +80 -0
  4. package/dist/activity-resource.d.ts +3 -0
  5. package/dist/activity-resource.js +114 -0
  6. package/dist/case-link-provider.d.ts +3 -0
  7. package/dist/case-link-provider.js +13 -0
  8. package/dist/configuration.d.ts +22 -0
  9. package/dist/configuration.js +85 -0
  10. package/dist/constants.d.ts +30 -0
  11. package/dist/constants.js +37 -0
  12. package/dist/contracts.d.ts +30 -0
  13. package/dist/contracts.js +51 -0
  14. package/dist/domain.d.ts +61 -0
  15. package/dist/domain.js +274 -0
  16. package/dist/engine/canonicalisation.d.ts +9 -0
  17. package/dist/engine/canonicalisation.js +150 -0
  18. package/dist/engine/compile.d.ts +4 -0
  19. package/dist/engine/compile.js +169 -0
  20. package/dist/engine/confidence.d.ts +13 -0
  21. package/dist/engine/confidence.js +57 -0
  22. package/dist/engine/configuration-cache.d.ts +29 -0
  23. package/dist/engine/configuration-cache.js +115 -0
  24. package/dist/engine/contracts.d.ts +82 -0
  25. package/dist/engine/contracts.js +25 -0
  26. package/dist/engine/index.d.ts +11 -0
  27. package/dist/engine/index.js +11 -0
  28. package/dist/engine/matcher.d.ts +3 -0
  29. package/dist/engine/matcher.js +206 -0
  30. package/dist/engine/observations.d.ts +45 -0
  31. package/dist/engine/observations.js +105 -0
  32. package/dist/engine/operation-id.d.ts +18 -0
  33. package/dist/engine/operation-id.js +24 -0
  34. package/dist/engine/similar-message-window.d.ts +68 -0
  35. package/dist/engine/similar-message-window.js +259 -0
  36. package/dist/engine/term-import.d.ts +15 -0
  37. package/dist/engine/term-import.js +43 -0
  38. package/dist/enhanced-configuration.d.ts +16 -0
  39. package/dist/enhanced-configuration.js +91 -0
  40. package/dist/events.d.ts +4 -0
  41. package/dist/events.js +371 -0
  42. package/dist/health.d.ts +9 -0
  43. package/dist/health.js +45 -0
  44. package/dist/index.d.ts +10 -0
  45. package/dist/index.js +141 -0
  46. package/dist/policy-provider.d.ts +3 -0
  47. package/dist/policy-provider.js +66 -0
  48. package/dist/publication-support.d.ts +14 -0
  49. package/dist/publication-support.js +69 -0
  50. package/dist/publication.d.ts +32 -0
  51. package/dist/publication.js +333 -0
  52. package/dist/receipt-statistics.d.ts +11 -0
  53. package/dist/receipt-statistics.js +58 -0
  54. package/dist/records.d.ts +299 -0
  55. package/dist/records.js +288 -0
  56. package/dist/repository-model.d.ts +54 -0
  57. package/dist/repository-model.js +132 -0
  58. package/dist/repository.d.ts +133 -0
  59. package/dist/repository.js +523 -0
  60. package/dist/resource-cursor.d.ts +5 -0
  61. package/dist/resource-cursor.js +45 -0
  62. package/dist/resource-presentation.d.ts +21 -0
  63. package/dist/resource-presentation.js +111 -0
  64. package/dist/resource-validation.d.ts +6 -0
  65. package/dist/resource-validation.js +80 -0
  66. package/dist/resources.d.ts +4 -0
  67. package/dist/resources.js +319 -0
  68. package/dist/scanner-configuration.d.ts +10 -0
  69. package/dist/scanner-configuration.js +146 -0
  70. package/dist/scanner-provider.d.ts +5 -0
  71. package/dist/scanner-provider.js +310 -0
  72. package/dist/scanner-result-validation.d.ts +3 -0
  73. package/dist/scanner-result-validation.js +61 -0
  74. package/dist/settings-preview.d.ts +6 -0
  75. package/dist/settings-preview.js +84 -0
  76. package/manifest.json +1601 -0
  77. package/migrations/0001_automod_standard.sql +114 -0
  78. package/migrations/0002_automod_enhanced_receipts.sql +40 -0
  79. package/package.json +61 -0
@@ -0,0 +1,111 @@
1
+ export function previewMessage(draft) {
2
+ const disclosures = draft.alertChannelId
3
+ ? "Discord alerts may include the triggering message content in the selected channel."
4
+ : "Helyx stores structural receipts only; it does not store matched content.";
5
+ return {
6
+ embeds: [
7
+ {
8
+ title: `Auto Moderation preview - ${draft.name}`,
9
+ description: `${filterSummary(draft)}\n\n${disclosures}`,
10
+ fields: [
11
+ { name: "Category", value: draft.category, inline: true },
12
+ {
13
+ name: "Native actions",
14
+ value: nativeActionSummary(draft),
15
+ inline: true,
16
+ },
17
+ {
18
+ name: "Repeat offender",
19
+ value: repeatSummary(draft),
20
+ inline: false,
21
+ },
22
+ ],
23
+ },
24
+ ],
25
+ };
26
+ }
27
+ export function ruleSummary(rule) {
28
+ return {
29
+ id: rule.ruleId,
30
+ revision: rule.revision,
31
+ status: rule.status,
32
+ title: rule.name,
33
+ description: `${rule.category} - ${rule.kind.replaceAll("_", " ")}`,
34
+ attributes: {
35
+ filter: filterSummary(rule.draft),
36
+ action: nativeActionSummary(rule.draft),
37
+ moderationThread: rule.draft.openViolationThread ? "Open" : "Off",
38
+ ignoredRoles: rule.draft.ignoredRoleIds,
39
+ repeatOffender: repeatSummary(rule.draft),
40
+ },
41
+ updatedAt: rule.updatedAt.toISOString(),
42
+ };
43
+ }
44
+ export function ruleDetail(rule) {
45
+ return {
46
+ ...ruleSummary(rule),
47
+ value: {
48
+ name: rule.draft.name,
49
+ category: rule.draft.category,
50
+ ruleKind: rule.draft.kind,
51
+ keywordFilter: rule.draft.keywordFilter.join("\n"),
52
+ regexPatterns: rule.draft.regexPatterns.join("\n"),
53
+ allowList: rule.draft.allowList.join("\n"),
54
+ enhancedNormalisedMatching: rule.draft.enhancedNormalisedMatching !== false,
55
+ enhancedFuzzyMatching: rule.draft.enhancedFuzzyMatching === true,
56
+ presetProfanity: rule.draft.presets.includes("profanity"),
57
+ presetSexualContent: rule.draft.presets.includes("sexual_content"),
58
+ presetSlurs: rule.draft.presets.includes("slurs"),
59
+ mentionTotalLimit: rule.draft.mentionTotalLimit,
60
+ mentionRaidProtectionEnabled: rule.draft.mentionRaidProtectionEnabled,
61
+ blockMessage: rule.draft.blockMessage,
62
+ blockExplanation: rule.draft.blockExplanation,
63
+ alertChannelId: rule.draft.alertChannelId,
64
+ nativeTimeoutSeconds: String(rule.draft.nativeTimeoutSeconds ?? 0),
65
+ ignoredRoleIds: rule.draft.ignoredRoleIds,
66
+ ignoredChannelIds: rule.draft.ignoredChannelIds,
67
+ openViolationThread: rule.draft.openViolationThread,
68
+ ...actionValues("first", rule.draft.actionPolicy.first),
69
+ ...actionValues("second", rule.draft.actionPolicy.second),
70
+ ...actionValues("third", rule.draft.actionPolicy.thirdAndLater),
71
+ nativeRuleId: rule.nativeRuleId,
72
+ configurationRevision: rule.configurationRevision,
73
+ },
74
+ };
75
+ }
76
+ function actionValues(prefix, action) {
77
+ return {
78
+ [`${prefix}Action`]: action.type,
79
+ [`${prefix}TimeoutSeconds`]: String(action.type === "timeout" ? action.durationSeconds : 300),
80
+ [`${prefix}BanDeleteMessageSeconds`]: String(action.type === "ban" ? action.deleteMessageSeconds : 0),
81
+ [`${prefix}DemoteRoleIds`]: action.type === "demote" ? action.roleIds : [],
82
+ };
83
+ }
84
+ function filterSummary(draft) {
85
+ if (draft.kind === "keyword")
86
+ return `${draft.keywordFilter.length} keyword/phrase entries - ${draft.regexPatterns.length} regex patterns`;
87
+ if (draft.kind === "keyword_preset")
88
+ return draft.presets.join(", ") || "No preset selected";
89
+ if (draft.kind === "mention_spam")
90
+ return `${draft.mentionTotalLimit} mentions per message`;
91
+ return "Discord generic spam detection";
92
+ }
93
+ function nativeActionSummary(draft) {
94
+ return ([
95
+ draft.blockMessage ? "Block" : null,
96
+ draft.alertChannelId ? "Alert" : null,
97
+ draft.nativeTimeoutSeconds ? `Mute ${draft.nativeTimeoutSeconds}s` : null,
98
+ ]
99
+ .filter(Boolean)
100
+ .join(" - ") || "None");
101
+ }
102
+ function repeatSummary(draft) {
103
+ return [
104
+ draft.actionPolicy.first.type,
105
+ draft.actionPolicy.second.type,
106
+ draft.actionPolicy.thirdAndLater.type,
107
+ ]
108
+ .map((value) => value.replaceAll("_", " "))
109
+ .join(" -> ");
110
+ }
111
+ //# sourceMappingURL=resource-presentation.js.map
@@ -0,0 +1,6 @@
1
+ import { type ServiceAccess } from "@helyx/sdk";
2
+ import type { AutoModerationRuleDraft } from "./domain.js";
3
+ import type { AutoModerationRepository } from "./repository.js";
4
+ export declare function validateRuleReferences(services: ServiceAccess, guildId: string, draft: AutoModerationRuleDraft): Promise<void>;
5
+ export declare function validateSingletonKind(repository: AutoModerationRepository, guildId: string, kind: AutoModerationRuleDraft["kind"], exceptRuleId?: string): Promise<void>;
6
+ //# sourceMappingURL=resource-validation.d.ts.map
@@ -0,0 +1,80 @@
1
+ import { DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { AUTOMOD_LIMITS } from "./constants.js";
3
+ import { currentConfiguration } from "./publication-support.js";
4
+ export async function validateRuleReferences(services, guildId, draft) {
5
+ const configuration = await currentConfiguration(services, guildId);
6
+ const violationRole = await violationAccessRole(services, guildId);
7
+ const ignoredRoles = [
8
+ ...new Set([
9
+ ...draft.ignoredRoleIds,
10
+ ...configuration.value.defaultExemptRoleIds,
11
+ ]),
12
+ ];
13
+ const demotionRoles = [
14
+ ...new Set([
15
+ draft.actionPolicy.first,
16
+ draft.actionPolicy.second,
17
+ draft.actionPolicy.thirdAndLater,
18
+ ].flatMap((action) => action.type === "demote" ? action.roleIds : [])),
19
+ ];
20
+ if (violationRole &&
21
+ (ignoredRoles.includes(violationRole) ||
22
+ demotionRoles.includes(violationRole)))
23
+ throw new DashboardActionValidationError("The violation-access role cannot be ignored or removed by Auto Moderation.");
24
+ if (!services.has(HELYX_SERVICE_NAMES.discordResources))
25
+ throw new DashboardActionValidationError("Discord role and channel validation is temporarily unavailable.");
26
+ const inspection = services.get(HELYX_SERVICE_NAMES.discordResources);
27
+ await Promise.all(ignoredRoles.map(async (roleId) => {
28
+ const role = await inspection.inspectRole({ guildId, roleId });
29
+ if (!role.exists || !role.guildMatches)
30
+ throw new DashboardActionValidationError("An ignored role is missing from this server.");
31
+ }));
32
+ await Promise.all(demotionRoles.map(async (roleId) => {
33
+ const role = await inspection.inspectRole({ guildId, roleId });
34
+ if (!role.exists || !role.guildMatches || !role.assignable)
35
+ throw new DashboardActionValidationError("A demotion role cannot be safely managed by Helyx.");
36
+ }));
37
+ const alertChannelId = draft.alertChannelId ?? configuration.value.defaultNativeAlertChannelId;
38
+ const ignoredChannels = [
39
+ ...new Set([
40
+ ...draft.ignoredChannelIds,
41
+ ...configuration.value.defaultExemptChannelIds,
42
+ ]),
43
+ ];
44
+ await Promise.all(ignoredChannels.map(async (channelId) => {
45
+ const channel = await inspection.inspectChannel({ guildId, channelId });
46
+ if (!channel.exists || !channel.guildMatches)
47
+ throw new DashboardActionValidationError("An ignored channel is missing from this server.");
48
+ }));
49
+ if (alertChannelId) {
50
+ const channel = await inspection.inspectChannel({
51
+ guildId,
52
+ channelId: alertChannelId,
53
+ });
54
+ if (!channel.exists ||
55
+ !channel.guildMatches ||
56
+ !["guild_text", "guild_announcement"].includes(channel.type))
57
+ throw new DashboardActionValidationError("The Discord alert channel must be a text or announcement channel in this server.");
58
+ }
59
+ }
60
+ export async function validateSingletonKind(repository, guildId, kind, exceptRuleId) {
61
+ if (kind === "keyword")
62
+ return;
63
+ const page = await repository.listRules({
64
+ guildId,
65
+ limit: AUTOMOD_LIMITS.rulePageSize,
66
+ });
67
+ if (page.items.some((rule) => rule.kind === kind && rule.ruleId !== exceptRuleId))
68
+ throw new DashboardActionValidationError(`Only one ${kind.replaceAll("_", " ")} rule can be owned by Helyx.`);
69
+ }
70
+ async function violationAccessRole(services, guildId) {
71
+ if (!services.has(HELYX_SERVICE_NAMES.configuration))
72
+ return null;
73
+ const moderation = await services
74
+ .get(HELYX_SERVICE_NAMES.configuration)
75
+ .get(guildId, "helyx.moderation");
76
+ return typeof moderation?.value.violationAccessRoleId === "string"
77
+ ? moderation.value.violationAccessRoleId
78
+ : null;
79
+ }
80
+ //# sourceMappingURL=resource-validation.js.map
@@ -0,0 +1,4 @@
1
+ import { type ModuleManagedResource } from "@helyx/sdk";
2
+ import type { AutoModerationPublicationService } from "./publication.js";
3
+ export declare function createAutoModerationRulesResource(publication: AutoModerationPublicationService): ModuleManagedResource;
4
+ //# sourceMappingURL=resources.d.ts.map
@@ -0,0 +1,319 @@
1
+ import { AUTOMOD_TIMEOUT_SECONDS, DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { AUTOMOD_LIMITS, AUTOMOD_MODULE_ID, AUTOMOD_PERMISSION_IDS, } from "./constants.js";
3
+ import { parseRuleDraft, canonicalRuleFingerprint, nativeRuleOwnershipMarker, } from "./domain.js";
4
+ import { currentConfiguration } from "./publication.js";
5
+ import { cursorBinding, decodeCursor, encodeCursor, } from "./resource-cursor.js";
6
+ import { previewMessage, ruleDetail, ruleSummary, } from "./resource-presentation.js";
7
+ import { validateRuleReferences, validateSingletonKind, } from "./resource-validation.js";
8
+ import { canonicalTermKey, parseEnhancedTermImport } from "./engine/index.js";
9
+ import { AutoModerationRepository, } from "./repository.js";
10
+ export function createAutoModerationRulesResource(publication) {
11
+ return {
12
+ id: "automod-rules",
13
+ availableWhileDisabled: true,
14
+ async list(context, input) {
15
+ const binding = cursorBinding(context.guildId, input.search, input.filters);
16
+ const cursor = decodeCursor(input.cursor, binding);
17
+ const page = await publication.repository(context.services).listRules({
18
+ guildId: context.guildId,
19
+ limit: input.limit,
20
+ ...(cursor ? { cursor } : {}),
21
+ ...(input.search ? { search: input.search.trim() } : {}),
22
+ ...(validStatus(input.filters?.status)
23
+ ? { status: input.filters.status }
24
+ : {}),
25
+ });
26
+ return {
27
+ items: page.items.map(ruleSummary),
28
+ ...(page.nextCursor
29
+ ? { nextCursor: encodeCursor(page.nextCursor, binding) }
30
+ : {}),
31
+ };
32
+ },
33
+ async read(context, resourceId) {
34
+ const rule = await publication
35
+ .repository(context.services)
36
+ .findRule(context.guildId, resourceId);
37
+ return rule ? ruleDetail(rule) : null;
38
+ },
39
+ async preview(context, value) {
40
+ const draft = validatedDraft(value);
41
+ await validateRuleReferences(context.services, context.guildId, draft);
42
+ return previewMessage(draft);
43
+ },
44
+ import(_context, input) {
45
+ let result;
46
+ try {
47
+ result = parseEnhancedTermImport(input.content, {
48
+ maximumBytes: AUTOMOD_LIMITS.importBytes,
49
+ maximumTerms: AUTOMOD_LIMITS.termsPerRule,
50
+ maximumTermCharacters: AUTOMOD_LIMITS.termCharacters,
51
+ });
52
+ }
53
+ catch (error) {
54
+ throw new DashboardActionValidationError(error instanceof Error ? error.message : "The import is invalid.");
55
+ }
56
+ return Promise.resolve({
57
+ value: { keywordFilter: result.accepted.join("\n") },
58
+ preview: {
59
+ accepted: result.accepted.length,
60
+ rejected: result.rejected.length,
61
+ duplicateCount: result.duplicateCount,
62
+ rowErrors: result.rejected.slice(0, 100),
63
+ },
64
+ });
65
+ },
66
+ async create(context, value, intent = "publish") {
67
+ if (intent !== "publish")
68
+ throw new DashboardActionValidationError("Save the rule to validate and apply it to Discord.");
69
+ const draft = validatedDraft(value);
70
+ await validateRuleReferences(context.services, context.guildId, draft);
71
+ await validateSingletonKind(publication.repository(context.services), context.guildId, draft.kind);
72
+ await validateCanonicalTermOwnership(publication.repository(context.services), context.guildId, draft);
73
+ if (!(await revalidatePublication(context)))
74
+ throw new DashboardActionValidationError("Enable Auto Moderation and check your rule publishing permission before saving.");
75
+ let created;
76
+ try {
77
+ created = await publication.repository(context.services).createDraft({
78
+ guildId: context.guildId,
79
+ draft,
80
+ actorUserId: context.actor.userId,
81
+ occurredAt: new Date(),
82
+ });
83
+ return ruleDetail(await publication.publish({
84
+ services: context.services,
85
+ current: created,
86
+ draft,
87
+ actorUserId: context.actor.userId,
88
+ revalidate: () => revalidatePublication(context),
89
+ }));
90
+ }
91
+ catch (error) {
92
+ const failure = validationError(error);
93
+ throw created
94
+ ? new DashboardActionValidationError(`${failure.message} The rule is retained for recovery. Open it in Rules before retrying Save.`)
95
+ : failure;
96
+ }
97
+ },
98
+ async update(context, input) {
99
+ const repository = publication.repository(context.services);
100
+ const current = await requiredRule(repository, context.guildId, input.resourceId);
101
+ if (current.revision !== input.expectedRevision)
102
+ throw new DashboardActionValidationError("This rule changed. Reload and try again.");
103
+ if (current.status === "drifted" || current.status === "missing")
104
+ throw new DashboardActionValidationError("Reconcile the owned Discord rule before saving changes.");
105
+ const draft = validatedDraft(input.value);
106
+ if (current.nativeRuleId && draft.kind !== current.kind)
107
+ throw new DashboardActionValidationError("Discord cannot change a mapped rule's filter type. Create a separate rule instead.");
108
+ await validateRuleReferences(context.services, context.guildId, draft);
109
+ await validateSingletonKind(repository, context.guildId, draft.kind, current.ruleId);
110
+ await validateCanonicalTermOwnership(repository, context.guildId, draft, current.ruleId);
111
+ try {
112
+ return ruleDetail(await publication.publish({
113
+ services: context.services,
114
+ current,
115
+ draft,
116
+ actorUserId: context.actor.userId,
117
+ revalidate: () => revalidatePublication(context),
118
+ }));
119
+ }
120
+ catch (error) {
121
+ throw validationError(error);
122
+ }
123
+ },
124
+ async delete(context, input) {
125
+ const repository = publication.repository(context.services);
126
+ const current = await requiredRule(repository, context.guildId, input.resourceId);
127
+ if (current.revision !== input.expectedRevision)
128
+ throw new DashboardActionValidationError("This rule changed. Reload and try again.");
129
+ if (current.status !== "draft" || current.nativeRuleId)
130
+ throw new DashboardActionValidationError("Only an unmapped draft can be deleted.");
131
+ const nativeRules = await context.services
132
+ .get(HELYX_SERVICE_NAMES.discordAutoModeration)
133
+ .list({ guildId: context.guildId })
134
+ .catch(() => {
135
+ throw new DashboardActionValidationError("Discord rule inspection is temporarily unavailable. The rule record was not deleted.");
136
+ });
137
+ const ownershipMarker = `${nativeRuleOwnershipMarker(current.ruleId)} `;
138
+ if (nativeRules.some((rule) => rule.name.startsWith(ownershipMarker)))
139
+ throw new DashboardActionValidationError("An unconfirmed Discord rule still exists for this record. Review it before deleting the unpublished rule.");
140
+ const deleted = await repository.deleteDraft({
141
+ current,
142
+ actorUserId: context.actor.userId,
143
+ });
144
+ if (!deleted.deleted)
145
+ throw new DashboardActionValidationError("This rule changed. Reload and try again.");
146
+ return deleted;
147
+ },
148
+ async executeAction(context, input) {
149
+ const repository = publication.repository(context.services);
150
+ const current = await requiredRule(repository, context.guildId, input.resourceId);
151
+ if (current.revision !== input.expectedRevision)
152
+ throw new DashboardActionValidationError("This rule changed. Reload and try again.");
153
+ try {
154
+ if (input.actionId === "restore-helyx") {
155
+ await validateRuleReferences(context.services, context.guildId, current.draft);
156
+ return ruleDetail(await publication.publish({
157
+ services: context.services,
158
+ current,
159
+ draft: current.draft,
160
+ actorUserId: context.actor.userId,
161
+ revalidate: () => revalidate(context, AUTOMOD_PERMISSION_IDS.rulesReconcile),
162
+ }));
163
+ }
164
+ if (input.actionId === "disable")
165
+ return ruleDetail(await publication.disable({
166
+ services: context.services,
167
+ current,
168
+ actorUserId: context.actor.userId,
169
+ revalidate: () => revalidate(context, AUTOMOD_PERMISSION_IDS.rulesPublish),
170
+ }));
171
+ if (input.actionId === "cleanup-owned-discord")
172
+ return ruleDetail(await publication.cleanup({
173
+ services: context.services,
174
+ current,
175
+ actorUserId: context.actor.userId,
176
+ revalidate: () => revalidate(context, AUTOMOD_PERMISSION_IDS.rulesReconcile),
177
+ }));
178
+ if (input.actionId === "adopt-discord")
179
+ return ruleDetail(await adoptDiscord(context.services, current, context.actor.userId, () => revalidate(context, AUTOMOD_PERMISSION_IDS.rulesReconcile)));
180
+ throw new DashboardActionValidationError("Unknown Auto Moderation rule action.");
181
+ }
182
+ catch (error) {
183
+ throw validationError(error);
184
+ }
185
+ },
186
+ };
187
+ }
188
+ async function adoptDiscord(services, current, actorUserId, revalidateAuthorization) {
189
+ if (!(await revalidateAuthorization()))
190
+ throw new Error("Your permission changed before the rule was adopted.");
191
+ if (!current.nativeRuleId)
192
+ throw new Error("This rule has no owned Discord mapping to adopt.");
193
+ const snapshot = await services
194
+ .get(HELYX_SERVICE_NAMES.discordAutoModeration)
195
+ .read({ guildId: current.guildId, ruleId: current.nativeRuleId });
196
+ if (!snapshot)
197
+ throw new Error("The owned Discord rule is missing.");
198
+ const configuration = await currentConfiguration(services, current.guildId);
199
+ const draft = adoptedDraft(current.draft, snapshot, configuration.value.defaultExemptRoleIds, configuration.value.defaultExemptChannelIds);
200
+ const repository = new AutoModerationRepository(services);
201
+ await validateCanonicalTermOwnership(repository, current.guildId, draft, current.ruleId);
202
+ const updated = await repository.updateRule({
203
+ current,
204
+ draft,
205
+ status: snapshot.enabled ? "published" : "disabled",
206
+ nativeFingerprint: canonicalRuleFingerprint(snapshotDefinition(snapshot)),
207
+ configurationRevision: configuration.revision,
208
+ actorUserId,
209
+ action: "automod.rule-discord-adopted",
210
+ occurredAt: new Date(),
211
+ });
212
+ if (!updated)
213
+ throw new Error("This rule changed. Reload and try again.");
214
+ return updated;
215
+ }
216
+ function adoptedDraft(current, snapshot, globalRoles, globalChannels) {
217
+ const block = snapshot.actions.find((action) => action.type === "block_message");
218
+ const alert = snapshot.actions.find((action) => action.type === "send_alert_message");
219
+ const timeoutAction = snapshot.actions.find((action) => action.type === "timeout");
220
+ const duration = timeoutAction?.type === "timeout" ? timeoutAction.durationSeconds : null;
221
+ if (duration !== null && !AUTOMOD_TIMEOUT_SECONDS.includes(duration))
222
+ throw new Error("The Discord rule uses a timeout duration outside Helyx's fixed choices.");
223
+ const trigger = snapshot.trigger;
224
+ return {
225
+ ...current,
226
+ kind: trigger.type,
227
+ keywordFilter: trigger.type === "keyword" ? trigger.keywordFilter : [],
228
+ regexPatterns: trigger.type === "keyword" ? trigger.regexPatterns : [],
229
+ allowList: trigger.type === "keyword" || trigger.type === "keyword_preset"
230
+ ? trigger.allowList
231
+ : [],
232
+ presets: trigger.type === "keyword_preset" ? trigger.presets : [],
233
+ mentionTotalLimit: trigger.type === "mention_spam" ? trigger.mentionTotalLimit : 5,
234
+ mentionRaidProtectionEnabled: trigger.type === "mention_spam" && trigger.mentionRaidProtectionEnabled,
235
+ blockMessage: Boolean(block),
236
+ blockExplanation: block?.type === "block_message" ? (block.customMessage ?? null) : null,
237
+ alertChannelId: alert?.type === "send_alert_message" ? alert.channelId : null,
238
+ nativeTimeoutSeconds: duration,
239
+ ignoredRoleIds: snapshot.exemptRoleIds.filter((id) => !globalRoles.includes(id)),
240
+ ignoredChannelIds: snapshot.exemptChannelIds.filter((id) => !globalChannels.includes(id)),
241
+ };
242
+ }
243
+ async function revalidate(context, permissionId) {
244
+ if (!context.services.has(HELYX_SERVICE_NAMES.resourceAuthorization))
245
+ return false;
246
+ return context.services
247
+ .get(HELYX_SERVICE_NAMES.resourceAuthorization)
248
+ .canUse({
249
+ guildId: context.guildId,
250
+ moduleId: AUTOMOD_MODULE_ID,
251
+ permissionId,
252
+ actor: context.actor,
253
+ });
254
+ }
255
+ async function revalidatePublication(context) {
256
+ if (!context.services.has(HELYX_SERVICE_NAMES.installations))
257
+ return false;
258
+ return ((await context.services
259
+ .get(HELYX_SERVICE_NAMES.installations)
260
+ .isModuleEnabled(context.guildId, AUTOMOD_MODULE_ID)) &&
261
+ revalidate(context, AUTOMOD_PERMISSION_IDS.rulesPublish));
262
+ }
263
+ async function requiredRule(repository, guildId, ruleId) {
264
+ const rule = await repository.findRule(guildId, ruleId);
265
+ if (!rule)
266
+ throw new DashboardActionValidationError("Auto Moderation rule was not found.");
267
+ return rule;
268
+ }
269
+ function snapshotDefinition(snapshot) {
270
+ return {
271
+ name: snapshot.name,
272
+ enabled: snapshot.enabled,
273
+ eventType: snapshot.eventType,
274
+ trigger: snapshot.trigger,
275
+ actions: snapshot.actions,
276
+ exemptRoleIds: snapshot.exemptRoleIds,
277
+ exemptChannelIds: snapshot.exemptChannelIds,
278
+ };
279
+ }
280
+ function validationError(error) {
281
+ return error instanceof DashboardActionValidationError
282
+ ? error
283
+ : new DashboardActionValidationError(error instanceof Error
284
+ ? error.message
285
+ : "Auto Moderation rejected the operation.");
286
+ }
287
+ function validatedDraft(value) {
288
+ try {
289
+ return parseRuleDraft(value);
290
+ }
291
+ catch (error) {
292
+ throw validationError(error);
293
+ }
294
+ }
295
+ function validStatus(value) {
296
+ return Boolean(value &&
297
+ ["draft", "published", "disabled", "drifted", "missing", "review"].includes(value));
298
+ }
299
+ async function validateCanonicalTermOwnership(repository, guildId, draft, excludedRuleId) {
300
+ if (draft.kind !== "keyword")
301
+ return;
302
+ const incoming = new Set(draft.keywordFilter.map((term) => canonicalTermKey(term.replaceAll("*", ""))));
303
+ let cursor = null;
304
+ do {
305
+ const page = await repository.listRules({
306
+ guildId,
307
+ limit: AUTOMOD_LIMITS.rulePageSize,
308
+ ...(cursor ? { cursor } : {}),
309
+ });
310
+ const conflict = page.items.find((rule) => rule.ruleId !== excludedRuleId &&
311
+ rule.draft.kind === "keyword" &&
312
+ rule.category !== draft.category &&
313
+ rule.draft.keywordFilter.some((term) => incoming.has(canonicalTermKey(term.replaceAll("*", "")))));
314
+ if (conflict)
315
+ throw new DashboardActionValidationError(`A canonical term is already owned by ${conflict.name} in the ${conflict.category} category. Choose one category owner.`);
316
+ cursor = page.nextCursor;
317
+ } while (cursor);
318
+ }
319
+ //# sourceMappingURL=resources.js.map
@@ -0,0 +1,10 @@
1
+ import { type AutoModerationScannerConfigurationPageV1, type ServiceAccess } from "@helyx/sdk";
2
+ export declare function createScannerConfigurationSource(): {
3
+ getPage(services: ServiceAccess, input: {
4
+ cursor?: string;
5
+ limit: number;
6
+ knownFingerprint?: string;
7
+ }): Promise<AutoModerationScannerConfigurationPageV1>;
8
+ invalidate(): void;
9
+ };
10
+ //# sourceMappingURL=scanner-configuration.d.ts.map