@helyx/bot 1.3.1 → 1.4.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 +77 -28
- package/README.md +65 -11
- package/dist/cloud-connector.d.ts +11 -4
- package/dist/cloud-connector.js +52 -15
- package/dist/cloud-control-dispatcher.d.ts +8 -3
- package/dist/cloud-control-dispatcher.js +62 -4
- package/dist/control-errors.d.ts +4 -0
- package/dist/control-errors.js +8 -0
- package/dist/control-server-schemas.d.ts +84 -0
- package/dist/control-server-schemas.js +101 -0
- package/dist/control-server.d.ts +21 -7
- package/dist/control-server.js +88 -132
- package/dist/core-retention.d.ts +26 -0
- package/dist/core-retention.js +49 -0
- package/dist/creator-guild-join-service.d.ts +25 -0
- package/dist/creator-guild-join-service.js +218 -0
- package/dist/creator-invite-service.d.ts +37 -0
- package/dist/creator-invite-service.js +686 -0
- package/dist/creator-onboarding-control.d.ts +12 -0
- package/dist/creator-onboarding-control.js +65 -0
- package/dist/creator-onboarding-platform.d.ts +20 -0
- package/dist/creator-onboarding-platform.js +58 -0
- package/dist/dashboard-action-control.d.ts +2 -2
- package/dist/dashboard-action-control.js +2 -3
- package/dist/database-bootstrap.d.ts +36 -0
- package/dist/database-bootstrap.js +101 -0
- package/dist/developer-operations-module.d.ts +1 -0
- package/dist/developer-operations-module.js +34 -16
- package/dist/discord-owned-message-service.d.ts +1 -0
- package/dist/discord-owned-message-service.js +3 -0
- package/dist/guild-command-read-service.d.ts +8 -3
- package/dist/guild-command-read-service.js +14 -7
- package/dist/guild-command-release.d.ts +15 -8
- package/dist/guild-command-release.js +10 -3
- package/dist/guild-command-worker.d.ts +17 -4
- package/dist/guild-command-worker.js +17 -6
- package/dist/guilds.d.ts +24 -3
- package/dist/guilds.js +185 -64
- package/dist/hosted-server-moderation.d.ts +2 -2
- package/dist/hosted-server-moderation.js +10 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +200 -82
- package/dist/interactions.js +16 -3
- package/dist/module-configuration.d.ts +1 -1
- package/dist/module-configuration.js +5 -2
- package/dist/module-logging.d.ts +8 -3
- package/dist/module-logging.js +13 -7
- package/dist/modules.d.ts +1 -0
- package/dist/modules.js +8 -1
- package/dist/mysql-runtime-persistence.d.ts +46 -0
- package/dist/mysql-runtime-persistence.js +91 -0
- package/dist/oauth-module.js +2 -2
- package/dist/oauth-service.d.ts +7 -0
- package/dist/oauth-service.js +3 -0
- package/dist/scheduled-task-runner.d.ts +9 -85
- package/dist/scheduled-task-runner.js +1 -1
- package/dist/shard-contracts.d.ts +135 -0
- package/dist/shard-contracts.js +13 -1
- package/dist/shard-router.d.ts +2 -1
- package/dist/shard-router.js +7 -0
- package/dist/shard-supervisor.js +43 -9
- package/dist/shard-worker.d.ts +5 -2
- package/dist/shard-worker.js +41 -5
- package/dist/supervisor-cloud-directory.d.ts +9 -3
- package/dist/supervisor-cloud-directory.js +8 -12
- package/dist/trivia-data-provider.d.ts +11 -0
- package/dist/trivia-data-provider.js +46 -26
- package/dist/verification-service.d.ts +2 -2
- package/dist/verification-service.js +6 -3
- package/documentation/README.md +5 -3
- package/documentation/examples/.env.example +15 -11
- package/documentation/guides/getting-started.md +29 -12
- package/documentation/guides/privacy-and-support.md +6 -5
- package/documentation/guides/self-hosting.md +16 -11
- package/documentation/modules/auto-moderation.md +7 -5
- package/documentation/modules/building-a-module.md +48 -2
- package/documentation/modules/moderation.md +5 -5
- package/documentation/modules/publishing-a-module.md +6 -4
- package/documentation/modules/testing-a-module.md +39 -4
- package/documentation/reference/environment.md +43 -9
- package/documentation/runbooks/backup-and-recovery.md +26 -7
- package/documentation/runbooks/installation-and-upgrades.md +97 -27
- package/package.json +20 -16
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { GuildCommandRepository, type GuildCommandHealth, type GuildCommandRequestRecord } from "@helyx/database";
|
|
1
|
+
import { GuildCommandRepository, InstallationRepository, ModuleAvailabilityRepository, type GuildCommandHealth, type GuildCommandRequestRecord } from "@helyx/database";
|
|
2
|
+
import { type GuildCommandRepository as MysqlGuildCommandRepository, type InstallationRepository as MysqlInstallationRepository, type ModuleAvailabilityRepository as MysqlModuleAvailabilityRepository } from "@helyx/database-mysql";
|
|
2
3
|
import type { Logger, ModuleDefinition } from "@helyx/sdk";
|
|
3
4
|
import type { Pool } from "pg";
|
|
4
5
|
import { type GuildCommandDiff, type GuildCommandProjection } from "./guild-command-projection.js";
|
|
@@ -9,10 +10,22 @@ export interface GuildCommandWorkerHealth extends GuildCommandHealth {
|
|
|
9
10
|
workerRunning: boolean;
|
|
10
11
|
activeCount: number;
|
|
11
12
|
}
|
|
13
|
+
type WorkerCommandRepository = Pick<GuildCommandRepository, "claimDue" | "getHealth" | "heartbeat" | "getState" | "completeDryRun" | "defer" | "markBlocked" | "markFailed" | "completeLive" | "isLeaseCurrent" | "reserveCreations" | "settleCreation"> & Pick<MysqlGuildCommandRepository, "claimDue" | "getHealth" | "heartbeat" | "getState" | "completeDryRun" | "defer" | "markBlocked" | "markFailed" | "completeLive" | "isLeaseCurrent" | "reserveCreations" | "settleCreation">;
|
|
14
|
+
export interface GuildCommandWorkerRepositories {
|
|
15
|
+
commands: WorkerCommandRepository;
|
|
16
|
+
installations: Pick<InstallationRepository, "listEnabledModules"> & Pick<MysqlInstallationRepository, "listEnabledModules">;
|
|
17
|
+
availability: Pick<ModuleAvailabilityRepository, "list"> & Pick<MysqlModuleAvailabilityRepository, "list">;
|
|
18
|
+
}
|
|
19
|
+
type WorkerPersistence = {
|
|
20
|
+
database: Pool;
|
|
21
|
+
repositories?: never;
|
|
22
|
+
} | {
|
|
23
|
+
database?: never;
|
|
24
|
+
repositories: GuildCommandWorkerRepositories;
|
|
25
|
+
};
|
|
12
26
|
export declare class GuildCommandWorker {
|
|
13
27
|
#private;
|
|
14
28
|
constructor(input: {
|
|
15
|
-
database: Pool;
|
|
16
29
|
modules: readonly ModuleDefinition[];
|
|
17
30
|
logger: Logger;
|
|
18
31
|
workerId?: string;
|
|
@@ -24,13 +37,13 @@ export declare class GuildCommandWorker {
|
|
|
24
37
|
claimLimit?: number;
|
|
25
38
|
concurrency?: number;
|
|
26
39
|
stopTimeoutMs?: number;
|
|
27
|
-
});
|
|
40
|
+
} & WorkerPersistence);
|
|
28
41
|
start(): void;
|
|
29
42
|
runOnce(): Promise<void>;
|
|
30
43
|
stop(): Promise<void>;
|
|
31
44
|
getHealth(): Promise<GuildCommandWorkerHealth>;
|
|
32
45
|
}
|
|
33
|
-
type LiveRepository = Pick<GuildCommandRepository, "completeLive" | "isLeaseCurrent" | "reserveCreations" | "settleCreation">;
|
|
46
|
+
type LiveRepository = Pick<GuildCommandRepository, "completeLive" | "isLeaseCurrent" | "reserveCreations" | "settleCreation"> & Pick<MysqlGuildCommandRepository, "completeLive" | "isLeaseCurrent" | "reserveCreations" | "settleCreation">;
|
|
34
47
|
export declare function reconcileLiveGuildCommands(input: {
|
|
35
48
|
repository: LiveRepository;
|
|
36
49
|
discord: Pick<GuildCommandDiscordClient, "observe" | "replace">;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { GuildCommandBudgetExceededError, GuildCommandRepository, InstallationRepository, ModuleAvailabilityRepository, } from "@helyx/database";
|
|
2
|
+
import { GuildCommandBudgetExceededError as MysqlGuildCommandBudgetExceededError, } from "@helyx/database-mysql";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { diffDryRunGuildCommands, diffGuildCommands, projectGuildCommands, } from "./guild-command-projection.js";
|
|
4
5
|
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
@@ -8,7 +9,8 @@ const DEFAULT_CONCURRENCY = 2;
|
|
|
8
9
|
const DEFAULT_STOP_TIMEOUT_MS = 5_000;
|
|
9
10
|
export class GuildCommandWorker {
|
|
10
11
|
#repository;
|
|
11
|
-
#
|
|
12
|
+
#installations;
|
|
13
|
+
#availability;
|
|
12
14
|
#modules;
|
|
13
15
|
#logger;
|
|
14
16
|
#workerId;
|
|
@@ -28,8 +30,16 @@ export class GuildCommandWorker {
|
|
|
28
30
|
constructor(input) {
|
|
29
31
|
if (input.fencingToken < 1n)
|
|
30
32
|
throw new Error("Guild command worker fencing token must be positive");
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
const repositories = input.database
|
|
34
|
+
? {
|
|
35
|
+
commands: new GuildCommandRepository(input.database),
|
|
36
|
+
installations: new InstallationRepository(input.database),
|
|
37
|
+
availability: new ModuleAvailabilityRepository(input.database),
|
|
38
|
+
}
|
|
39
|
+
: input.repositories;
|
|
40
|
+
this.#repository = repositories.commands;
|
|
41
|
+
this.#installations = repositories.installations;
|
|
42
|
+
this.#availability = repositories.availability;
|
|
33
43
|
this.#modules = input.modules;
|
|
34
44
|
this.#logger = input.logger;
|
|
35
45
|
this.#workerId = input.workerId ?? `guild-command:${randomUUID()}`;
|
|
@@ -145,8 +155,8 @@ export class GuildCommandWorker {
|
|
|
145
155
|
heartbeat.unref();
|
|
146
156
|
try {
|
|
147
157
|
const [enabled, availability, state] = await Promise.all([
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
this.#installations.listEnabledModules(request.guildId),
|
|
159
|
+
this.#availability.list(),
|
|
150
160
|
this.#repository.getState(request.applicationId, request.guildId),
|
|
151
161
|
]);
|
|
152
162
|
if (!state || state.desiredRevision !== request.requestedRevision) {
|
|
@@ -310,7 +320,8 @@ export async function reconcileLiveGuildCommands(input) {
|
|
|
310
320
|
})).ledgerId;
|
|
311
321
|
}
|
|
312
322
|
catch (error) {
|
|
313
|
-
if (error instanceof GuildCommandBudgetExceededError
|
|
323
|
+
if (error instanceof GuildCommandBudgetExceededError ||
|
|
324
|
+
error instanceof MysqlGuildCommandBudgetExceededError)
|
|
314
325
|
throw new GuildCommandLiveError("creation_budget_exhausted");
|
|
315
326
|
throw error;
|
|
316
327
|
}
|
package/dist/guilds.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type DatabasePool, type GuildShardOwnership } from "@helyx/database";
|
|
1
|
+
import { InstallationRepository, type DatabasePool, type GuildShardOwnership } from "@helyx/database";
|
|
2
|
+
import type { MysqlPool } from "@helyx/database-mysql";
|
|
2
3
|
import type { ModuleDefinition } from "@helyx/sdk";
|
|
3
4
|
import { type Client } from "discord.js";
|
|
4
5
|
import type { InteractionLogger } from "./interactions.js";
|
|
@@ -20,7 +21,27 @@ export interface GuildTrackingPreparation {
|
|
|
20
21
|
(readyClient: Client<true>): Promise<void>;
|
|
21
22
|
reconcile(reason: "ready" | "resume" | "periodic"): Promise<void>;
|
|
22
23
|
health(): GuildReconciliationHealth;
|
|
23
|
-
stop(): void
|
|
24
|
+
stop(): Promise<void>;
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
+
interface GuildObservation {
|
|
27
|
+
guildId: string;
|
|
28
|
+
ownerUserId: string;
|
|
29
|
+
ownerUsername?: string;
|
|
30
|
+
name: string;
|
|
31
|
+
modules: readonly ModuleDefinition[];
|
|
32
|
+
enabledByDefaultModuleIds: ReadonlySet<string>;
|
|
33
|
+
firstObservation: boolean;
|
|
34
|
+
applicationId?: string;
|
|
35
|
+
ownership?: GuildShardOwnership;
|
|
36
|
+
continueObservation?: () => boolean;
|
|
37
|
+
}
|
|
38
|
+
type ReconciliationRepository = Pick<InstallationRepository, "listActiveGuildIdsForOwnership" | "adoptGuildsForReconciliation" | "markGuildsRemovedForReconciliation">;
|
|
39
|
+
export interface GuildTrackingPersistence extends ReconciliationRepository {
|
|
40
|
+
readonly engine: "postgresql" | "mysql";
|
|
41
|
+
syncGuild(input: GuildObservation): Promise<void>;
|
|
42
|
+
markGuildRemoved(guildId: string, ownership: GuildShardOwnership | undefined, applicationId: string | undefined): Promise<boolean>;
|
|
43
|
+
}
|
|
44
|
+
export declare function createMysqlGuildTrackingPersistence(database: MysqlPool): Promise<GuildTrackingPersistence>;
|
|
45
|
+
export declare function installGuildTracking(client: Client, database: DatabasePool | GuildTrackingPersistence, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership, reconciliationIntervalMs?: number, shardCount?: number, applicationId?: string, hostedSafety?: HostedGuildSafety, hostedInviteGate?: HostedBotInviteGate, hostedOwnerObserver?: HostedGuildOwnerObserver | undefined): GuildTrackingPreparation;
|
|
46
|
+
export {};
|
|
26
47
|
//# sourceMappingURL=guilds.d.ts.map
|
package/dist/guilds.js
CHANGED
|
@@ -3,14 +3,122 @@ import { safeError } from "@helyx/core";
|
|
|
3
3
|
import { Events } from "discord.js";
|
|
4
4
|
const RECONCILIATION_PAGE_SIZE = 250;
|
|
5
5
|
const DEFAULT_RECONCILIATION_INTERVAL_MS = 15 * 60 * 1_000;
|
|
6
|
+
async function writeGuildObservation(installations, commands, input) {
|
|
7
|
+
const record = await installations.syncGuild({
|
|
8
|
+
guildId: input.guildId,
|
|
9
|
+
ownerUserId: input.ownerUserId,
|
|
10
|
+
...(input.ownerUsername ? { ownerUsername: input.ownerUsername } : {}),
|
|
11
|
+
name: input.name,
|
|
12
|
+
...(input.ownership ? { ownership: input.ownership } : {}),
|
|
13
|
+
});
|
|
14
|
+
const ownership = input.ownership;
|
|
15
|
+
if (ownership &&
|
|
16
|
+
(record.shardGeneration !== ownership.generation ||
|
|
17
|
+
record.shardDeploymentOrder !== (ownership.deploymentOrder ?? 1n) ||
|
|
18
|
+
record.shardSupervisorId !== ownership.supervisorId ||
|
|
19
|
+
record.shardId !== ownership.shardId ||
|
|
20
|
+
record.shardFencingToken !== ownership.fencingToken))
|
|
21
|
+
throw new Error("Guild observation was rejected by a newer shard owner");
|
|
22
|
+
if (input.firstObservation && input.applicationId)
|
|
23
|
+
await commands.ensureState({
|
|
24
|
+
applicationId: input.applicationId,
|
|
25
|
+
guildId: input.guildId,
|
|
26
|
+
setupState: "incomplete",
|
|
27
|
+
});
|
|
28
|
+
for (const module of input.modules) {
|
|
29
|
+
// First observations remain atomic; refreshes can stop between writes.
|
|
30
|
+
if (!input.firstObservation && input.continueObservation?.() === false)
|
|
31
|
+
return;
|
|
32
|
+
await installations.ensureModule({
|
|
33
|
+
guildId: input.guildId,
|
|
34
|
+
moduleId: module.manifest.id,
|
|
35
|
+
moduleVersion: module.manifest.version,
|
|
36
|
+
configurationSchemaVersion: module.configuration?.schemaVersion ?? 1,
|
|
37
|
+
enabledBy: input.ownerUserId,
|
|
38
|
+
enabledByDefault: input.enabledByDefaultModuleIds.has(module.manifest.id),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function createPostgresqlGuildTrackingPersistence(database) {
|
|
43
|
+
const repository = new InstallationRepository(database);
|
|
44
|
+
return {
|
|
45
|
+
engine: "postgresql",
|
|
46
|
+
async syncGuild(input) {
|
|
47
|
+
const persist = (connection) => writeGuildObservation(new InstallationRepository(connection), new GuildCommandRepository(connection), input);
|
|
48
|
+
if (input.firstObservation)
|
|
49
|
+
await withTransaction(database, persist);
|
|
50
|
+
else
|
|
51
|
+
await persist(database);
|
|
52
|
+
},
|
|
53
|
+
async markGuildRemoved(guildId, ownership, applicationId) {
|
|
54
|
+
const changed = await repository.markGuildRemoved(guildId, ownership);
|
|
55
|
+
if (changed && applicationId)
|
|
56
|
+
await new GuildCommandRepository(database).cancelGuildWork(applicationId, guildId);
|
|
57
|
+
return changed;
|
|
58
|
+
},
|
|
59
|
+
listActiveGuildIdsForOwnership: (input) => repository.listActiveGuildIdsForOwnership(input),
|
|
60
|
+
adoptGuildsForReconciliation: (input) => repository.adoptGuildsForReconciliation(input),
|
|
61
|
+
markGuildsRemovedForReconciliation: (input) => repository.markGuildsRemovedForReconciliation(input),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export async function createMysqlGuildTrackingPersistence(database) {
|
|
65
|
+
const { InstallationRepository: MysqlInstallationRepository, GuildCommandRepository: MysqlGuildCommandRepository, createMysqlQueryExecutor, withMysqlTransaction, } = await import("@helyx/database-mysql");
|
|
66
|
+
const repository = new MysqlInstallationRepository(database);
|
|
67
|
+
return {
|
|
68
|
+
engine: "mysql",
|
|
69
|
+
async syncGuild(input) {
|
|
70
|
+
if (input.ownership)
|
|
71
|
+
throw new Error("MySQL guild tracking does not support shard ownership");
|
|
72
|
+
if (input.firstObservation) {
|
|
73
|
+
await withMysqlTransaction(database, (connection) => {
|
|
74
|
+
const executor = createMysqlQueryExecutor(connection);
|
|
75
|
+
return writeGuildObservation(new MysqlInstallationRepository(executor), new MysqlGuildCommandRepository(executor), input);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
await writeGuildObservation(new MysqlInstallationRepository(database), new MysqlGuildCommandRepository(database), input);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
async markGuildRemoved(guildId, ownership, applicationId) {
|
|
83
|
+
if (ownership)
|
|
84
|
+
throw new Error("MySQL guild tracking does not support shard ownership");
|
|
85
|
+
return withMysqlTransaction(database, async (connection) => {
|
|
86
|
+
const executor = createMysqlQueryExecutor(connection);
|
|
87
|
+
const changed = await new MysqlInstallationRepository(executor).markGuildRemoved(guildId);
|
|
88
|
+
if (changed && applicationId)
|
|
89
|
+
await new MysqlGuildCommandRepository(executor).cancelGuildWork(applicationId, guildId);
|
|
90
|
+
return changed;
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
listActiveGuildIdsForOwnership: (input) => {
|
|
94
|
+
if (input.ownership)
|
|
95
|
+
throw new Error("MySQL guild tracking does not support shard ownership");
|
|
96
|
+
return repository.listActiveGuildIdsForOwnership(input);
|
|
97
|
+
},
|
|
98
|
+
adoptGuildsForReconciliation: () => {
|
|
99
|
+
throw new Error("MySQL guild tracking does not support shard ownership");
|
|
100
|
+
},
|
|
101
|
+
markGuildsRemovedForReconciliation: (input) => {
|
|
102
|
+
if (input.ownership)
|
|
103
|
+
throw new Error("MySQL guild tracking does not support shard ownership");
|
|
104
|
+
return repository.markGuildsRemovedForReconciliation(input);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
6
108
|
export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set(), ownership, reconciliationIntervalMs = DEFAULT_RECONCILIATION_INTERVAL_MS, shardCount, applicationId, hostedSafety, hostedInviteGate, hostedOwnerObserver = hostedSafety) {
|
|
7
109
|
if (!Number.isSafeInteger(reconciliationIntervalMs) ||
|
|
8
110
|
reconciliationIntervalMs < 60_000)
|
|
9
111
|
throw new Error("Guild reconciliation interval must be at least one minute");
|
|
10
|
-
const
|
|
112
|
+
const persistence = "engine" in database
|
|
113
|
+
? database
|
|
114
|
+
: createPostgresqlGuildTrackingPersistence(database);
|
|
115
|
+
if (persistence.engine === "mysql" && ownership)
|
|
116
|
+
throw new Error("MySQL guild tracking does not support shard ownership");
|
|
11
117
|
let prepared = false;
|
|
12
118
|
let stopped = false;
|
|
13
119
|
let reconciliation;
|
|
120
|
+
let stopPromise;
|
|
121
|
+
const inFlightOperations = new Set();
|
|
14
122
|
let timer;
|
|
15
123
|
const admissionRetries = new Map();
|
|
16
124
|
const inviteGateRejections = new Map();
|
|
@@ -25,46 +133,29 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
25
133
|
rejectedFences: 0,
|
|
26
134
|
unavailableGuilds: 0,
|
|
27
135
|
};
|
|
136
|
+
function trackOperation(operation) {
|
|
137
|
+
const settled = operation.then(() => undefined, () => undefined);
|
|
138
|
+
inFlightOperations.add(settled);
|
|
139
|
+
void settled.then(() => inFlightOperations.delete(settled));
|
|
140
|
+
return operation;
|
|
141
|
+
}
|
|
28
142
|
async function syncGuild(guild, firstObservation = false) {
|
|
29
143
|
const cachedOwner = guild.members?.cache.get(guild.ownerId)?.user ??
|
|
30
144
|
guild.client?.users.cache.get(guild.ownerId);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
record.shardFencingToken !== ownership.fencingToken))
|
|
46
|
-
throw new Error("Guild observation was rejected by a newer shard owner");
|
|
47
|
-
if (firstObservation && applicationId)
|
|
48
|
-
await new GuildCommandRepository(connection).ensureState({
|
|
49
|
-
applicationId,
|
|
50
|
-
guildId: guild.id,
|
|
51
|
-
setupState: "incomplete",
|
|
52
|
-
});
|
|
53
|
-
for (const module of modules) {
|
|
54
|
-
await installations.ensureModule({
|
|
55
|
-
guildId: guild.id,
|
|
56
|
-
moduleId: module.manifest.id,
|
|
57
|
-
moduleVersion: module.manifest.version,
|
|
58
|
-
configurationSchemaVersion: module.configuration?.schemaVersion ?? 1,
|
|
59
|
-
enabledBy: guild.ownerId,
|
|
60
|
-
enabledByDefault: enabledByDefaultModuleIds.has(module.manifest.id),
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
if (firstObservation)
|
|
65
|
-
await withTransaction(database, persist);
|
|
66
|
-
else
|
|
67
|
-
await persist(database);
|
|
145
|
+
await persistence.syncGuild({
|
|
146
|
+
guildId: guild.id,
|
|
147
|
+
ownerUserId: guild.ownerId,
|
|
148
|
+
...(cachedOwner ? { ownerUsername: cachedOwner.username } : {}),
|
|
149
|
+
name: guild.name,
|
|
150
|
+
modules,
|
|
151
|
+
enabledByDefaultModuleIds,
|
|
152
|
+
firstObservation,
|
|
153
|
+
continueObservation: () => !stopped,
|
|
154
|
+
...(applicationId ? { applicationId } : {}),
|
|
155
|
+
...(ownership ? { ownership } : {}),
|
|
156
|
+
});
|
|
157
|
+
if (stopped)
|
|
158
|
+
return;
|
|
68
159
|
if (cachedOwner && hostedOwnerObserver)
|
|
69
160
|
await hostedOwnerObserver.observeOwner(guild);
|
|
70
161
|
else
|
|
@@ -82,6 +173,8 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
82
173
|
}, "New guild was rejected by the Bot Invite gate");
|
|
83
174
|
}
|
|
84
175
|
async function admitAndSync(guild, firstObservation) {
|
|
176
|
+
if (stopped)
|
|
177
|
+
return;
|
|
85
178
|
if (inviteGateRejections.has(guild.id)) {
|
|
86
179
|
await leaveInviteRejectedGuild(guild);
|
|
87
180
|
return;
|
|
@@ -92,6 +185,8 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
92
185
|
: Promise.resolve(false),
|
|
93
186
|
hostedInviteGate ? hostedInviteGate.isEnabled() : Promise.resolve(true),
|
|
94
187
|
]);
|
|
188
|
+
if (stopped)
|
|
189
|
+
return;
|
|
95
190
|
if (hostedSafety && blacklist.status === "fulfilled" && blacklist.value) {
|
|
96
191
|
const enforcement = await Promise.allSettled([
|
|
97
192
|
hostedSafety.recordAutomaticLeave(guild.id),
|
|
@@ -132,7 +227,7 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
132
227
|
admissionRetries.delete(guild.id);
|
|
133
228
|
if (stopped || !guild.available)
|
|
134
229
|
return;
|
|
135
|
-
void admitAndSync(guild, true).catch(() => {
|
|
230
|
+
void trackOperation(admitAndSync(guild, true)).catch(() => {
|
|
136
231
|
logger.error({
|
|
137
232
|
event: "guild.admission_retry_failed",
|
|
138
233
|
guildId: guild.id,
|
|
@@ -145,7 +240,9 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
145
240
|
admissionRetries.set(guild.id, retry);
|
|
146
241
|
}
|
|
147
242
|
client.on(Events.GuildCreate, (guild) => {
|
|
148
|
-
|
|
243
|
+
if (stopped)
|
|
244
|
+
return;
|
|
245
|
+
void trackOperation(admitAndSync(guild, true)).catch(() => {
|
|
149
246
|
logger.error({
|
|
150
247
|
event: "guild.admission_failed",
|
|
151
248
|
guildId: guild.id,
|
|
@@ -155,20 +252,21 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
155
252
|
});
|
|
156
253
|
});
|
|
157
254
|
client.on(Events.GuildUpdate, (_previous, guild) => {
|
|
255
|
+
if (stopped)
|
|
256
|
+
return;
|
|
158
257
|
if (!inviteGateRejections.has(guild.id))
|
|
159
258
|
ownerObservations?.enqueue(guild);
|
|
160
259
|
});
|
|
161
260
|
client.on(Events.GuildDelete, (guild) => {
|
|
261
|
+
if (stopped)
|
|
262
|
+
return;
|
|
162
263
|
const retry = admissionRetries.get(guild.id);
|
|
163
264
|
if (retry)
|
|
164
265
|
clearTimeout(retry);
|
|
165
266
|
admissionRetries.delete(guild.id);
|
|
166
267
|
inviteGateRejections.delete(guild.id);
|
|
167
|
-
void
|
|
168
|
-
.
|
|
169
|
-
.then(async (changed) => {
|
|
170
|
-
if (changed && applicationId)
|
|
171
|
-
await new GuildCommandRepository(database).cancelGuildWork(applicationId, guild.id);
|
|
268
|
+
void trackOperation(persistence.markGuildRemoved(guild.id, ownership, applicationId))
|
|
269
|
+
.then((changed) => {
|
|
172
270
|
logger.info({
|
|
173
271
|
event: "guild.removed",
|
|
174
272
|
outcome: changed ? "changed" : "rejected_or_duplicate",
|
|
@@ -210,6 +308,8 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
210
308
|
if (hostedSafety) {
|
|
211
309
|
const observed = [...observedGuildIds];
|
|
212
310
|
for (let offset = 0; offset < observed.length; offset += RECONCILIATION_PAGE_SIZE) {
|
|
311
|
+
if (stopped)
|
|
312
|
+
return;
|
|
213
313
|
const page = await hostedSafety.blacklistedGuildIds(observed.slice(offset, offset + RECONCILIATION_PAGE_SIZE));
|
|
214
314
|
for (const guildId of page)
|
|
215
315
|
blacklistedGuildIds.add(guildId);
|
|
@@ -224,30 +324,38 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
224
324
|
continue;
|
|
225
325
|
}
|
|
226
326
|
await syncGuild(guild);
|
|
327
|
+
if (stopped)
|
|
328
|
+
return;
|
|
227
329
|
refreshedGuilds += 1;
|
|
228
330
|
}
|
|
229
331
|
let cursor;
|
|
230
332
|
do {
|
|
231
333
|
if (stopped || !client.isReady())
|
|
232
334
|
return;
|
|
233
|
-
const page = await
|
|
335
|
+
const page = await persistence.listActiveGuildIdsForOwnership({
|
|
234
336
|
...(ownership ? { ownership } : {}),
|
|
235
337
|
...(ownership && shardCount !== undefined ? { shardCount } : {}),
|
|
236
338
|
...(cursor ? { cursor } : {}),
|
|
237
339
|
limit: RECONCILIATION_PAGE_SIZE,
|
|
238
340
|
});
|
|
341
|
+
if (stopped)
|
|
342
|
+
return;
|
|
239
343
|
examinedRows += page.guildIds.length;
|
|
240
344
|
if (ownership)
|
|
241
|
-
await
|
|
345
|
+
await persistence.adoptGuildsForReconciliation({
|
|
242
346
|
guildIds: page.guildIds,
|
|
243
347
|
ownership,
|
|
244
348
|
shardCount: shardCount,
|
|
245
349
|
});
|
|
350
|
+
if (stopped)
|
|
351
|
+
return;
|
|
246
352
|
const absent = page.guildIds.filter((guildId) => !observedGuildIds.has(guildId));
|
|
247
|
-
const result = await
|
|
353
|
+
const result = await persistence.markGuildsRemovedForReconciliation({
|
|
248
354
|
guildIds: absent,
|
|
249
355
|
...(ownership ? { ownership } : {}),
|
|
250
356
|
});
|
|
357
|
+
if (stopped)
|
|
358
|
+
return;
|
|
251
359
|
removedGuilds += result.changed;
|
|
252
360
|
rejectedFences += result.rejectedFence;
|
|
253
361
|
cursor = page.nextCursor;
|
|
@@ -277,22 +385,23 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
277
385
|
}, "Discord guild presence reconciliation completed");
|
|
278
386
|
}
|
|
279
387
|
catch (error) {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
388
|
+
if (!stopped)
|
|
389
|
+
health = {
|
|
390
|
+
status: "failed",
|
|
391
|
+
lastStartedAt: new Date(startedAt).toISOString(),
|
|
392
|
+
durationMs: Date.now() - startedAt,
|
|
393
|
+
examinedRows,
|
|
394
|
+
refreshedGuilds,
|
|
395
|
+
removedGuilds,
|
|
396
|
+
rejectedFences,
|
|
397
|
+
unavailableGuilds,
|
|
398
|
+
failureReasonCode: "reconciliation_failed",
|
|
399
|
+
nextScheduledAt: new Date(Date.now() + reconciliationIntervalMs).toISOString(),
|
|
400
|
+
};
|
|
292
401
|
logger.error({
|
|
293
402
|
event: "guild.reconciliation_failed",
|
|
294
403
|
reason,
|
|
295
|
-
reasonCode:
|
|
404
|
+
reasonCode: "reconciliation_failed",
|
|
296
405
|
error: safeError(error),
|
|
297
406
|
}, "Discord guild presence reconciliation failed");
|
|
298
407
|
}
|
|
@@ -306,7 +415,7 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
306
415
|
return;
|
|
307
416
|
prepared = true;
|
|
308
417
|
await runReconciliation("ready");
|
|
309
|
-
if (!timer) {
|
|
418
|
+
if (!stopped && !timer) {
|
|
310
419
|
timer = setInterval(() => void runReconciliation("periodic"), reconciliationIntervalMs);
|
|
311
420
|
timer.unref();
|
|
312
421
|
}
|
|
@@ -317,6 +426,8 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
317
426
|
prepare.reconcile = runReconciliation;
|
|
318
427
|
prepare.health = () => ({ ...health });
|
|
319
428
|
prepare.stop = () => {
|
|
429
|
+
if (stopPromise)
|
|
430
|
+
return stopPromise;
|
|
320
431
|
stopped = true;
|
|
321
432
|
if (timer)
|
|
322
433
|
clearInterval(timer);
|
|
@@ -325,8 +436,14 @@ export function installGuildTracking(client, database, modules, logger, enabledB
|
|
|
325
436
|
clearTimeout(retry);
|
|
326
437
|
admissionRetries.clear();
|
|
327
438
|
inviteGateRejections.clear();
|
|
328
|
-
ownerObservations?.stop();
|
|
439
|
+
const ownerDrain = ownerObservations?.stop();
|
|
329
440
|
health = { ...health, status: "stopped", nextScheduledAt: undefined };
|
|
441
|
+
stopPromise = Promise.allSettled([
|
|
442
|
+
...inFlightOperations,
|
|
443
|
+
...(reconciliation ? [reconciliation] : []),
|
|
444
|
+
...(ownerDrain ? [ownerDrain] : []),
|
|
445
|
+
]).then(() => undefined);
|
|
446
|
+
return stopPromise;
|
|
330
447
|
};
|
|
331
448
|
return prepare;
|
|
332
449
|
}
|
|
@@ -336,6 +453,7 @@ class OwnerObservationQueue {
|
|
|
336
453
|
#pending = new Map();
|
|
337
454
|
#active = 0;
|
|
338
455
|
#stopped = false;
|
|
456
|
+
#inFlight = new Set();
|
|
339
457
|
constructor(observer, logger) {
|
|
340
458
|
this.observer = observer;
|
|
341
459
|
this.logger = logger;
|
|
@@ -349,6 +467,7 @@ class OwnerObservationQueue {
|
|
|
349
467
|
stop() {
|
|
350
468
|
this.#stopped = true;
|
|
351
469
|
this.#pending.clear();
|
|
470
|
+
return Promise.allSettled([...this.#inFlight]).then(() => undefined);
|
|
352
471
|
}
|
|
353
472
|
#drain() {
|
|
354
473
|
while (!this.#stopped && this.#active < 2 && this.#pending.size > 0) {
|
|
@@ -358,7 +477,7 @@ class OwnerObservationQueue {
|
|
|
358
477
|
const [guildId, guild] = next;
|
|
359
478
|
this.#pending.delete(guildId);
|
|
360
479
|
this.#active += 1;
|
|
361
|
-
|
|
480
|
+
const observation = this.observer
|
|
362
481
|
.observeOwner(guild)
|
|
363
482
|
.catch(() => {
|
|
364
483
|
this.logger.warn({
|
|
@@ -368,9 +487,11 @@ class OwnerObservationQueue {
|
|
|
368
487
|
}, "Guild owner identity observation was deferred");
|
|
369
488
|
})
|
|
370
489
|
.finally(() => {
|
|
490
|
+
this.#inFlight.delete(observation);
|
|
371
491
|
this.#active -= 1;
|
|
372
492
|
this.#drain();
|
|
373
493
|
});
|
|
494
|
+
this.#inFlight.add(observation);
|
|
374
495
|
}
|
|
375
496
|
}
|
|
376
497
|
}
|
|
@@ -11,8 +11,8 @@ declare const resultSchema: z.ZodObject<{
|
|
|
11
11
|
}>;
|
|
12
12
|
outcome: z.ZodEnum<{
|
|
13
13
|
completed: "completed";
|
|
14
|
-
requires_review: "requires_review";
|
|
15
14
|
failed: "failed";
|
|
15
|
+
requires_review: "requires_review";
|
|
16
16
|
queued: "queued";
|
|
17
17
|
not_currently_installed: "not_currently_installed";
|
|
18
18
|
accepted_for_retry: "accepted_for_retry";
|
|
@@ -45,7 +45,7 @@ export declare class HostedServerModerationClient implements DeveloperServerMode
|
|
|
45
45
|
idempotencyKey: string;
|
|
46
46
|
}): Promise<{
|
|
47
47
|
operationId: string;
|
|
48
|
-
outcome: "completed" | "
|
|
48
|
+
outcome: "completed" | "failed" | "requires_review" | "queued" | "not_currently_installed" | "accepted_for_retry" | "already_blacklisted";
|
|
49
49
|
paidSlotsReleased: number;
|
|
50
50
|
}>;
|
|
51
51
|
}
|
|
@@ -168,14 +168,20 @@ export class HostedServerLeaveWorker {
|
|
|
168
168
|
});
|
|
169
169
|
}
|
|
170
170
|
async #deliver(claim) {
|
|
171
|
+
const outcome = await this.#repository.withLeaveDeliveryGuard(claim, (connection) => this.#deliverGuarded(claim, connection));
|
|
172
|
+
return (outcome ??
|
|
173
|
+
(await this.#repository.getLeaveDeliveryOutcome(claim.operationId, claim.guildId)) ??
|
|
174
|
+
"queued");
|
|
175
|
+
}
|
|
176
|
+
async #deliverGuarded(claim, connection) {
|
|
171
177
|
const guild = this.client.guilds.cache.get(claim.guildId);
|
|
172
178
|
if (!guild) {
|
|
173
|
-
await this.#repository.completeLeaveDelivery(claim, "not_currently_installed");
|
|
179
|
+
await this.#repository.completeLeaveDelivery(claim, "not_currently_installed", connection);
|
|
174
180
|
return "not_currently_installed";
|
|
175
181
|
}
|
|
176
182
|
try {
|
|
177
183
|
await guild.leave();
|
|
178
|
-
await this.#repository.completeLeaveDelivery(claim, "completed");
|
|
184
|
+
await this.#repository.completeLeaveDelivery(claim, "completed", connection);
|
|
179
185
|
this.logger.info({
|
|
180
186
|
event: "hosted_moderation.leave_completed",
|
|
181
187
|
guildId: claim.guildId,
|
|
@@ -186,14 +192,14 @@ export class HostedServerLeaveWorker {
|
|
|
186
192
|
catch (error) {
|
|
187
193
|
const failureCode = classifyLeaveFailure(error);
|
|
188
194
|
if (failureCode === "discord_not_installed") {
|
|
189
|
-
await this.#repository.completeLeaveDelivery(claim, "not_currently_installed");
|
|
195
|
+
await this.#repository.completeLeaveDelivery(claim, "not_currently_installed", connection);
|
|
190
196
|
return "not_currently_installed";
|
|
191
197
|
}
|
|
192
198
|
const outcome = await this.#repository.failLeaveDelivery({
|
|
193
199
|
claim,
|
|
194
200
|
failureCode,
|
|
195
201
|
retryAfterSeconds: Math.min(3_600, 15 * 2 ** claim.attemptCount),
|
|
196
|
-
});
|
|
202
|
+
}, connection);
|
|
197
203
|
this.logger.warn({
|
|
198
204
|
event: "hosted_moderation.leave_deferred",
|
|
199
205
|
guildId: claim.guildId,
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type ModuleDefinition } from "@helyx/sdk";
|
|
|
2
2
|
import { GatewayIntentBits } from "discord.js";
|
|
3
3
|
import { type ShardHealthSnapshot } from "./shard-contracts.js";
|
|
4
4
|
export { loadConfiguredModulePackages } from "./modules.js";
|
|
5
|
+
export { assertConfiguredModuleDatabaseCompatibility } from "./modules.js";
|
|
5
6
|
export { resolveModulePackageSpecifiers } from "./module-configuration.js";
|
|
6
7
|
export * from "./control-server.js";
|
|
7
8
|
export interface BotHandle {
|