@helyx/bot 1.2.0 → 1.3.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.
- package/CHANGELOG.md +23 -0
- package/dist/control-server.js +7 -36
- package/dist/dashboard-discord-settings.d.ts +20 -0
- package/dist/dashboard-discord-settings.js +67 -0
- package/dist/dashboard-feature-control.d.ts +2 -1
- package/dist/dashboard-feature-control.js +3 -0
- package/dist/discord-automod-execution-ids.d.ts +12 -0
- package/dist/discord-automod-execution-ids.js +62 -0
- package/dist/discord-violation-evidence-message.d.ts +12 -0
- package/dist/discord-violation-evidence-message.js +63 -0
- package/dist/discord-violation-layout-message.d.ts +13 -0
- package/dist/discord-violation-layout-message.js +156 -0
- package/dist/discord-violation-notification-users.d.ts +12 -0
- package/dist/discord-violation-notification-users.js +37 -0
- package/dist/discord-violation-thread-close.d.ts +5 -0
- package/dist/discord-violation-thread-close.js +71 -0
- package/dist/discord-violation-thread-service.d.ts +9 -0
- package/dist/discord-violation-thread-service.js +188 -50
- package/dist/events.js +13 -13
- package/dist/index.js +1 -1
- package/dist/management-module.js +36 -2
- package/dist/moderation-action-registry.js +41 -2
- package/dist/moderation-platform-services.d.ts +2 -2
- package/dist/moderation-platform-services.js +9 -6
- package/dist/privacy-provider-registry.d.ts +1 -1
- package/dist/privacy-provider-registry.js +35 -6
- package/dist/shard-router.js +2 -13
- package/dist/shard-worker.js +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Release accountable Moderation and Discord-native plus Enhanced Auto Moderation with configurable thread layouts, individual staff notifications, strict configuration metadata, privacy-safe settings cleanup, durable action recovery and theme-aware dashboard controls. Publish compatible manifest and SDK contracts and refresh self-host package catalogues.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- @helyx/module-manifest@0.11.0
|
|
13
|
+
- @helyx/sdk@0.13.0
|
|
14
|
+
- @helyx/module-automod@1.0.1
|
|
15
|
+
- @helyx/module-moderation@1.0.1
|
|
16
|
+
|
|
17
|
+
## 1.2.1
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- Load the embedded Auto Moderation engine only for a self-hosted runtime with its
|
|
22
|
+
scanner provider installed. Core-only and public-module installations no longer
|
|
23
|
+
require the optional Testing module to import or start the bot. Refresh the
|
|
24
|
+
self-host catalogue to reference the corrected bot release.
|
|
25
|
+
|
|
3
26
|
## 1.2.0
|
|
4
27
|
|
|
5
28
|
### Minor Changes
|
package/dist/control-server.js
CHANGED
|
@@ -24,9 +24,9 @@ import { registerDashboardPreviewImportRoutes, registerDashboardResourceMutation
|
|
|
24
24
|
import { executeDashboardAction as executeDeclaredDashboardAction, previewDashboardAction as previewDeclaredDashboardAction, } from "./dashboard-action-control.js";
|
|
25
25
|
import { registerDashboardActionRoutes, } from "./dashboard-action-routes.js";
|
|
26
26
|
import { registerEckoTransportRoutes, } from "./ecko-transport-routes.js";
|
|
27
|
+
import { validateDiscordUserSettingSelections, validateDiscordSettingSelections, } from "./dashboard-discord-settings.js";
|
|
27
28
|
const snowflake = z.string().regex(/^\d{17,20}$/u);
|
|
28
29
|
const moduleId = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/u);
|
|
29
|
-
const MAX_DISCORD_SETTING_SELECTIONS = 25;
|
|
30
30
|
const configurationValue = z.record(z.string(), z.union([
|
|
31
31
|
z.null(),
|
|
32
32
|
z.string(),
|
|
@@ -92,7 +92,10 @@ const managedResourceSummarySchema = z
|
|
|
92
92
|
})
|
|
93
93
|
.strict();
|
|
94
94
|
const managedResourceDetailSchema = managedResourceSummarySchema
|
|
95
|
-
.extend({
|
|
95
|
+
.extend({
|
|
96
|
+
value: managedResourceObject,
|
|
97
|
+
availableActionIds: z.array(z.string().min(1).max(100)).max(50).optional(),
|
|
98
|
+
})
|
|
96
99
|
.strict()
|
|
97
100
|
.refine(hasBoundedManagedResourceBytes);
|
|
98
101
|
const managedResourcePageSchema = z
|
|
@@ -1230,43 +1233,11 @@ export class BotControlService {
|
|
|
1230
1233
|
}
|
|
1231
1234
|
async #validateDiscordSettings(guildId, module, value) {
|
|
1232
1235
|
const settings = module.manifest.dashboard?.settings ?? [];
|
|
1236
|
+
await validateDiscordUserSettingSelections(this.#services, guildId, settings, value);
|
|
1233
1237
|
if (!settings.some((setting) => setting.type === "discord-channel" || setting.type === "discord-role"))
|
|
1234
1238
|
return;
|
|
1235
1239
|
const resources = await this.resources.getGuildResources(guildId);
|
|
1236
|
-
|
|
1237
|
-
const roles = new Set(resources.roles.map((role) => role.id));
|
|
1238
|
-
for (const setting of settings) {
|
|
1239
|
-
const configured = value[setting.key];
|
|
1240
|
-
if (setting.type === "discord-channel") {
|
|
1241
|
-
if (configured === "" || configured === undefined)
|
|
1242
|
-
continue;
|
|
1243
|
-
if (Boolean(setting.multiple) !== Array.isArray(configured))
|
|
1244
|
-
throw new BotControlValidationError(`${setting.label} has an invalid Discord channel selection`);
|
|
1245
|
-
const configuredChannels = Array.isArray(configured)
|
|
1246
|
-
? configured
|
|
1247
|
-
: [configured];
|
|
1248
|
-
if (configuredChannels.length > MAX_DISCORD_SETTING_SELECTIONS ||
|
|
1249
|
-
new Set(configuredChannels).size !== configuredChannels.length ||
|
|
1250
|
-
configuredChannels.some((channelId) => {
|
|
1251
|
-
if (typeof channelId !== "string")
|
|
1252
|
-
return true;
|
|
1253
|
-
const channel = channels.get(channelId);
|
|
1254
|
-
return !channel || !setting.channelTypes.includes(channel.type);
|
|
1255
|
-
}))
|
|
1256
|
-
throw new BotControlValidationError(`${setting.label} must contain available supported Discord channels`);
|
|
1257
|
-
}
|
|
1258
|
-
if (setting.type === "discord-role") {
|
|
1259
|
-
if (configured === "" || configured === undefined)
|
|
1260
|
-
continue;
|
|
1261
|
-
if (Boolean(setting.multiple) !== Array.isArray(configured))
|
|
1262
|
-
throw new BotControlValidationError(`${setting.label} has an invalid Discord role selection`);
|
|
1263
|
-
const configuredRoles = Array.isArray(configured)
|
|
1264
|
-
? configured
|
|
1265
|
-
: [configured];
|
|
1266
|
-
if (configuredRoles.some((roleId) => typeof roleId !== "string" || !roles.has(roleId)))
|
|
1267
|
-
throw new BotControlValidationError(`${setting.label} must contain available Discord roles`);
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1240
|
+
validateDiscordSettingSelections(settings, value, resources);
|
|
1270
1241
|
}
|
|
1271
1242
|
async #validatePermissionRoles(guildId, state) {
|
|
1272
1243
|
const policies = [
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DashboardSetting } from "@helyx/module-manifest";
|
|
2
|
+
import { type ServiceAccess } from "@helyx/sdk";
|
|
3
|
+
type ChannelType = Extract<DashboardSetting, {
|
|
4
|
+
type: "discord-channel";
|
|
5
|
+
}>["channelTypes"][number];
|
|
6
|
+
/** Shape checks precede bounded, freshly resolved guild-membership checks. */
|
|
7
|
+
export declare function discordUserSettingSelections(settings: readonly DashboardSetting[], value: Readonly<Record<string, unknown>>): string[];
|
|
8
|
+
export declare function validateDiscordUserSettingSelections(services: ServiceAccess, guildId: string, settings: readonly DashboardSetting[], value: Readonly<Record<string, unknown>>): Promise<void>;
|
|
9
|
+
/** Validates selections against the freshly fetched server resources. */
|
|
10
|
+
export declare function validateDiscordSettingSelections(settings: readonly DashboardSetting[], value: Readonly<Record<string, unknown>>, resources: {
|
|
11
|
+
channels: readonly {
|
|
12
|
+
id: string;
|
|
13
|
+
type: ChannelType;
|
|
14
|
+
}[];
|
|
15
|
+
roles: readonly {
|
|
16
|
+
id: string;
|
|
17
|
+
}[];
|
|
18
|
+
}): void;
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=dashboard-discord-settings.d.ts.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
const MAX_DISCORD_SETTING_SELECTIONS = 25;
|
|
3
|
+
/** Shape checks precede bounded, freshly resolved guild-membership checks. */
|
|
4
|
+
export function discordUserSettingSelections(settings, value) {
|
|
5
|
+
const selected = new Set();
|
|
6
|
+
for (const setting of settings) {
|
|
7
|
+
if (setting.type !== "discord-user")
|
|
8
|
+
continue;
|
|
9
|
+
const configured = value[setting.key] === undefined ? [] : value[setting.key];
|
|
10
|
+
if (!Array.isArray(configured) ||
|
|
11
|
+
configured.length <
|
|
12
|
+
Math.max(setting.required ? 1 : 0, setting.minItems ?? 0) ||
|
|
13
|
+
configured.length >
|
|
14
|
+
(setting.maxItems ?? MAX_DISCORD_SETTING_SELECTIONS) ||
|
|
15
|
+
new Set(configured).size !== configured.length ||
|
|
16
|
+
configured.some((id) => typeof id !== "string" || !/^\d{17,20}$/u.test(id)))
|
|
17
|
+
throw new DashboardActionValidationError(`${setting.label} must contain a bounded list of Discord user IDs`);
|
|
18
|
+
for (const userId of configured)
|
|
19
|
+
selected.add(userId);
|
|
20
|
+
}
|
|
21
|
+
if (selected.size > MAX_DISCORD_SETTING_SELECTIONS)
|
|
22
|
+
throw new DashboardActionValidationError("Select at most 25 individual users across these settings");
|
|
23
|
+
return [...selected];
|
|
24
|
+
}
|
|
25
|
+
export async function validateDiscordUserSettingSelections(services, guildId, settings, value) {
|
|
26
|
+
const userIds = discordUserSettingSelections(settings, value);
|
|
27
|
+
if (!userIds.length)
|
|
28
|
+
return;
|
|
29
|
+
if (!services.has(HELYX_SERVICE_NAMES.discordResources))
|
|
30
|
+
throw new DashboardActionValidationError("Discord membership confirmation is temporarily unavailable");
|
|
31
|
+
const members = await services
|
|
32
|
+
.get(HELYX_SERVICE_NAMES.discordResources)
|
|
33
|
+
.resolveMemberSnapshots({ guildId, userIds });
|
|
34
|
+
if (userIds.some((userId) => !members.some((member) => member.userId === userId && member.exists && !member.isBot)))
|
|
35
|
+
throw new DashboardActionValidationError("Notification users must be current human members of this server");
|
|
36
|
+
}
|
|
37
|
+
/** Validates selections against the freshly fetched server resources. */
|
|
38
|
+
export function validateDiscordSettingSelections(settings, value, resources) {
|
|
39
|
+
const channels = new Map(resources.channels.map(({ id, type }) => [id, type]));
|
|
40
|
+
const roles = new Set(resources.roles.map((role) => role.id));
|
|
41
|
+
for (const setting of settings) {
|
|
42
|
+
if (setting.type !== "discord-channel" && setting.type !== "discord-role")
|
|
43
|
+
continue;
|
|
44
|
+
const configured = value[setting.key];
|
|
45
|
+
if (configured === "" ||
|
|
46
|
+
configured === undefined ||
|
|
47
|
+
(configured === null && !setting.required && !setting.multiple))
|
|
48
|
+
continue;
|
|
49
|
+
if (Boolean(setting.multiple) !== Array.isArray(configured))
|
|
50
|
+
throw new DashboardActionValidationError(`${setting.label} has an invalid Discord ${setting.type === "discord-channel" ? "channel" : "role"} selection`);
|
|
51
|
+
const selections = Array.isArray(configured) ? configured : [configured];
|
|
52
|
+
if (setting.type === "discord-channel") {
|
|
53
|
+
if (selections.length > MAX_DISCORD_SETTING_SELECTIONS ||
|
|
54
|
+
new Set(selections).size !== selections.length ||
|
|
55
|
+
selections.some((channelId) => {
|
|
56
|
+
if (typeof channelId !== "string")
|
|
57
|
+
return true;
|
|
58
|
+
const channel = channels.get(channelId);
|
|
59
|
+
return !channel || !setting.channelTypes.includes(channel);
|
|
60
|
+
}))
|
|
61
|
+
throw new DashboardActionValidationError(`${setting.label} must contain available supported Discord channels`);
|
|
62
|
+
}
|
|
63
|
+
else if (selections.some((roleId) => typeof roleId !== "string" || !roles.has(roleId)))
|
|
64
|
+
throw new DashboardActionValidationError(`${setting.label} must contain available Discord roles`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=dashboard-discord-settings.js.map
|
|
@@ -16,9 +16,10 @@ export declare function isBoundedManagedResourceValue(value: unknown, depth?: nu
|
|
|
16
16
|
keys: number;
|
|
17
17
|
}): value is ManagedResourceValue;
|
|
18
18
|
export declare function hasBoundedManagedResourceBytes(value: unknown): boolean;
|
|
19
|
-
export declare function normalizeManagedResourceDetail(value: Omit<ManagedResourceDetail, "description" | "attributes"> & {
|
|
19
|
+
export declare function normalizeManagedResourceDetail(value: Omit<ManagedResourceDetail, "description" | "attributes" | "availableActionIds"> & {
|
|
20
20
|
description?: string | undefined;
|
|
21
21
|
attributes?: Readonly<Record<string, ManagedResourceValue>> | undefined;
|
|
22
|
+
availableActionIds?: readonly string[] | undefined;
|
|
22
23
|
}, attributeKeys: readonly string[]): ManagedResourceDetail;
|
|
23
24
|
export declare function normalizeManagedResourcePage(value: {
|
|
24
25
|
items: readonly (Omit<ManagedResourcePage["items"][number], "description" | "attributes"> & {
|
|
@@ -151,6 +151,9 @@ export function normalizeManagedResourceDetail(value, attributeKeys) {
|
|
|
151
151
|
}),
|
|
152
152
|
updatedAt: value.updatedAt,
|
|
153
153
|
value: value.value,
|
|
154
|
+
...(value.availableActionIds === undefined
|
|
155
|
+
? {}
|
|
156
|
+
: { availableActionIds: value.availableActionIds }),
|
|
154
157
|
};
|
|
155
158
|
}
|
|
156
159
|
export function normalizeManagedResourcePage(value, attributeKeys) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AutoModerationActionExecution } from "discord.js";
|
|
2
|
+
/** Discord supplies no message ID for blocked posts. Never key all such posts
|
|
3
|
+
* permanently by member/channel; correlate companion actions only transiently.
|
|
4
|
+
* Same-type actions begin a new attempt, while a known alert ID is a replay.
|
|
5
|
+
*/
|
|
6
|
+
export declare class DiscordAutoModerationExecutionIds {
|
|
7
|
+
#private;
|
|
8
|
+
private readonly now;
|
|
9
|
+
constructor(now?: () => number);
|
|
10
|
+
identify(execution: Pick<AutoModerationActionExecution, "guild" | "ruleId" | "userId" | "channelId" | "messageId" | "content" | "action" | "alertSystemMessageId">): string;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=discord-automod-execution-ids.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
const CORRELATION_LIFETIME_MS = 5_000;
|
|
3
|
+
const MAX_CORRELATION_GROUPS = 1_000;
|
|
4
|
+
const MAX_ATTEMPTS_PER_GROUP = 16;
|
|
5
|
+
/** Discord supplies no message ID for blocked posts. Never key all such posts
|
|
6
|
+
* permanently by member/channel; correlate companion actions only transiently.
|
|
7
|
+
* Same-type actions begin a new attempt, while a known alert ID is a replay.
|
|
8
|
+
*/
|
|
9
|
+
export class DiscordAutoModerationExecutionIds {
|
|
10
|
+
now;
|
|
11
|
+
#groups = new Map();
|
|
12
|
+
constructor(now = Date.now) {
|
|
13
|
+
this.now = now;
|
|
14
|
+
}
|
|
15
|
+
identify(execution) {
|
|
16
|
+
const identity = [
|
|
17
|
+
execution.guild.id,
|
|
18
|
+
execution.ruleId,
|
|
19
|
+
execution.userId,
|
|
20
|
+
execution.channelId,
|
|
21
|
+
];
|
|
22
|
+
if (execution.messageId)
|
|
23
|
+
return createHash("sha256")
|
|
24
|
+
.update(JSON.stringify([...identity, execution.messageId]))
|
|
25
|
+
.digest("hex");
|
|
26
|
+
const now = this.now();
|
|
27
|
+
for (const [key, attempts] of this.#groups) {
|
|
28
|
+
if (attempts.at(-1).expiresAt > now)
|
|
29
|
+
break;
|
|
30
|
+
this.#groups.delete(key);
|
|
31
|
+
}
|
|
32
|
+
// Content is hashed inside the core adapter and never stored or exposed to
|
|
33
|
+
// module code, records, telemetry or logs by this correlation cache.
|
|
34
|
+
const key = createHash("sha256")
|
|
35
|
+
.update(JSON.stringify([...identity, execution.content]))
|
|
36
|
+
.digest("hex");
|
|
37
|
+
const attempts = (this.#groups.get(key) ?? []).filter((attempt) => attempt.expiresAt > now);
|
|
38
|
+
const knownAlert = execution.alertSystemMessageId
|
|
39
|
+
? attempts.find((attempt) => attempt.alertMessageId === execution.alertSystemMessageId)
|
|
40
|
+
: undefined;
|
|
41
|
+
let attempt = knownAlert ??
|
|
42
|
+
attempts.find((candidate) => !candidate.actionTypes.has(execution.action.type));
|
|
43
|
+
if (!attempt) {
|
|
44
|
+
attempt = {
|
|
45
|
+
id: randomUUID(),
|
|
46
|
+
expiresAt: now + CORRELATION_LIFETIME_MS,
|
|
47
|
+
actionTypes: new Set(),
|
|
48
|
+
alertMessageId: null,
|
|
49
|
+
};
|
|
50
|
+
attempts.push(attempt);
|
|
51
|
+
}
|
|
52
|
+
attempt.actionTypes.add(execution.action.type);
|
|
53
|
+
if (execution.alertSystemMessageId)
|
|
54
|
+
attempt.alertMessageId = execution.alertSystemMessageId;
|
|
55
|
+
this.#groups.delete(key);
|
|
56
|
+
this.#groups.set(key, attempts.slice(-MAX_ATTEMPTS_PER_GROUP));
|
|
57
|
+
while (this.#groups.size > MAX_CORRELATION_GROUPS)
|
|
58
|
+
this.#groups.delete(this.#groups.keys().next().value);
|
|
59
|
+
return attempt.id;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=discord-automod-execution-ids.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ModerationSeverity, ViolationEvidenceReference } from "@helyx/sdk";
|
|
2
|
+
import { type MessageCreateOptions } from "discord.js";
|
|
3
|
+
export interface ViolationEvidenceMessageContext {
|
|
4
|
+
severity: ModerationSeverity;
|
|
5
|
+
occurredAt: Date;
|
|
6
|
+
ruleName?: string;
|
|
7
|
+
confidenceClass?: string;
|
|
8
|
+
confidenceScore?: number;
|
|
9
|
+
}
|
|
10
|
+
/** Render only inside the core evidence boundary, directly into Discord. */
|
|
11
|
+
export declare function violationEvidenceMessage(reference: ViolationEvidenceReference, content: string, context?: ViolationEvidenceMessageContext): MessageCreateOptions;
|
|
12
|
+
//# sourceMappingURL=discord-violation-evidence-message.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { escapeMarkdown } from "discord.js";
|
|
2
|
+
/** Render only inside the core evidence boundary, directly into Discord. */
|
|
3
|
+
export function violationEvidenceMessage(reference, content, context) {
|
|
4
|
+
const severity = context?.severity;
|
|
5
|
+
return {
|
|
6
|
+
allowedMentions: { parse: [] },
|
|
7
|
+
embeds: [
|
|
8
|
+
{
|
|
9
|
+
title: "Auto Moderation Trigger",
|
|
10
|
+
description: `Automated rule trigger from <@${reference.authorUserId}>${severity ? ` at severity ${severity}` : ""}.`,
|
|
11
|
+
color: severity === "critical" || severity === "high"
|
|
12
|
+
? 0xef4444
|
|
13
|
+
: severity === "medium"
|
|
14
|
+
? 0xf59e0b
|
|
15
|
+
: 0xf97316,
|
|
16
|
+
fields: [
|
|
17
|
+
{
|
|
18
|
+
name: "Original Channel",
|
|
19
|
+
value: `<#${reference.channelId}>`,
|
|
20
|
+
inline: true,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "Rule",
|
|
24
|
+
value: escapeMarkdown(context?.ruleName ?? "Auto Moderation rule"),
|
|
25
|
+
inline: true,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "Confidence",
|
|
29
|
+
value: context?.confidenceScore !== undefined
|
|
30
|
+
? `${context.confidenceScore}%`
|
|
31
|
+
: context?.confidenceClass === "native_equivalent"
|
|
32
|
+
? "Discord-native match (no score provided)"
|
|
33
|
+
: (context?.confidenceClass ?? "Not available"),
|
|
34
|
+
inline: true,
|
|
35
|
+
},
|
|
36
|
+
...capturedMessageFields(content),
|
|
37
|
+
],
|
|
38
|
+
...(context ? { timestamp: context.occurredAt.toISOString() } : {}),
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function capturedMessageFields(content) {
|
|
44
|
+
// Preserve all evidence while respecting Discord's 1,024-character field
|
|
45
|
+
// limit. Keep escaped Markdown pairs and Unicode code points intact.
|
|
46
|
+
const chunks = [];
|
|
47
|
+
let chunk = "";
|
|
48
|
+
for (const token of escapeMarkdown(content).match(/\\.|./gsu) ?? []) {
|
|
49
|
+
if (chunk.length + token.length > 1_024) {
|
|
50
|
+
chunks.push(chunk);
|
|
51
|
+
chunk = "";
|
|
52
|
+
}
|
|
53
|
+
chunk += token;
|
|
54
|
+
}
|
|
55
|
+
if (chunk)
|
|
56
|
+
chunks.push(chunk);
|
|
57
|
+
return chunks.map((value, index) => ({
|
|
58
|
+
name: index === 0 ? "Original Message" : "Original Message (continued)",
|
|
59
|
+
value,
|
|
60
|
+
inline: false,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=discord-violation-evidence-message.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type MessageResponse, type ViolationThreadService } from "@helyx/sdk";
|
|
2
|
+
import { type APIMessageTopLevelComponent, type MessageCreateOptions } from "discord.js";
|
|
3
|
+
import type { ViolationEvidenceMessageContext } from "./discord-violation-evidence-message.js";
|
|
4
|
+
import type { ViolationEvidenceReference } from "@helyx/sdk";
|
|
5
|
+
type Presentation = NonNullable<Parameters<ViolationThreadService["send"]>[0]["presentation"]>;
|
|
6
|
+
/** Initial card has no evidence content; the request-bound core adapter fills its slots. */
|
|
7
|
+
export declare function violationLayoutOpening(response: MessageResponse, presentation: Presentation): MessageCreateOptions;
|
|
8
|
+
/** Replace nested controls in place; legacy top-level rows remain supported. */
|
|
9
|
+
export declare function violationLayoutControls(components: readonly APIMessageTopLevelComponent[], buttons: NonNullable<MessageResponse["buttons"]>): APIMessageTopLevelComponent[];
|
|
10
|
+
/** Evidence never leaves this core-owned transient renderer. */
|
|
11
|
+
export declare function violationLayoutEvidence(components: readonly APIMessageTopLevelComponent[], reference: ViolationEvidenceReference, content: string, context?: ViolationEvidenceMessageContext, separateEvidence?: boolean): APIMessageTopLevelComponent[] | null;
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=discord-violation-layout-message.d.ts.map
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { VIOLATION_THREAD_LAYOUTS, violationThreadLayoutContainers, } from "@helyx/sdk";
|
|
2
|
+
import { ComponentType, MessageFlags, escapeMarkdown, } from "discord.js";
|
|
3
|
+
import { toReplyOptions } from "./interactions.js";
|
|
4
|
+
const SLOT_IDS = {
|
|
5
|
+
title: 501,
|
|
6
|
+
participants: 502,
|
|
7
|
+
opening: 503,
|
|
8
|
+
trigger: 504,
|
|
9
|
+
channel: 505,
|
|
10
|
+
evidence: 506,
|
|
11
|
+
controls: 507,
|
|
12
|
+
};
|
|
13
|
+
/** Initial card has no evidence content; the request-bound core adapter fills its slots. */
|
|
14
|
+
export function violationLayoutOpening(response, presentation) {
|
|
15
|
+
if (!VIOLATION_THREAD_LAYOUTS.includes(presentation.layout) ||
|
|
16
|
+
!Number.isSafeInteger(presentation.caseNumber) ||
|
|
17
|
+
presentation.caseNumber < 1)
|
|
18
|
+
throw new Error("Invalid violation thread presentation");
|
|
19
|
+
const text = response.componentsV2?.text;
|
|
20
|
+
if (!text || text.length !== 2)
|
|
21
|
+
throw new Error("Invalid violation opening body");
|
|
22
|
+
const controls = controlRows(response.buttons ?? []);
|
|
23
|
+
const values = {
|
|
24
|
+
title: `**Moderation case · ${String(presentation.caseNumber).padStart(3, "0")}**`,
|
|
25
|
+
participants: text[0].content,
|
|
26
|
+
opening: escapeMarkdown(text[1].content),
|
|
27
|
+
trigger: "**Automated rule trigger**\nRule and confidence unavailable.",
|
|
28
|
+
channel: "**Original Channel**\nNot available.",
|
|
29
|
+
evidence: "**Original Message**\nEvidence unavailable or awaiting delivery.",
|
|
30
|
+
};
|
|
31
|
+
let separatorId = 700;
|
|
32
|
+
const components = violationThreadLayoutContainers(presentation.layout).map((container) => ({
|
|
33
|
+
type: ComponentType.Container,
|
|
34
|
+
id: container.id,
|
|
35
|
+
...(container.accent ? { accent_color: 0xf97316 } : {}),
|
|
36
|
+
...(container.spoiler ? { spoiler: true } : {}),
|
|
37
|
+
components: container.slots.flatMap((slot) => {
|
|
38
|
+
if (slot === "divider")
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
type: ComponentType.Separator,
|
|
42
|
+
id: separatorId++,
|
|
43
|
+
divider: true,
|
|
44
|
+
spacing: 1,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
if (slot === "controls")
|
|
48
|
+
return controls;
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
type: ComponentType.TextDisplay,
|
|
52
|
+
id: SLOT_IDS[slot],
|
|
53
|
+
content: values[slot],
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
}),
|
|
57
|
+
}));
|
|
58
|
+
const original = toReplyOptions(response);
|
|
59
|
+
if (textLength(components) > 4_000) {
|
|
60
|
+
// Keep an unusually Markdown-heavy configured message literal without
|
|
61
|
+
// doubling every character or dropping any of its text.
|
|
62
|
+
for (const container of components)
|
|
63
|
+
for (const child of container.components)
|
|
64
|
+
if (child.type === ComponentType.TextDisplay &&
|
|
65
|
+
child.id === SLOT_IDS.opening)
|
|
66
|
+
child.content = `\`\`\`\n${text[1].content.replaceAll("```", "``\u200b`")}\n\`\`\``;
|
|
67
|
+
if (textLength(components) > 4_000)
|
|
68
|
+
throw new Error("Violation opening exceeds Discord's display limit");
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
flags: MessageFlags.IsComponentsV2,
|
|
72
|
+
components,
|
|
73
|
+
...(original.allowedMentions
|
|
74
|
+
? { allowedMentions: original.allowedMentions }
|
|
75
|
+
: {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Replace nested controls in place; legacy top-level rows remain supported. */
|
|
79
|
+
export function violationLayoutControls(components, buttons) {
|
|
80
|
+
const rows = controlRows(buttons);
|
|
81
|
+
let nested = false;
|
|
82
|
+
const updated = components
|
|
83
|
+
.filter((component) => component.type !== ComponentType.ActionRow)
|
|
84
|
+
.map((component) => {
|
|
85
|
+
if (component.type !== ComponentType.Container)
|
|
86
|
+
return component;
|
|
87
|
+
return {
|
|
88
|
+
...component,
|
|
89
|
+
components: component.components.flatMap((child) => {
|
|
90
|
+
if (child.type !== ComponentType.ActionRow)
|
|
91
|
+
return [child];
|
|
92
|
+
nested = true;
|
|
93
|
+
return rows;
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
return nested ? updated : [...updated, ...rows];
|
|
98
|
+
}
|
|
99
|
+
/** Evidence never leaves this core-owned transient renderer. */
|
|
100
|
+
export function violationLayoutEvidence(components, reference, content, context, separateEvidence = false) {
|
|
101
|
+
let found = false;
|
|
102
|
+
const confidence = context?.confidenceScore !== undefined
|
|
103
|
+
? `${context.confidenceScore}%`
|
|
104
|
+
: context?.confidenceClass === "native_equivalent"
|
|
105
|
+
? "Discord-native match (no score provided)"
|
|
106
|
+
: "Not available";
|
|
107
|
+
const replacement = {
|
|
108
|
+
[SLOT_IDS.trigger]: `**Automated rule trigger${context ? ` at severity ${context.severity}` : ""}.**\nRule: ${escapeMarkdown(context?.ruleName ?? "Auto Moderation rule")} · Confidence: ${confidence}`,
|
|
109
|
+
[SLOT_IDS.channel]: `**Original Channel**\n<#${reference.channelId}>`,
|
|
110
|
+
[SLOT_IDS.evidence]: separateEvidence
|
|
111
|
+
? "**Original Message**\nThe full original message requires a separate evidence card below."
|
|
112
|
+
: `**Original Message**\n${escapeMarkdown(content)}`,
|
|
113
|
+
};
|
|
114
|
+
const result = components.map((component) => {
|
|
115
|
+
if (component.type !== ComponentType.Container)
|
|
116
|
+
return component;
|
|
117
|
+
return {
|
|
118
|
+
...component,
|
|
119
|
+
components: component.components.map((child) => {
|
|
120
|
+
if (child.type !== ComponentType.TextDisplay || child.id === undefined)
|
|
121
|
+
return child;
|
|
122
|
+
const value = replacement[child.id];
|
|
123
|
+
if (value === undefined)
|
|
124
|
+
return child;
|
|
125
|
+
if (child.id === SLOT_IDS.evidence)
|
|
126
|
+
found = true;
|
|
127
|
+
return { ...child, content: value };
|
|
128
|
+
}),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
if (!found)
|
|
132
|
+
return null;
|
|
133
|
+
// Long escaped text is delivered separately without truncating evidence.
|
|
134
|
+
return textLength(result) <= 4_000 ? result : null;
|
|
135
|
+
}
|
|
136
|
+
function textLength(components) {
|
|
137
|
+
return components.reduce((total, component) => total +
|
|
138
|
+
(component.type === ComponentType.TextDisplay
|
|
139
|
+
? component.content.length
|
|
140
|
+
: component.type === ComponentType.Container
|
|
141
|
+
? component.components.reduce((sum, child) => sum +
|
|
142
|
+
(child.type === ComponentType.TextDisplay
|
|
143
|
+
? child.content.length
|
|
144
|
+
: 0), 0)
|
|
145
|
+
: 0), 0);
|
|
146
|
+
}
|
|
147
|
+
function controlRows(buttons) {
|
|
148
|
+
const raw = toReplyOptions({ buttons }).components ?? [];
|
|
149
|
+
return raw.map((row) => {
|
|
150
|
+
const json = ("toJSON" in row ? row.toJSON() : row);
|
|
151
|
+
if (json.type !== ComponentType.ActionRow)
|
|
152
|
+
throw new Error("Invalid violation control row");
|
|
153
|
+
return { ...json, id: SLOT_IDS.controls };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=discord-violation-layout-message.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Guild, type GuildMember, type TextChannel } from "discord.js";
|
|
2
|
+
/** Notification identities receive only reviewed parent/thread access. */
|
|
3
|
+
export declare function inspectViolationNotificationUsers(guild: Guild, channel: TextChannel, userIds: readonly string[]): Promise<{
|
|
4
|
+
outcome: "allowed";
|
|
5
|
+
members: readonly GuildMember[];
|
|
6
|
+
requiredChanges: string[];
|
|
7
|
+
} | {
|
|
8
|
+
outcome: "denied";
|
|
9
|
+
safeCode: "moderator_user_unavailable";
|
|
10
|
+
}>;
|
|
11
|
+
export declare function configureViolationNotificationUsers(channel: TextChannel, members: readonly GuildMember[], revalidate: () => Promise<boolean>, reason: string): Promise<boolean>;
|
|
12
|
+
//# sourceMappingURL=discord-violation-notification-users.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { VIOLATION_THREAD_LIMITS } from "@helyx/sdk";
|
|
2
|
+
import { PermissionFlagsBits, } from "discord.js";
|
|
3
|
+
const NOTIFICATION_ACCESS = Object.freeze({
|
|
4
|
+
view_channel: PermissionFlagsBits.ViewChannel,
|
|
5
|
+
read_message_history: PermissionFlagsBits.ReadMessageHistory,
|
|
6
|
+
send_messages_in_threads: PermissionFlagsBits.SendMessagesInThreads,
|
|
7
|
+
});
|
|
8
|
+
/** Notification identities receive only reviewed parent/thread access. */
|
|
9
|
+
export async function inspectViolationNotificationUsers(guild, channel, userIds) {
|
|
10
|
+
if (userIds.length > VIOLATION_THREAD_LIMITS.moderatorUsers ||
|
|
11
|
+
new Set(userIds).size !== userIds.length ||
|
|
12
|
+
userIds.some((id) => !/^\d{17,20}$/u.test(id)))
|
|
13
|
+
throw new Error("Violation notification user list is invalid");
|
|
14
|
+
const members = await Promise.all(userIds.map((user) => guild.members.fetch({ user, force: true }).catch(() => null)));
|
|
15
|
+
if (members.some((member) => !member || member.guild.id !== guild.id || member.user.bot))
|
|
16
|
+
return { outcome: "denied", safeCode: "moderator_user_unavailable" };
|
|
17
|
+
const confirmed = members;
|
|
18
|
+
const requiredChanges = [];
|
|
19
|
+
for (const member of confirmed)
|
|
20
|
+
for (const [name, flag] of Object.entries(NOTIFICATION_ACCESS))
|
|
21
|
+
if (!channel.permissionOverwrites.cache.get(member.id)?.allow.has(flag))
|
|
22
|
+
requiredChanges.push(`allow_moderator_user_${member.id}_${name}`);
|
|
23
|
+
return { outcome: "allowed", members: confirmed, requiredChanges };
|
|
24
|
+
}
|
|
25
|
+
export async function configureViolationNotificationUsers(channel, members, revalidate, reason) {
|
|
26
|
+
for (const member of members) {
|
|
27
|
+
if (!(await revalidate()))
|
|
28
|
+
return false;
|
|
29
|
+
await channel.permissionOverwrites.edit(member, {
|
|
30
|
+
ViewChannel: true,
|
|
31
|
+
ReadMessageHistory: true,
|
|
32
|
+
SendMessagesInThreads: true,
|
|
33
|
+
}, { reason });
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=discord-violation-notification-users.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ViolationThreadMutationResult, ViolationThreadService } from "@helyx/sdk";
|
|
2
|
+
import { type Client } from "discord.js";
|
|
3
|
+
/** Destructive lifecycle is restricted to the bot-created, durably bound private thread. */
|
|
4
|
+
export declare function closeAndDeleteViolationThread(client: Client, input: Parameters<ViolationThreadService["close"]>[0], reason: string, revalidate: () => Promise<boolean>): Promise<ViolationThreadMutationResult>;
|
|
5
|
+
//# sourceMappingURL=discord-violation-thread-close.d.ts.map
|