@helyx/bot 1.2.1 → 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 +14 -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/management-module.js +36 -2
- package/dist/moderation-action-registry.js +41 -2
- 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/package.json +3 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ViolationEvidenceReference, ViolationThreadConfigurationInspection, ViolationThreadDeliveryResult, ViolationThreadService } from "@helyx/sdk";
|
|
2
2
|
import { type Client } from "discord.js";
|
|
3
3
|
import type { TransientViolationEvidenceStore } from "./transient-violation-evidence.js";
|
|
4
|
+
import { type ViolationEvidenceMessageContext } from "./discord-violation-evidence-message.js";
|
|
4
5
|
/** Guild-owner-routed adapter for role-gated private violation threads. */
|
|
5
6
|
export declare class DiscordViolationThreadAdapter implements ViolationThreadService {
|
|
6
7
|
#private;
|
|
@@ -14,13 +15,21 @@ export declare class DiscordViolationThreadAdapter implements ViolationThreadSer
|
|
|
14
15
|
removeMember(input: Parameters<ViolationThreadService["removeMember"]>[0]): ReturnType<ViolationThreadService["removeMember"]>;
|
|
15
16
|
send(input: Parameters<ViolationThreadService["send"]>[0]): ReturnType<ViolationThreadService["send"]>;
|
|
16
17
|
close(input: Parameters<ViolationThreadService["close"]>[0]): ReturnType<ViolationThreadService["close"]>;
|
|
18
|
+
editControls(input: Parameters<NonNullable<ViolationThreadService["editControls"]>>[0]): Promise<{
|
|
19
|
+
outcome: "updated";
|
|
20
|
+
} | {
|
|
21
|
+
outcome: "failed";
|
|
22
|
+
safeCode: string;
|
|
23
|
+
}>;
|
|
17
24
|
reconcile(input: Parameters<ViolationThreadService["reconcile"]>[0]): ReturnType<ViolationThreadService["reconcile"]>;
|
|
18
25
|
}
|
|
19
26
|
export interface DiscordViolationEvidenceDeliveryInput {
|
|
20
27
|
guildId: string;
|
|
21
28
|
threadId: string;
|
|
29
|
+
openingMessageId?: string;
|
|
22
30
|
evidenceReference: ViolationEvidenceReference;
|
|
23
31
|
operationKey: string;
|
|
32
|
+
context?: ViolationEvidenceMessageContext;
|
|
24
33
|
}
|
|
25
34
|
/** Bot-private capability; never installed in the module service map. */
|
|
26
35
|
export declare class DiscordViolationEvidenceDeliveryAdapter {
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { VIOLATION_THREAD_LIMITS, VIOLATION_THREAD_REQUIRED_PERMISSIONS, VIOLATION_THREAD_RUNTIME_PERMISSIONS, } from "@helyx/sdk";
|
|
2
|
-
import { ChannelType, DiscordAPIError,
|
|
2
|
+
import { ChannelType, DiscordAPIError, PermissionFlagsBits, } from "discord.js";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { toReplyOptions } from "./interactions.js";
|
|
5
|
+
import { closeAndDeleteViolationThread } from "./discord-violation-thread-close.js";
|
|
6
|
+
import { violationEvidenceMessage, } from "./discord-violation-evidence-message.js";
|
|
7
|
+
import { configureViolationNotificationUsers, inspectViolationNotificationUsers, } from "./discord-violation-notification-users.js";
|
|
8
|
+
import { violationLayoutOpening, violationLayoutControls, violationLayoutEvidence, } from "./discord-violation-layout-message.js";
|
|
5
9
|
const SNOWFLAKE_PATTERN = /^\d{17,20}$/u;
|
|
6
10
|
const OPERATION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u;
|
|
7
11
|
const MAX_OPERATION_CACHE_ENTRIES = 10_000;
|
|
@@ -51,15 +55,34 @@ export class DiscordViolationThreadAdapter {
|
|
|
51
55
|
if (!guild)
|
|
52
56
|
return deniedInspection("guild_mismatch", "guild_not_owned");
|
|
53
57
|
const channel = await guild.channels
|
|
54
|
-
.fetch(input.parentChannelId)
|
|
58
|
+
.fetch(input.parentChannelId, { force: true })
|
|
55
59
|
.catch(() => null);
|
|
56
60
|
if (!channel ||
|
|
57
61
|
channel.guild.id !== guild.id ||
|
|
58
62
|
channel.type !== ChannelType.GuildText)
|
|
59
63
|
return deniedInspection("channel_missing", "parent_channel_missing");
|
|
64
|
+
const notificationUsers = await inspectViolationNotificationUsers(guild, channel, input.moderatorUserIds ?? []);
|
|
65
|
+
if (notificationUsers.outcome !== "allowed")
|
|
66
|
+
return {
|
|
67
|
+
outcome: "denied",
|
|
68
|
+
configurationRevision: null,
|
|
69
|
+
accessRole: null,
|
|
70
|
+
missingPermissions: [],
|
|
71
|
+
requiredChanges: [],
|
|
72
|
+
safeCode: notificationUsers.safeCode,
|
|
73
|
+
};
|
|
60
74
|
if (input.accessRoleId === null) {
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
if (input.moderatorRoleIds.includes(guild.roles.everyone.id))
|
|
76
|
+
return {
|
|
77
|
+
outcome: "denied",
|
|
78
|
+
configurationRevision: configurationRevision(channel, null, input.moderatorRoleIds, input.moderatorUserIds),
|
|
79
|
+
accessRole: null,
|
|
80
|
+
missingPermissions: [],
|
|
81
|
+
requiredChanges: [],
|
|
82
|
+
safeCode: "moderator_role_unsafe",
|
|
83
|
+
};
|
|
84
|
+
const moderatorRoles = await Promise.all(input.moderatorRoleIds.map((roleId) => guild.roles.fetch(roleId, { force: true }).catch(() => null)));
|
|
85
|
+
const revision = configurationRevision(channel, null, input.moderatorRoleIds, input.moderatorUserIds);
|
|
63
86
|
if (moderatorRoles.some((role) => role === null))
|
|
64
87
|
return {
|
|
65
88
|
outcome: "role_missing",
|
|
@@ -74,7 +97,8 @@ export class DiscordViolationThreadAdapter {
|
|
|
74
97
|
const missingPermissions = VIOLATION_THREAD_REQUIRED_PERMISSIONS.filter((name) => name === "MANAGE_ROLES"
|
|
75
98
|
? !botMember?.permissions.has(PermissionFlagsBits.ManageRoles)
|
|
76
99
|
: !permissions?.has(PERMISSION_FLAGS[name]));
|
|
77
|
-
if (missingPermissions.
|
|
100
|
+
if (missingPermissions.includes("MANAGE_ROLES") ||
|
|
101
|
+
missingPermissions.includes("MANAGE_CHANNELS"))
|
|
78
102
|
return {
|
|
79
103
|
outcome: "denied",
|
|
80
104
|
configurationRevision: revision,
|
|
@@ -87,24 +111,28 @@ export class DiscordViolationThreadAdapter {
|
|
|
87
111
|
outcome: "allowed",
|
|
88
112
|
configurationRevision: revision,
|
|
89
113
|
accessRole: null,
|
|
90
|
-
missingPermissions
|
|
91
|
-
requiredChanges: [
|
|
114
|
+
missingPermissions,
|
|
115
|
+
requiredChanges: [
|
|
116
|
+
"create_access_role",
|
|
117
|
+
...requiredConfigurationChanges(channel, guild, null, moderatorRoles),
|
|
118
|
+
...notificationUsers.requiredChanges,
|
|
119
|
+
],
|
|
92
120
|
};
|
|
93
121
|
}
|
|
94
122
|
const accessRole = await guild.roles
|
|
95
|
-
.fetch(input.accessRoleId)
|
|
123
|
+
.fetch(input.accessRoleId, { force: true })
|
|
96
124
|
.catch(() => null);
|
|
97
125
|
if (!accessRole)
|
|
98
126
|
return {
|
|
99
127
|
outcome: "role_missing",
|
|
100
|
-
configurationRevision: configurationRevision(channel, null, input.moderatorRoleIds),
|
|
128
|
+
configurationRevision: configurationRevision(channel, null, input.moderatorRoleIds, input.moderatorUserIds),
|
|
101
129
|
accessRole: null,
|
|
102
130
|
missingPermissions: [],
|
|
103
131
|
requiredChanges: [],
|
|
104
132
|
safeCode: "access_role_missing",
|
|
105
133
|
};
|
|
106
134
|
const accessInspection = await inspectAccessRole(guild, channel.id, accessRole);
|
|
107
|
-
const revision = configurationRevision(channel, accessRole, input.moderatorRoleIds);
|
|
135
|
+
const revision = configurationRevision(channel, accessRole, input.moderatorRoleIds, input.moderatorUserIds);
|
|
108
136
|
if (accessInspection.outcome !== "safe")
|
|
109
137
|
return {
|
|
110
138
|
outcome: "unsafe_access_role",
|
|
@@ -124,7 +152,7 @@ export class DiscordViolationThreadAdapter {
|
|
|
124
152
|
requiredChanges: [],
|
|
125
153
|
safeCode: "moderator_role_unsafe",
|
|
126
154
|
};
|
|
127
|
-
const moderatorRoles = await Promise.all(input.moderatorRoleIds.map((roleId) => guild.roles.fetch(roleId).catch(() => null)));
|
|
155
|
+
const moderatorRoles = await Promise.all(input.moderatorRoleIds.map((roleId) => guild.roles.fetch(roleId, { force: true }).catch(() => null)));
|
|
128
156
|
if (moderatorRoles.some((role) => role === null))
|
|
129
157
|
return {
|
|
130
158
|
outcome: "role_missing",
|
|
@@ -153,7 +181,10 @@ export class DiscordViolationThreadAdapter {
|
|
|
153
181
|
configurationRevision: revision,
|
|
154
182
|
accessRole: accessInspection,
|
|
155
183
|
missingPermissions,
|
|
156
|
-
requiredChanges:
|
|
184
|
+
requiredChanges: [
|
|
185
|
+
...requiredConfigurationChanges(channel, guild, accessRole, moderatorRoles),
|
|
186
|
+
...notificationUsers.requiredChanges,
|
|
187
|
+
],
|
|
157
188
|
};
|
|
158
189
|
}
|
|
159
190
|
async createAccessRole(input) {
|
|
@@ -226,17 +257,27 @@ export class DiscordViolationThreadAdapter {
|
|
|
226
257
|
if (current.outcome !== "allowed" ||
|
|
227
258
|
current.configurationRevision !== input.expectedConfigurationRevision)
|
|
228
259
|
return { outcome: "stale_inspection" };
|
|
260
|
+
if (current.missingPermissions.includes("MANAGE_CHANNELS"))
|
|
261
|
+
return { outcome: "missing_permission", safeCode: "manage_channels" };
|
|
229
262
|
const guild = this.client.guilds.cache.get(input.guildId);
|
|
230
|
-
const channel =
|
|
263
|
+
const channel = await guild.channels
|
|
264
|
+
.fetch(input.parentChannelId, { force: true })
|
|
265
|
+
.catch(() => null);
|
|
266
|
+
if (!channel || channel.type !== ChannelType.GuildText)
|
|
267
|
+
return { outcome: "channel_missing" };
|
|
268
|
+
const accessRole = await guild.roles
|
|
269
|
+
.fetch(input.accessRoleId, { force: true })
|
|
270
|
+
.catch(() => null);
|
|
271
|
+
if (!accessRole)
|
|
272
|
+
return { outcome: "role_missing" };
|
|
273
|
+
const notificationUsers = await inspectViolationNotificationUsers(guild, channel, input.moderatorUserIds ?? []);
|
|
274
|
+
if (notificationUsers.outcome !== "allowed")
|
|
275
|
+
return { outcome: "failed", safeCode: notificationUsers.safeCode };
|
|
276
|
+
if (configurationRevision(channel, accessRole, input.moderatorRoleIds, input.moderatorUserIds) !== input.expectedConfigurationRevision)
|
|
277
|
+
return { outcome: "stale_inspection" };
|
|
231
278
|
const botMember = guild.members.me;
|
|
232
279
|
try {
|
|
233
|
-
|
|
234
|
-
await channel.permissionOverwrites.edit(input.accessRoleId, {
|
|
235
|
-
ViewChannel: true,
|
|
236
|
-
ReadMessageHistory: true,
|
|
237
|
-
SendMessages: false,
|
|
238
|
-
SendMessagesInThreads: true,
|
|
239
|
-
}, { reason: auditReason(input.operationKey) });
|
|
280
|
+
// Retain the bot's access before hiding a previously public parent.
|
|
240
281
|
await channel.permissionOverwrites.edit(botMember, {
|
|
241
282
|
ViewChannel: true,
|
|
242
283
|
ReadMessageHistory: true,
|
|
@@ -245,6 +286,12 @@ export class DiscordViolationThreadAdapter {
|
|
|
245
286
|
ManageThreads: true,
|
|
246
287
|
ManageChannels: true,
|
|
247
288
|
}, { reason: auditReason(input.operationKey) });
|
|
289
|
+
await channel.permissionOverwrites.edit(input.accessRoleId, {
|
|
290
|
+
ViewChannel: true,
|
|
291
|
+
ReadMessageHistory: true,
|
|
292
|
+
SendMessages: false,
|
|
293
|
+
SendMessagesInThreads: true,
|
|
294
|
+
}, { reason: auditReason(input.operationKey) });
|
|
248
295
|
for (const roleId of input.moderatorRoleIds)
|
|
249
296
|
await channel.permissionOverwrites.edit(roleId, {
|
|
250
297
|
ViewChannel: true,
|
|
@@ -252,6 +299,12 @@ export class DiscordViolationThreadAdapter {
|
|
|
252
299
|
SendMessagesInThreads: true,
|
|
253
300
|
ManageThreads: true,
|
|
254
301
|
}, { reason: auditReason(input.operationKey) });
|
|
302
|
+
if (!(await configureViolationNotificationUsers(channel, notificationUsers.members, () => input.revalidate(), auditReason(input.operationKey))))
|
|
303
|
+
return {
|
|
304
|
+
outcome: "ambiguous",
|
|
305
|
+
safeCode: "configuration_partially_applied",
|
|
306
|
+
};
|
|
307
|
+
await channel.permissionOverwrites.edit(guild.roles.everyone, { ViewChannel: false }, { reason: auditReason(input.operationKey) });
|
|
255
308
|
}
|
|
256
309
|
catch (error) {
|
|
257
310
|
return isDiscordStatus(error, 403)
|
|
@@ -296,6 +349,7 @@ export class DiscordViolationThreadAdapter {
|
|
|
296
349
|
};
|
|
297
350
|
const operationIdentity = `${input.guildId}:${input.parentChannelId}:${input.operationKey}`;
|
|
298
351
|
const marker = operationMarker(operationIdentity);
|
|
352
|
+
const caseSuffix = /-\d{3,16}$/u.exec(input.name.trim())?.[0];
|
|
299
353
|
const knownThreadId = this.#operationResources.get(operationIdentity);
|
|
300
354
|
if (knownThreadId) {
|
|
301
355
|
const known = await guild.channels.fetch(knownThreadId).catch(() => null);
|
|
@@ -303,7 +357,7 @@ export class DiscordViolationThreadAdapter {
|
|
|
303
357
|
return { outcome: "already_exists", threadId: known.id };
|
|
304
358
|
this.#operationResources.delete(operationIdentity);
|
|
305
359
|
}
|
|
306
|
-
const existing = await findPrivateThreadByMarker(parent, marker);
|
|
360
|
+
const existing = await findPrivateThreadByMarker(parent, marker, caseSuffix, this.client.user?.id);
|
|
307
361
|
if (existing === UNKNOWN_THREAD)
|
|
308
362
|
return { outcome: "ambiguous", safeCode: "idempotency_lookup_failed" };
|
|
309
363
|
if (existing) {
|
|
@@ -314,7 +368,7 @@ export class DiscordViolationThreadAdapter {
|
|
|
314
368
|
return { outcome: "no_longer_required" };
|
|
315
369
|
try {
|
|
316
370
|
const thread = await parent.threads.create({
|
|
317
|
-
name: markedName(input.name, marker),
|
|
371
|
+
name: caseSuffix ? input.name.trim() : markedName(input.name, marker),
|
|
318
372
|
type: ChannelType.PrivateThread,
|
|
319
373
|
invitable: false,
|
|
320
374
|
reason: auditReason(input.operationKey),
|
|
@@ -342,10 +396,12 @@ export class DiscordViolationThreadAdapter {
|
|
|
342
396
|
if (!thread)
|
|
343
397
|
return { outcome: "thread_missing" };
|
|
344
398
|
try {
|
|
345
|
-
const message = await thread.send(
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
399
|
+
const message = await thread.send(input.presentation
|
|
400
|
+
? violationLayoutOpening(input.response, input.presentation)
|
|
401
|
+
: toReplyOptions({
|
|
402
|
+
...input.response,
|
|
403
|
+
ephemeral: false,
|
|
404
|
+
}));
|
|
349
405
|
this.#remember(this.#operationMessages, operationIdentity, message.id);
|
|
350
406
|
return { outcome: "sent", messageId: message.id };
|
|
351
407
|
}
|
|
@@ -355,6 +411,8 @@ export class DiscordViolationThreadAdapter {
|
|
|
355
411
|
}
|
|
356
412
|
async close(input) {
|
|
357
413
|
validateThreadInput(input);
|
|
414
|
+
if (input.deleteThread)
|
|
415
|
+
return this.#closeAndDelete(input);
|
|
358
416
|
const thread = await this.#privateThread(input.guildId, input.threadId);
|
|
359
417
|
if (!thread)
|
|
360
418
|
return { outcome: "thread_missing" };
|
|
@@ -374,6 +432,42 @@ export class DiscordViolationThreadAdapter {
|
|
|
374
432
|
return threadMutationFailure(error);
|
|
375
433
|
}
|
|
376
434
|
}
|
|
435
|
+
async #closeAndDelete(input) {
|
|
436
|
+
if (!input.parentChannelId)
|
|
437
|
+
return {
|
|
438
|
+
outcome: "missing_permission",
|
|
439
|
+
safeCode: "thread_binding_missing",
|
|
440
|
+
};
|
|
441
|
+
validateSnowflake(input.parentChannelId);
|
|
442
|
+
try {
|
|
443
|
+
return await closeAndDeleteViolationThread(this.client, input, auditReason(input.operationKey), () => safelyRevalidate(() => input.revalidate()));
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
return threadMutationFailure(error);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async editControls(input) {
|
|
450
|
+
validateThreadInput(input);
|
|
451
|
+
validateSnowflake(input.messageId);
|
|
452
|
+
const thread = await this.#privateThread(input.guildId, input.threadId);
|
|
453
|
+
if (!thread)
|
|
454
|
+
return { outcome: "failed", safeCode: "thread_missing" };
|
|
455
|
+
try {
|
|
456
|
+
const message = await thread.messages.fetch(input.messageId);
|
|
457
|
+
if (message.author.id !== this.client.user?.id)
|
|
458
|
+
return { outcome: "failed", safeCode: "control_owner_mismatch" };
|
|
459
|
+
if (!(await safelyRevalidate(() => input.revalidate())))
|
|
460
|
+
return { outcome: "failed", safeCode: "control_access_changed" };
|
|
461
|
+
await message.edit({
|
|
462
|
+
allowedMentions: { parse: [] },
|
|
463
|
+
components: violationLayoutControls(message.components.map((component) => component.toJSON()), input.buttons),
|
|
464
|
+
});
|
|
465
|
+
return { outcome: "updated" };
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
return { outcome: "failed", safeCode: "control_edit_failed" };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
377
471
|
async reconcile(input) {
|
|
378
472
|
validateSnowflake(input.guildId);
|
|
379
473
|
validateSnowflake(input.parentChannelId);
|
|
@@ -387,8 +481,12 @@ export class DiscordViolationThreadAdapter {
|
|
|
387
481
|
try {
|
|
388
482
|
const channel = await guild.channels.fetch(input.threadId);
|
|
389
483
|
if (!isOwnedPrivateThread(channel, input.guildId) ||
|
|
390
|
-
channel.parentId !== input.parentChannelId
|
|
391
|
-
|
|
484
|
+
channel.parentId !== input.parentChannelId ||
|
|
485
|
+
(input.requireBotOwner &&
|
|
486
|
+
(!this.client.user?.id || channel.ownerId !== this.client.user.id)))
|
|
487
|
+
return channel
|
|
488
|
+
? { outcome: "unknown", safeCode: "thread_binding_mismatch" }
|
|
489
|
+
: { outcome: "absent" };
|
|
392
490
|
return {
|
|
393
491
|
outcome: "exists",
|
|
394
492
|
threadId: channel.id,
|
|
@@ -409,23 +507,37 @@ export class DiscordViolationThreadAdapter {
|
|
|
409
507
|
const thread = await this.#privateThread(input.guildId, input.threadId);
|
|
410
508
|
if (!guild || !thread)
|
|
411
509
|
return { outcome: "thread_missing" };
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
510
|
+
let member;
|
|
511
|
+
try {
|
|
512
|
+
// Singular role grants return a clone; without GuildMembers updates the
|
|
513
|
+
// cached member can still lack the just-granted violation access role.
|
|
514
|
+
member = await guild.members.fetch({
|
|
515
|
+
user: input.memberUserId,
|
|
516
|
+
force: true,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
return isDiscordStatus(error, 404)
|
|
521
|
+
? { outcome: "member_missing" }
|
|
522
|
+
: threadMutationFailure(error);
|
|
523
|
+
}
|
|
417
524
|
const parent = thread.parent;
|
|
418
525
|
const memberPermissions = parent?.permissionsFor(member);
|
|
419
|
-
if (
|
|
420
|
-
!memberPermissions
|
|
526
|
+
if (operation === "add" &&
|
|
527
|
+
(!memberPermissions?.has(PermissionFlagsBits.ViewChannel) ||
|
|
528
|
+
!memberPermissions.has(PermissionFlagsBits.SendMessagesInThreads)))
|
|
421
529
|
return {
|
|
422
530
|
outcome: "missing_permission",
|
|
423
531
|
safeCode: "member_parent_access",
|
|
424
532
|
};
|
|
425
533
|
try {
|
|
426
534
|
const present = await thread.members
|
|
427
|
-
.fetch(input.memberUserId)
|
|
428
|
-
.catch(() =>
|
|
535
|
+
.fetch({ member: input.memberUserId, force: true, withMember: false })
|
|
536
|
+
.catch((error) => {
|
|
537
|
+
if (isDiscordStatus(error, 404))
|
|
538
|
+
return null;
|
|
539
|
+
throw error;
|
|
540
|
+
});
|
|
429
541
|
if ((operation === "add" && present) ||
|
|
430
542
|
(operation === "remove" && !present))
|
|
431
543
|
return { outcome: "already_exists", threadId: thread.id };
|
|
@@ -468,6 +580,8 @@ export class DiscordViolationEvidenceDeliveryAdapter {
|
|
|
468
580
|
}
|
|
469
581
|
async deliverEvidence(input) {
|
|
470
582
|
validateThreadInput(input);
|
|
583
|
+
if (input.openingMessageId)
|
|
584
|
+
validateSnowflake(input.openingMessageId);
|
|
471
585
|
const operationIdentity = `${input.guildId}:${input.threadId}:evidence:${input.operationKey}`;
|
|
472
586
|
const known = this.#operationMessages.get(operationIdentity);
|
|
473
587
|
if (known)
|
|
@@ -488,11 +602,25 @@ export class DiscordViolationEvidenceDeliveryAdapter {
|
|
|
488
602
|
: "evidence_unavailable",
|
|
489
603
|
};
|
|
490
604
|
try {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
605
|
+
if (input.openingMessageId) {
|
|
606
|
+
const opening = await channel.messages.fetch(input.openingMessageId);
|
|
607
|
+
if (opening.author.id !== this.client.user?.id)
|
|
608
|
+
return { outcome: "evidence_mismatch" };
|
|
609
|
+
const components = violationLayoutEvidence(opening.components.map((component) => component.toJSON()), input.evidenceReference, evidence.content, input.context);
|
|
610
|
+
if (components) {
|
|
611
|
+
await opening.edit({ components, allowedMentions: { parse: [] } });
|
|
612
|
+
rememberOperation(this.#operationMessages, operationIdentity, opening.id);
|
|
613
|
+
this.evidence.discard(input.evidenceReference);
|
|
614
|
+
return { outcome: "sent", messageId: opening.id };
|
|
615
|
+
}
|
|
616
|
+
const summary = violationLayoutEvidence(opening.components.map((component) => component.toJSON()), input.evidenceReference, evidence.content, input.context, true);
|
|
617
|
+
if (summary)
|
|
618
|
+
await opening.edit({
|
|
619
|
+
components: summary,
|
|
620
|
+
allowedMentions: { parse: [] },
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
const message = await channel.send(violationEvidenceMessage(input.evidenceReference, evidence.content, input.context));
|
|
496
624
|
rememberOperation(this.#operationMessages, operationIdentity, message.id);
|
|
497
625
|
this.evidence.discard(input.evidenceReference);
|
|
498
626
|
return { outcome: "sent", messageId: message.id };
|
|
@@ -573,9 +701,10 @@ function requiredConfigurationChanges(channel, guild, accessRole, moderatorRoles
|
|
|
573
701
|
if (!overwriteDenies(channel, guild.roles.everyone.id, PermissionFlagsBits.ViewChannel))
|
|
574
702
|
changes.push("deny_everyone_view_channel");
|
|
575
703
|
for (const [name, permission] of Object.entries(ACCESS_PERMISSION_FLAGS))
|
|
576
|
-
if (!overwriteAllows(channel, accessRole.id, permission))
|
|
704
|
+
if (!accessRole || !overwriteAllows(channel, accessRole.id, permission))
|
|
577
705
|
changes.push(`allow_access_role_${name.toLowerCase()}`);
|
|
578
|
-
if (!
|
|
706
|
+
if (!accessRole ||
|
|
707
|
+
!overwriteDenies(channel, accessRole.id, PermissionFlagsBits.SendMessages))
|
|
579
708
|
changes.push("deny_access_role_parent_messages");
|
|
580
709
|
const botMemberId = guild.members.me.id;
|
|
581
710
|
for (const name of [
|
|
@@ -613,7 +742,7 @@ function rememberOperation(cache, key, value) {
|
|
|
613
742
|
cache.delete(oldest);
|
|
614
743
|
}
|
|
615
744
|
}
|
|
616
|
-
function configurationRevision(channel, role, moderatorRoleIds) {
|
|
745
|
+
function configurationRevision(channel, role, moderatorRoleIds, moderatorUserIds = []) {
|
|
617
746
|
const overwrites = [...channel.permissionOverwrites.cache.values()]
|
|
618
747
|
.map((overwrite) => ({
|
|
619
748
|
id: overwrite.id,
|
|
@@ -634,6 +763,7 @@ function configurationRevision(channel, role, moderatorRoleIds) {
|
|
|
634
763
|
}
|
|
635
764
|
: null,
|
|
636
765
|
moderatorRoleIds: [...moderatorRoleIds].sort(),
|
|
766
|
+
moderatorUserIds: [...moderatorUserIds].sort(),
|
|
637
767
|
overwrites,
|
|
638
768
|
}))
|
|
639
769
|
.digest("hex");
|
|
@@ -699,14 +829,22 @@ function isOwnedPrivateThread(channel, guildId, parentChannelId) {
|
|
|
699
829
|
channel.guild.id === guildId &&
|
|
700
830
|
(!parentChannelId || channel.parentId === parentChannelId));
|
|
701
831
|
}
|
|
702
|
-
async function findPrivateThreadByMarker(parent, marker) {
|
|
703
|
-
|
|
704
|
-
|
|
832
|
+
async function findPrivateThreadByMarker(parent, marker, caseSuffix, botUserId) {
|
|
833
|
+
// Guild case numbers never repeat. The fixed, padded numeric suffix survives
|
|
834
|
+
// prefix changes without exposing the operation hash in the thread name.
|
|
835
|
+
// Restrict the new-name recovery path to private threads created by this bot.
|
|
836
|
+
// Legacy operation markers remain readable for deliveries created before this change.
|
|
837
|
+
const matches = (thread) => thread.name.endsWith(`[${marker}]`) ||
|
|
838
|
+
Boolean(caseSuffix &&
|
|
839
|
+
botUserId &&
|
|
840
|
+
thread.ownerId === botUserId &&
|
|
841
|
+
thread.name.endsWith(caseSuffix));
|
|
842
|
+
const cached = parent.threads.cache.find((thread) => thread.type === ChannelType.PrivateThread && matches(thread));
|
|
705
843
|
if (cached)
|
|
706
844
|
return cached.id;
|
|
707
845
|
try {
|
|
708
846
|
const active = await parent.threads.fetchActive();
|
|
709
|
-
const activeMatch = active.threads.find((thread) => thread.type === ChannelType.PrivateThread && matches(thread
|
|
847
|
+
const activeMatch = active.threads.find((thread) => thread.type === ChannelType.PrivateThread && matches(thread));
|
|
710
848
|
if (activeMatch)
|
|
711
849
|
return activeMatch.id;
|
|
712
850
|
let before;
|
|
@@ -716,7 +854,7 @@ async function findPrivateThreadByMarker(parent, marker) {
|
|
|
716
854
|
limit: 100,
|
|
717
855
|
...(before ? { before } : {}),
|
|
718
856
|
});
|
|
719
|
-
const match = archived.threads.find((thread) => matches(thread
|
|
857
|
+
const match = archived.threads.find((thread) => thread.type === ChannelType.PrivateThread && matches(thread));
|
|
720
858
|
if (match)
|
|
721
859
|
return match.id;
|
|
722
860
|
if (!archived.hasMore)
|
package/dist/events.js
CHANGED
|
@@ -7,6 +7,7 @@ import { classifyGuildMessage, unavailableGuildMessageClassification, } from "./
|
|
|
7
7
|
import { canAccessModule } from "./premium-access.js";
|
|
8
8
|
import { fromDiscordAction, toRuleSnapshot, } from "./discord-auto-moderation-service.js";
|
|
9
9
|
import { PrivilegedMessageMetadataCache } from "./privileged-message-metadata-cache.js";
|
|
10
|
+
import { DiscordAutoModerationExecutionIds } from "./discord-automod-execution-ids.js";
|
|
10
11
|
export function availabilityAwareInstallations(services, modulesById) {
|
|
11
12
|
const installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
12
13
|
const candidate = typeof services.has === "function" &&
|
|
@@ -65,6 +66,7 @@ function privilegedSource(message) {
|
|
|
65
66
|
}
|
|
66
67
|
export function installModuleEvents(client, modules, logger, services, options) {
|
|
67
68
|
const runtimeProfile = options?.runtimeProfile ?? "hosted";
|
|
69
|
+
const autoModerationExecutionIds = new DiscordAutoModerationExecutionIds();
|
|
68
70
|
const modulesById = new Map(modules.map((module) => [module.manifest.id, module]));
|
|
69
71
|
const privilegedMessageContributions = modules.flatMap((module) => (module.events?.privilegedMessageCreate ?? [])
|
|
70
72
|
.filter((contribution) => !contribution.featureId || runtimeProfile === "self-hosted")
|
|
@@ -342,7 +344,11 @@ export function installModuleEvents(client, modules, logger, services, options)
|
|
|
342
344
|
}
|
|
343
345
|
if (autoModerationActionContributions.length > 0) {
|
|
344
346
|
client.on(Events.AutoModerationActionExecution, (execution) => {
|
|
345
|
-
|
|
347
|
+
// Correlate at Gateway receipt, not after earlier actions/thread setup
|
|
348
|
+
// have held the per-guild queue longer than the correlation window.
|
|
349
|
+
const executionId = autoModerationExecutionIds.identify(execution);
|
|
350
|
+
const occurredAt = new Date();
|
|
351
|
+
queue.enqueue(execution.guild.id, () => handleAutoModerationAction(execution, executionId, occurredAt));
|
|
346
352
|
});
|
|
347
353
|
}
|
|
348
354
|
if (messageDeleteContributions.length > 0) {
|
|
@@ -542,18 +548,9 @@ export function installModuleEvents(client, modules, logger, services, options)
|
|
|
542
548
|
}
|
|
543
549
|
}
|
|
544
550
|
}
|
|
545
|
-
async function handleAutoModerationAction(execution) {
|
|
551
|
+
async function handleAutoModerationAction(execution, executionId, occurredAt) {
|
|
546
552
|
const installations = availabilityAwareInstallations(services, modulesById);
|
|
547
553
|
const action = fromDiscordAction(execution.action);
|
|
548
|
-
const executionId = createHash("sha256")
|
|
549
|
-
.update(JSON.stringify([
|
|
550
|
-
execution.guild.id,
|
|
551
|
-
execution.ruleId,
|
|
552
|
-
execution.userId,
|
|
553
|
-
execution.channelId,
|
|
554
|
-
execution.messageId,
|
|
555
|
-
]))
|
|
556
|
-
.digest("hex");
|
|
557
554
|
const evidenceReference = options?.evidenceStore && execution.channelId && execution.content
|
|
558
555
|
? options.evidenceStore.capture({
|
|
559
556
|
source: "discord_auto_moderation",
|
|
@@ -580,7 +577,7 @@ export function installModuleEvents(client, modules, logger, services, options)
|
|
|
580
577
|
action,
|
|
581
578
|
executionId,
|
|
582
579
|
evidenceReference,
|
|
583
|
-
occurredAt
|
|
580
|
+
occurredAt,
|
|
584
581
|
services,
|
|
585
582
|
});
|
|
586
583
|
}
|
|
@@ -1685,7 +1682,10 @@ export class DiscordMemberRoleService {
|
|
|
1685
1682
|
const guild = await this.client.guilds.fetch(input.guildId);
|
|
1686
1683
|
if (guild.id !== input.guildId)
|
|
1687
1684
|
throw new Error("Discord guild validation failed");
|
|
1688
|
-
const member = await guild.members.fetch(
|
|
1685
|
+
const member = await guild.members.fetch({
|
|
1686
|
+
user: input.memberUserId,
|
|
1687
|
+
force: true,
|
|
1688
|
+
});
|
|
1689
1689
|
if (member.id !== input.memberUserId || member.guild.id !== guild.id)
|
|
1690
1690
|
throw new Error("Discord member validation failed");
|
|
1691
1691
|
return { guild, member };
|
|
@@ -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" ||
|
|
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)
|