@helyx/bot 0.5.0 → 0.6.2
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 +57 -0
- package/README.md +4 -4
- package/dist/cloud-management-module.js +1 -5
- package/dist/command-registration-policy.js +1 -0
- package/dist/control-server.d.ts +1 -33
- package/dist/control-server.js +23 -41
- package/dist/dashboard-commands.d.ts +1 -0
- package/dist/dashboard-commands.js +1 -0
- package/dist/dashboard-module-projection.d.ts +39 -0
- package/dist/dashboard-module-projection.js +28 -0
- package/dist/events.d.ts +1 -1
- package/dist/events.js +29 -20
- package/dist/guilds.js +1 -4
- package/dist/index.js +38 -5
- package/dist/interactions.d.ts +1 -1
- package/dist/interactions.js +41 -34
- package/dist/module-logging.js +1 -4
- package/dist/module-status-control.js +1 -1
- package/dist/premium-access.d.ts +7 -0
- package/dist/premium-access.js +38 -0
- package/dist/premium-activation-module.d.ts +20 -0
- package/dist/premium-activation-module.js +542 -0
- package/dist/premium-catalogue-enforcement.d.ts +5 -0
- package/dist/premium-catalogue-enforcement.js +29 -0
- package/dist/premium-guild-authority.d.ts +12 -0
- package/dist/premium-guild-authority.js +25 -0
- package/dist/scheduled-task-runner.js +14 -1
- package/dist/shard-supervisor.js +30 -1
- package/dist/shard-worker.d.ts +2 -0
- package/dist/shard-worker.js +25 -1
- package/documentation/examples/.env.example +2 -2
- package/package.json +7 -7
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { defineModule, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
|
|
3
|
+
const MODULE_ID = "helyx.premium-activation";
|
|
4
|
+
const PERMISSION_ID = "premium.activate";
|
|
5
|
+
const SESSION_PURPOSE = "premium.activate";
|
|
6
|
+
const SELECT_ID = "helyx.premium-activation.source";
|
|
7
|
+
const COMMIT_PREFIX = "helyx.premium-activation.commit:";
|
|
8
|
+
const CANCEL_PREFIX = "helyx.premium-activation.cancel:";
|
|
9
|
+
const SESSION_LIFETIME_MS = 5 * 60 * 1_000;
|
|
10
|
+
const VIOLET = 0x7c3aed;
|
|
11
|
+
const GREEN = 0x16a34a;
|
|
12
|
+
const RED = 0xdc2626;
|
|
13
|
+
export function createPremiumActivationModule(options) {
|
|
14
|
+
const now = options.now ?? (() => new Date());
|
|
15
|
+
return defineModule({
|
|
16
|
+
manifest: {
|
|
17
|
+
...INTERNAL_MODULE_MANIFEST_FIELDS,
|
|
18
|
+
releaseStatus: "live",
|
|
19
|
+
id: MODULE_ID,
|
|
20
|
+
name: "Helyx Premium activation",
|
|
21
|
+
version: "0.1.0",
|
|
22
|
+
description: "Assigns purchaser-owned Helyx Premium server slots.",
|
|
23
|
+
entry: "./dist/premium-activation-module.js",
|
|
24
|
+
framework: "^0.1.0",
|
|
25
|
+
dependencies: [],
|
|
26
|
+
permissions: [],
|
|
27
|
+
contributions: { commands: ["activate"], events: [] },
|
|
28
|
+
},
|
|
29
|
+
defaultPermissions: {
|
|
30
|
+
module: manageServerPolicy(),
|
|
31
|
+
commands: { [PERMISSION_ID]: manageServerPolicy() },
|
|
32
|
+
},
|
|
33
|
+
interactions: {
|
|
34
|
+
commands: [
|
|
35
|
+
{
|
|
36
|
+
name: "activate",
|
|
37
|
+
description: "Manage your Helyx Premium slot for this server",
|
|
38
|
+
registrationPolicy: "platform-global",
|
|
39
|
+
contexts: ["guild"],
|
|
40
|
+
integrationTypes: ["guild_install"],
|
|
41
|
+
permissionId: PERMISSION_ID,
|
|
42
|
+
authorization: "self-authorized",
|
|
43
|
+
acknowledgement: "deferred-ephemeral",
|
|
44
|
+
rateLimit: { scope: "user", limit: 12, windowSeconds: 60 },
|
|
45
|
+
async execute(context) {
|
|
46
|
+
const guildId = context.serverId;
|
|
47
|
+
if (!guildId) {
|
|
48
|
+
await context.reply(message("This command can only be used in a server.", RED));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!(await canManageTarget(context, options.authority))) {
|
|
52
|
+
await deny(options.allocations, context, "missing_manage_server");
|
|
53
|
+
await context.reply(message("You need Manage Server permission to use this command.", RED));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const observedAt = now();
|
|
57
|
+
const target = await options.allocations.inspectTarget({
|
|
58
|
+
targetGuildId: guildId,
|
|
59
|
+
observedAt,
|
|
60
|
+
});
|
|
61
|
+
if (!target.activeHostedInstallation) {
|
|
62
|
+
await deny(options.allocations, context, "target_not_hosted");
|
|
63
|
+
await context.reply(message("Helyx is not actively installed on this server.", RED));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const allocation = await options.allocations.getAccountAllocation({
|
|
67
|
+
accountUserId: context.userId,
|
|
68
|
+
provider: options.provider,
|
|
69
|
+
providerEnvironment: options.providerEnvironment,
|
|
70
|
+
targetGuildId: guildId,
|
|
71
|
+
observedAt,
|
|
72
|
+
});
|
|
73
|
+
await presentInitial(context, options, target, allocation, observedAt);
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
selects: [
|
|
78
|
+
{
|
|
79
|
+
customId: SELECT_ID,
|
|
80
|
+
acknowledgement: "deferred-update",
|
|
81
|
+
async execute(context) {
|
|
82
|
+
await selectMoveSource(context, options, now());
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
buttons: [
|
|
87
|
+
{
|
|
88
|
+
customIdPrefix: COMMIT_PREFIX,
|
|
89
|
+
acknowledgement: "deferred-update",
|
|
90
|
+
async execute(context) {
|
|
91
|
+
await commit(context, options, now());
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
customIdPrefix: CANCEL_PREFIX,
|
|
96
|
+
acknowledgement: "deferred-update",
|
|
97
|
+
async execute(context) {
|
|
98
|
+
const session = await boundSession(context, CANCEL_PREFIX);
|
|
99
|
+
if (session)
|
|
100
|
+
await context.services
|
|
101
|
+
.get(HELYX_SERVICE_NAMES.sessions)
|
|
102
|
+
.delete(session.sessionId);
|
|
103
|
+
await respond(context, message("Premium slot changes cancelled.", VIOLET));
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
start() {
|
|
109
|
+
return Promise.resolve();
|
|
110
|
+
},
|
|
111
|
+
stop() {
|
|
112
|
+
return Promise.resolve();
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
async function presentInitial(context, options, target, allocation, observedAt) {
|
|
117
|
+
if (!context.serverId)
|
|
118
|
+
return;
|
|
119
|
+
const targetView = allocation?.target;
|
|
120
|
+
const ownSlot = allocation?.slots.find(({ guildId }) => guildId === context.serverId);
|
|
121
|
+
if (ownSlot && allocation) {
|
|
122
|
+
const session = await createSession(context, {
|
|
123
|
+
step: 0,
|
|
124
|
+
action: "release",
|
|
125
|
+
subscriptionVersion: allocation.subscriptionVersion.toString(),
|
|
126
|
+
slotId: ownSlot.slotId,
|
|
127
|
+
slotVersion: ownSlot.version.toString(),
|
|
128
|
+
sourceGuildId: context.serverId,
|
|
129
|
+
adminOverlap: targetView?.hasAdminOverride ?? target.hasAdminOverride,
|
|
130
|
+
}, observedAt);
|
|
131
|
+
await context.reply({
|
|
132
|
+
...message(target.hasAdminOverride
|
|
133
|
+
? "## Premium is active from two sources\nThis server has both an Admin override and your paid slot. Releasing your slot leaves the override active."
|
|
134
|
+
: "## Premium slot active\nRelease your paid slot from this server? Configuration and data are retained, and the slot is immediately available to assign elsewhere.", target.hasAdminOverride ? RED : VIOLET),
|
|
135
|
+
buttons: confirmButtons(session.sessionId, "Release slot", "danger", session.version),
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (target.hasAdminOverride) {
|
|
140
|
+
await context.reply(message("## Premium is already active\nThis server is covered by an Admin override. Your paid slots were not changed.", GREEN));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (target.hasPaidAssignment || targetView?.hasOtherPaidAssignment) {
|
|
144
|
+
await context.reply(message("## Premium is already active\nThis server already has Premium access. No slot changes were made.", GREEN));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!allocation || !subscriptionEligible(allocation, observedAt)) {
|
|
148
|
+
await context.reply(message(`## No active Premium plan\nPurchase or restore Helyx Premium in the [Billing centre](${billingUrl(context)}), then run \`/activate\` again.`, VIOLET));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const free = allocation.slots.find((slot) => slot.guildId === null);
|
|
152
|
+
if (free) {
|
|
153
|
+
const session = await createSession(context, {
|
|
154
|
+
step: 0,
|
|
155
|
+
action: "assign",
|
|
156
|
+
subscriptionVersion: allocation.subscriptionVersion.toString(),
|
|
157
|
+
}, observedAt);
|
|
158
|
+
await context.reply({
|
|
159
|
+
...message(`## Premium slot available\n${slotSummary(allocation)}\nActivate the lowest available slot for this server?`, VIOLET),
|
|
160
|
+
buttons: confirmButtons(session.sessionId, "Activate", "success", session.version),
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const movable = (await Promise.all(allocation.slots
|
|
165
|
+
.filter((slot) => slot.guildId !== null)
|
|
166
|
+
.map(async (slot) => ({
|
|
167
|
+
slot,
|
|
168
|
+
allowed: !slot.activeHostedInstallation ||
|
|
169
|
+
(await options.authority.canManageGuild({
|
|
170
|
+
guildId: slot.guildId,
|
|
171
|
+
userId: context.userId,
|
|
172
|
+
})),
|
|
173
|
+
}))))
|
|
174
|
+
.filter(({ allowed }) => allowed)
|
|
175
|
+
.map(({ slot }) => slot);
|
|
176
|
+
if (movable.length === 0) {
|
|
177
|
+
await deny(options.allocations, context, "missing_manage_server");
|
|
178
|
+
await context.reply(message("## All slots are assigned\nNo assigned slot you can manage is currently eligible to move.", VIOLET));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const session = await createSession(context, {
|
|
182
|
+
step: 0,
|
|
183
|
+
action: "move_select",
|
|
184
|
+
subscriptionVersion: allocation.subscriptionVersion.toString(),
|
|
185
|
+
candidateSlotIds: movable.map(({ slotId }) => slotId),
|
|
186
|
+
}, observedAt);
|
|
187
|
+
await context.reply({
|
|
188
|
+
...message("## Move a Premium slot\nChoose one of your eligible source servers. You will confirm the move next.", VIOLET),
|
|
189
|
+
selects: [
|
|
190
|
+
{
|
|
191
|
+
customId: SELECT_ID,
|
|
192
|
+
placeholder: "Choose a source server",
|
|
193
|
+
options: movable.slice(0, 25).map((slot) => ({
|
|
194
|
+
label: truncate(slot.guildName ?? `Premium slot ${String(slot.ordinal)}`, 100),
|
|
195
|
+
description: `Slot ${String(slot.ordinal)} · eligible to move`,
|
|
196
|
+
value: `${session.sessionId}.${session.version.toString()}.${slot.slotId}`,
|
|
197
|
+
})),
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
buttons: [
|
|
201
|
+
{
|
|
202
|
+
type: "button",
|
|
203
|
+
customId: `${CANCEL_PREFIX}${session.sessionId}:${session.version.toString()}`,
|
|
204
|
+
label: "Cancel",
|
|
205
|
+
style: "secondary",
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
async function selectMoveSource(context, options, observedAt) {
|
|
211
|
+
if (!context.serverId ||
|
|
212
|
+
!(await canManageTarget(context, options.authority))) {
|
|
213
|
+
await respond(context, message("This Premium session is no longer authorised.", RED));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const selected = /^([0-9a-f-]{36})\.(\d+)\.([0-9a-f-]{36})$/iu.exec(context.selectedValues[0] ?? "");
|
|
217
|
+
if (!selected)
|
|
218
|
+
return expired(context);
|
|
219
|
+
const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
|
|
220
|
+
const session = await sessions.get(selected[1]);
|
|
221
|
+
if (!session || !sessionMatches(session, context, BigInt(selected[2])))
|
|
222
|
+
return expired(context);
|
|
223
|
+
const state = parseState(session.state);
|
|
224
|
+
const slotId = selected[3];
|
|
225
|
+
if (state.step !== 0 ||
|
|
226
|
+
state.action !== "move_select" ||
|
|
227
|
+
!state.candidateSlotIds?.includes(slotId))
|
|
228
|
+
return expired(context);
|
|
229
|
+
const allocation = await options.allocations.getAccountAllocation({
|
|
230
|
+
accountUserId: context.userId,
|
|
231
|
+
provider: options.provider,
|
|
232
|
+
providerEnvironment: options.providerEnvironment,
|
|
233
|
+
targetGuildId: context.serverId,
|
|
234
|
+
observedAt,
|
|
235
|
+
});
|
|
236
|
+
const slot = allocation?.slots.find((candidate) => candidate.slotId === slotId);
|
|
237
|
+
if (!allocation ||
|
|
238
|
+
!slot?.guildId ||
|
|
239
|
+
(slot.activeHostedInstallation &&
|
|
240
|
+
!(await options.authority.canManageGuild({
|
|
241
|
+
guildId: slot.guildId,
|
|
242
|
+
userId: context.userId,
|
|
243
|
+
}))))
|
|
244
|
+
return expired(context);
|
|
245
|
+
const next = {
|
|
246
|
+
step: 1,
|
|
247
|
+
action: "move_confirm",
|
|
248
|
+
subscriptionVersion: allocation.subscriptionVersion.toString(),
|
|
249
|
+
slotId: slot.slotId,
|
|
250
|
+
slotVersion: slot.version.toString(),
|
|
251
|
+
sourceGuildId: slot.guildId,
|
|
252
|
+
};
|
|
253
|
+
const advanced = await sessions.advance({
|
|
254
|
+
sessionId: session.sessionId,
|
|
255
|
+
guildId: context.serverId,
|
|
256
|
+
userId: context.userId,
|
|
257
|
+
purpose: SESSION_PURPOSE,
|
|
258
|
+
expectedVersion: session.version,
|
|
259
|
+
expectedStep: 0,
|
|
260
|
+
nextStep: 1,
|
|
261
|
+
state: next,
|
|
262
|
+
expiresAt: sessionExpiry(observedAt),
|
|
263
|
+
});
|
|
264
|
+
await respond(context, {
|
|
265
|
+
...message(`## Confirm Premium move\nMove slot ${String(slot.ordinal)} from **${escapeMarkdown(slot.guildName ?? "the selected server")}** to this server?`, RED),
|
|
266
|
+
buttons: confirmButtons(session.sessionId, "Move slot", "danger", advanced.version),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
async function commit(context, options, observedAt) {
|
|
270
|
+
const session = await boundSession(context, COMMIT_PREFIX);
|
|
271
|
+
if (!session || !context.serverId)
|
|
272
|
+
return expired(context);
|
|
273
|
+
if (!(await canManageTarget(context, options.authority))) {
|
|
274
|
+
await deny(options.allocations, context, "missing_manage_server");
|
|
275
|
+
return respond(context, message("This Premium session is no longer authorised.", RED));
|
|
276
|
+
}
|
|
277
|
+
const state = parseState(session.state);
|
|
278
|
+
try {
|
|
279
|
+
let result;
|
|
280
|
+
const base = {
|
|
281
|
+
accountUserId: context.userId,
|
|
282
|
+
provider: options.provider,
|
|
283
|
+
providerEnvironment: options.providerEnvironment,
|
|
284
|
+
expectedSubscriptionVersion: BigInt(state.subscriptionVersion),
|
|
285
|
+
observedAt,
|
|
286
|
+
projectionId: context.interactionId,
|
|
287
|
+
reviewedPremiumModuleIds: options.reviewedPremiumModuleIds,
|
|
288
|
+
context: {
|
|
289
|
+
actorUserId: context.userId,
|
|
290
|
+
source: "discord",
|
|
291
|
+
correlationId: context.interactionId,
|
|
292
|
+
sessionClaim: {
|
|
293
|
+
sessionId: session.sessionId,
|
|
294
|
+
guildId: context.serverId,
|
|
295
|
+
userId: context.userId,
|
|
296
|
+
purpose: SESSION_PURPOSE,
|
|
297
|
+
expectedVersion: session.version,
|
|
298
|
+
expectedStep: state.step,
|
|
299
|
+
requiredState: state,
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
if (state.action === "assign" && state.step === 0) {
|
|
304
|
+
result = await options.allocations.assignLowestAvailable({
|
|
305
|
+
...base,
|
|
306
|
+
targetGuildId: context.serverId,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
else if (state.action === "release" &&
|
|
310
|
+
state.step === 0 &&
|
|
311
|
+
state.slotId &&
|
|
312
|
+
state.slotVersion &&
|
|
313
|
+
state.sourceGuildId === context.serverId) {
|
|
314
|
+
result = await options.allocations.releaseSlot({
|
|
315
|
+
...base,
|
|
316
|
+
slotId: state.slotId,
|
|
317
|
+
sourceGuildId: state.sourceGuildId,
|
|
318
|
+
expectedSlotVersion: BigInt(state.slotVersion),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
else if (state.action === "move_confirm" &&
|
|
322
|
+
state.step === 1 &&
|
|
323
|
+
state.slotId &&
|
|
324
|
+
state.slotVersion &&
|
|
325
|
+
state.sourceGuildId) {
|
|
326
|
+
const current = await options.allocations.getAccountAllocation({
|
|
327
|
+
accountUserId: context.userId,
|
|
328
|
+
provider: options.provider,
|
|
329
|
+
providerEnvironment: options.providerEnvironment,
|
|
330
|
+
targetGuildId: context.serverId,
|
|
331
|
+
observedAt,
|
|
332
|
+
});
|
|
333
|
+
const sourceSlot = current?.slots.find(({ slotId }) => slotId === state.slotId);
|
|
334
|
+
if (!sourceSlot?.guildId ||
|
|
335
|
+
sourceSlot.guildId !== state.sourceGuildId ||
|
|
336
|
+
(sourceSlot.activeHostedInstallation &&
|
|
337
|
+
!(await options.authority.canManageGuild({
|
|
338
|
+
guildId: state.sourceGuildId,
|
|
339
|
+
userId: context.userId,
|
|
340
|
+
})))) {
|
|
341
|
+
await deny(options.allocations, context, "missing_manage_server");
|
|
342
|
+
return respond(context, message("Manage Server is required on both servers to move this slot.", RED));
|
|
343
|
+
}
|
|
344
|
+
result = await options.allocations.moveSlot({
|
|
345
|
+
...base,
|
|
346
|
+
slotId: state.slotId,
|
|
347
|
+
sourceGuildId: state.sourceGuildId,
|
|
348
|
+
targetGuildId: context.serverId,
|
|
349
|
+
expectedSlotVersion: BigInt(state.slotVersion),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
else
|
|
353
|
+
return expired(context);
|
|
354
|
+
await respond(context, successResponse(state.action, result.slot));
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
const code = allocationErrorCode(error);
|
|
358
|
+
if (code)
|
|
359
|
+
await deny(options.allocations, context, code);
|
|
360
|
+
await respond(context, safeFailure(code));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async function createSession(context, state, observedAt) {
|
|
364
|
+
if (!context.serverId)
|
|
365
|
+
throw new Error("Guild context is required");
|
|
366
|
+
return context.services
|
|
367
|
+
.get(HELYX_SERVICE_NAMES.sessions)
|
|
368
|
+
.create({
|
|
369
|
+
guildId: context.serverId,
|
|
370
|
+
userId: context.userId,
|
|
371
|
+
purpose: SESSION_PURPOSE,
|
|
372
|
+
state,
|
|
373
|
+
expiresAt: sessionExpiry(observedAt),
|
|
374
|
+
replace: true,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
async function boundSession(context, prefix) {
|
|
378
|
+
const match = new RegExp(`^${escapeRegExp(prefix)}([0-9a-f-]{36}):(\\d+)$`, "iu").exec(context.customId ?? "");
|
|
379
|
+
if (!match)
|
|
380
|
+
return null;
|
|
381
|
+
const session = await context.services
|
|
382
|
+
.get(HELYX_SERVICE_NAMES.sessions)
|
|
383
|
+
.get(match[1]);
|
|
384
|
+
return session && sessionMatches(session, context, BigInt(match[2]))
|
|
385
|
+
? session
|
|
386
|
+
: null;
|
|
387
|
+
}
|
|
388
|
+
function sessionMatches(session, context, expectedVersion) {
|
|
389
|
+
return (session.guildId === context.serverId &&
|
|
390
|
+
session.userId === context.userId &&
|
|
391
|
+
session.purpose === SESSION_PURPOSE &&
|
|
392
|
+
session.version === expectedVersion);
|
|
393
|
+
}
|
|
394
|
+
function parseState(value) {
|
|
395
|
+
const step = value.step;
|
|
396
|
+
const action = value.action;
|
|
397
|
+
const subscriptionVersion = value.subscriptionVersion;
|
|
398
|
+
if (!Number.isInteger(step) ||
|
|
399
|
+
(step !== 0 && step !== 1) ||
|
|
400
|
+
!["assign", "release", "move_select", "move_confirm"].includes(String(action)) ||
|
|
401
|
+
typeof subscriptionVersion !== "string" ||
|
|
402
|
+
!/^\d+$/u.test(subscriptionVersion))
|
|
403
|
+
throw new Error("Premium interaction session is invalid");
|
|
404
|
+
const optionalString = (key) => value[key] === undefined
|
|
405
|
+
? undefined
|
|
406
|
+
: typeof value[key] === "string"
|
|
407
|
+
? value[key]
|
|
408
|
+
: null;
|
|
409
|
+
const slotId = optionalString("slotId");
|
|
410
|
+
const slotVersion = optionalString("slotVersion");
|
|
411
|
+
const sourceGuildId = optionalString("sourceGuildId");
|
|
412
|
+
if (slotId === null || slotVersion === null || sourceGuildId === null)
|
|
413
|
+
throw new Error("Premium interaction session is invalid");
|
|
414
|
+
const candidates = value.candidateSlotIds;
|
|
415
|
+
if (candidates !== undefined &&
|
|
416
|
+
(!Array.isArray(candidates) ||
|
|
417
|
+
candidates.length > 5 ||
|
|
418
|
+
candidates.some((item) => typeof item !== "string")))
|
|
419
|
+
throw new Error("Premium interaction session is invalid");
|
|
420
|
+
return {
|
|
421
|
+
step: step,
|
|
422
|
+
action: action,
|
|
423
|
+
subscriptionVersion,
|
|
424
|
+
...(slotId ? { slotId } : {}),
|
|
425
|
+
...(slotVersion ? { slotVersion } : {}),
|
|
426
|
+
...(sourceGuildId ? { sourceGuildId } : {}),
|
|
427
|
+
...(candidates ? { candidateSlotIds: candidates } : {}),
|
|
428
|
+
...(value.adminOverlap === true ? { adminOverlap: true } : {}),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function subscriptionEligible(view, observedAt) {
|
|
432
|
+
return (view.subscriptionState === "active" ||
|
|
433
|
+
view.subscriptionState === "ending" ||
|
|
434
|
+
(view.subscriptionState === "past_due_grace" &&
|
|
435
|
+
!!view.gracePeriodEnd &&
|
|
436
|
+
view.gracePeriodEnd > observedAt));
|
|
437
|
+
}
|
|
438
|
+
async function canManageTarget(context, authority) {
|
|
439
|
+
if (!context.serverId)
|
|
440
|
+
return false;
|
|
441
|
+
return authority.canManageGuild({
|
|
442
|
+
guildId: context.serverId,
|
|
443
|
+
userId: context.userId,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
async function deny(allocations, context, outcome) {
|
|
447
|
+
if (!context.serverId)
|
|
448
|
+
return;
|
|
449
|
+
await allocations
|
|
450
|
+
.auditDenied({
|
|
451
|
+
guildId: context.serverId,
|
|
452
|
+
actorUserId: context.userId,
|
|
453
|
+
correlationId: context.interactionId,
|
|
454
|
+
outcome,
|
|
455
|
+
})
|
|
456
|
+
.catch(() => undefined);
|
|
457
|
+
}
|
|
458
|
+
function confirmButtons(sessionId, label, style, version = 1n) {
|
|
459
|
+
return [
|
|
460
|
+
{
|
|
461
|
+
type: "button",
|
|
462
|
+
customId: `${COMMIT_PREFIX}${sessionId}:${version.toString()}`,
|
|
463
|
+
label,
|
|
464
|
+
style,
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
type: "button",
|
|
468
|
+
customId: `${CANCEL_PREFIX}${sessionId}:${version.toString()}`,
|
|
469
|
+
label: "Cancel",
|
|
470
|
+
style: "secondary",
|
|
471
|
+
},
|
|
472
|
+
];
|
|
473
|
+
}
|
|
474
|
+
function successResponse(action, slot) {
|
|
475
|
+
if (action === "release")
|
|
476
|
+
return message(`## Slot released\nPremium slot ${String(slot.ordinal)} is unassigned and can be assigned again immediately.`, GREEN);
|
|
477
|
+
if (action === "move_confirm")
|
|
478
|
+
return message(`## Premium moved\nSlot ${String(slot.ordinal)} now supplies Premium to this server.`, GREEN);
|
|
479
|
+
return message(`## Premium activated\nSlot ${String(slot.ordinal)} now supplies Premium to this server.`, GREEN);
|
|
480
|
+
}
|
|
481
|
+
function safeFailure(code) {
|
|
482
|
+
if (code === "target_admin_override")
|
|
483
|
+
return message("This server is already covered by an Admin override. No paid slot was used.", GREEN);
|
|
484
|
+
if (code === "target_already_paid")
|
|
485
|
+
return message("Premium is already active on this server. No slot changes were made.", GREEN);
|
|
486
|
+
if (code === "slot_required_for_reduction")
|
|
487
|
+
return message("This slot must remain free for a scheduled quantity reduction.", RED);
|
|
488
|
+
return message("This Premium selection expired or changed. Run `/activate` again.", RED);
|
|
489
|
+
}
|
|
490
|
+
function allocationErrorCode(error) {
|
|
491
|
+
if (!error || typeof error !== "object" || !("code" in error))
|
|
492
|
+
return undefined;
|
|
493
|
+
const code = Reflect.get(error, "code");
|
|
494
|
+
return typeof code === "string"
|
|
495
|
+
? code
|
|
496
|
+
: undefined;
|
|
497
|
+
}
|
|
498
|
+
function expired(context) {
|
|
499
|
+
return respond(context, message("This Premium selection expired or was already used. Run `/activate` again.", RED));
|
|
500
|
+
}
|
|
501
|
+
function respond(context, response) {
|
|
502
|
+
return context.updateResponse
|
|
503
|
+
? context.updateResponse(response)
|
|
504
|
+
: context.reply(response);
|
|
505
|
+
}
|
|
506
|
+
function message(content, accentColor) {
|
|
507
|
+
return {
|
|
508
|
+
ephemeral: true,
|
|
509
|
+
componentsV2: { accentColor, text: [{ content }] },
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function slotSummary(view) {
|
|
513
|
+
const assigned = view.slots.filter(({ guildId }) => guildId !== null).length;
|
|
514
|
+
return `${String(assigned)} of ${String(view.purchasedQuantity)} paid slots assigned.`;
|
|
515
|
+
}
|
|
516
|
+
function billingUrl(context) {
|
|
517
|
+
const service = context.services.get(HELYX_SERVICE_NAMES.applicationUrls);
|
|
518
|
+
return `${service.dashboardBaseUrl.replace(/\/$/u, "")}/billing`;
|
|
519
|
+
}
|
|
520
|
+
function sessionExpiry(now) {
|
|
521
|
+
return new Date(now.getTime() + SESSION_LIFETIME_MS);
|
|
522
|
+
}
|
|
523
|
+
function truncate(value, maximum) {
|
|
524
|
+
return value.length <= maximum ? value : `${value.slice(0, maximum - 1)}…`;
|
|
525
|
+
}
|
|
526
|
+
function escapeMarkdown(value) {
|
|
527
|
+
return value.replaceAll(/([\\`*_{}[\]()#+.!|>-])/gu, "\\$1");
|
|
528
|
+
}
|
|
529
|
+
function escapeRegExp(value) {
|
|
530
|
+
return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
531
|
+
}
|
|
532
|
+
function manageServerPolicy() {
|
|
533
|
+
return {
|
|
534
|
+
everyone: false,
|
|
535
|
+
requiredPermissions: ["MANAGE_GUILD"],
|
|
536
|
+
allowedRoleIds: [],
|
|
537
|
+
deniedRoleIds: [],
|
|
538
|
+
allowedUserIds: [],
|
|
539
|
+
deniedUserIds: [],
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
//# sourceMappingURL=premium-activation-module.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Logger } from "@helyx/sdk";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
export declare const PREMIUM_CATALOGUE_ENFORCEMENT_INTERVAL_MS: number;
|
|
4
|
+
export declare function enforcePremiumCatalogue(database: Pool, logger: Logger): Promise<void>;
|
|
5
|
+
//# sourceMappingURL=premium-catalogue-enforcement.d.ts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { PremiumEntitlementProjectionRepository } from "@helyx/database";
|
|
2
|
+
import { REVIEWED_PREMIUM_MODULE_IDS } from "@helyx/module-manifest";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
const ENFORCEMENT_PAGE_SIZE = 100;
|
|
5
|
+
export const PREMIUM_CATALOGUE_ENFORCEMENT_INTERVAL_MS = 24 * 60 * 60 * 1_000;
|
|
6
|
+
export async function enforcePremiumCatalogue(database, logger) {
|
|
7
|
+
const projection = new PremiumEntitlementProjectionRepository(database);
|
|
8
|
+
const projectionId = `premium-catalogue-startup:${randomUUID()}`;
|
|
9
|
+
let scannedGuilds = 0;
|
|
10
|
+
let disabledModules = 0;
|
|
11
|
+
for (;;) {
|
|
12
|
+
const page = await projection.enforceCataloguePage({
|
|
13
|
+
projectionId,
|
|
14
|
+
observedAt: new Date(),
|
|
15
|
+
reviewedPremiumModuleIds: REVIEWED_PREMIUM_MODULE_IDS,
|
|
16
|
+
limit: ENFORCEMENT_PAGE_SIZE,
|
|
17
|
+
});
|
|
18
|
+
scannedGuilds += page.scannedGuilds;
|
|
19
|
+
disabledModules += page.disabledModules.length;
|
|
20
|
+
if (page.scannedGuilds < ENFORCEMENT_PAGE_SIZE)
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
logger.info({
|
|
24
|
+
event: "premium.catalogue_enforced",
|
|
25
|
+
scannedGuilds,
|
|
26
|
+
disabledModules,
|
|
27
|
+
}, "Premium module catalogue enforced");
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=premium-catalogue-enforcement.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Client } from "discord.js";
|
|
2
|
+
import type { PremiumGuildAuthority } from "./premium-activation-module.js";
|
|
3
|
+
/** Fresh, fail-closed Discord authority used for cross-guild slot moves. */
|
|
4
|
+
export declare class DiscordPremiumGuildAuthority implements PremiumGuildAuthority {
|
|
5
|
+
private readonly client;
|
|
6
|
+
constructor(client: Client);
|
|
7
|
+
canManageGuild(input: {
|
|
8
|
+
guildId: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
}): Promise<boolean>;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=premium-guild-authority.d.ts.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { PermissionFlagsBits } from "discord.js";
|
|
2
|
+
/** Fresh, fail-closed Discord authority used for cross-guild slot moves. */
|
|
3
|
+
export class DiscordPremiumGuildAuthority {
|
|
4
|
+
client;
|
|
5
|
+
constructor(client) {
|
|
6
|
+
this.client = client;
|
|
7
|
+
}
|
|
8
|
+
async canManageGuild(input) {
|
|
9
|
+
try {
|
|
10
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
11
|
+
if (guild.ownerId === input.userId)
|
|
12
|
+
return true;
|
|
13
|
+
const member = await guild.members.fetch({
|
|
14
|
+
user: input.userId,
|
|
15
|
+
force: true,
|
|
16
|
+
});
|
|
17
|
+
return (member.permissions.has(PermissionFlagsBits.Administrator) ||
|
|
18
|
+
member.permissions.has(PermissionFlagsBits.ManageGuild));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=premium-guild-authority.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CORE_RECORD_RETENTION_TASK, MODULE_ACTIVATION_RECOVERY_TASK, ScheduledTaskRepository, } from "@helyx/database";
|
|
2
2
|
import { getModuleScheduledTaskContribution, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { canAccessModule } from "./premium-access.js";
|
|
4
5
|
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
5
6
|
const DEFAULT_LEASE_MS = 45_000;
|
|
6
7
|
const DEFAULT_CLAIM_LIMIT = 8;
|
|
@@ -350,8 +351,18 @@ export function createLocalScheduledTaskRoute(input) {
|
|
|
350
351
|
if (enabled !== (state === "enabled"))
|
|
351
352
|
return { outcome: "completed" };
|
|
352
353
|
const context = { guildId: task.guildId, services: input.services };
|
|
353
|
-
if (state === "enabled")
|
|
354
|
+
if (state === "enabled") {
|
|
355
|
+
if (typeof input.services.has === "function" &&
|
|
356
|
+
input.services.has(HELYX_SERVICE_NAMES.moduleAvailability)) {
|
|
357
|
+
const availability = input.services.get(HELYX_SERVICE_NAMES.moduleAvailability);
|
|
358
|
+
if (typeof availability?.isAvailable === "function" &&
|
|
359
|
+
!(await availability.isAvailable(task.moduleId)))
|
|
360
|
+
return deferred("module_globally_disabled", DEFAULT_POLL_INTERVAL_MS * 30);
|
|
361
|
+
}
|
|
362
|
+
if (!(await canAccessModule(input.services, task.guildId, module)))
|
|
363
|
+
return deferred("premium_entitlement_unavailable", DEFAULT_POLL_INTERVAL_MS * 30);
|
|
354
364
|
await module.onActivated?.(context);
|
|
365
|
+
}
|
|
355
366
|
else
|
|
356
367
|
await module.onDeactivated?.(context);
|
|
357
368
|
return { outcome: "completed" };
|
|
@@ -369,6 +380,8 @@ export function createLocalScheduledTaskRoute(input) {
|
|
|
369
380
|
.isModuleEnabled(task.guildId, task.moduleId);
|
|
370
381
|
if (!enabled)
|
|
371
382
|
return deferred("module_disabled", DEFAULT_POLL_INTERVAL_MS * 30);
|
|
383
|
+
if (!(await canAccessModule(input.services, task.guildId, module)))
|
|
384
|
+
return deferred("premium_entitlement_unavailable", DEFAULT_POLL_INTERVAL_MS * 30);
|
|
372
385
|
}
|
|
373
386
|
const contribution = getModuleScheduledTaskContribution(module, task.taskKind);
|
|
374
387
|
if (!contribution ||
|