@helyx/bot 1.5.4 → 1.7.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 +19 -0
- package/README.md +5 -2
- package/command-root-catalogue.json +21 -5
- package/dist/cloud-connector.js +2 -0
- package/dist/cloud-control-dispatcher.js +20 -0
- package/dist/command-registration-policy.js +2 -0
- package/dist/control-directory-contracts.d.ts +50 -0
- package/dist/control-directory-contracts.js +2 -0
- package/dist/control-server-schemas.d.ts +3 -3
- package/dist/control-server.d.ts +10 -46
- package/dist/control-server.js +50 -3
- package/dist/creator-invite-service.d.ts +7 -0
- package/dist/creator-invite-service.js +17 -0
- package/dist/developer-operations-module.js +0 -55
- package/dist/diagnostic-command.js +1 -0
- package/dist/discord-guild-health.d.ts +2 -2
- package/dist/discord-guild-health.js +1 -0
- package/dist/events.d.ts +1 -0
- package/dist/events.js +2 -0
- package/dist/help-credentials.d.ts +25 -0
- package/dist/help-credentials.js +149 -0
- package/dist/help-module.d.ts +27 -0
- package/dist/help-module.js +288 -0
- package/dist/help-runtime.d.ts +3 -0
- package/dist/help-runtime.js +42 -0
- package/dist/help-transport.d.ts +53 -0
- package/dist/help-transport.js +267 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -0
- package/dist/interactions.js +10 -6
- package/dist/process-info-module.d.ts +3 -0
- package/dist/process-info-module.js +97 -0
- package/dist/server-overview-control.d.ts +35 -0
- package/dist/server-overview-control.js +362 -0
- package/dist/server-overview-discord.d.ts +33 -0
- package/dist/server-overview-discord.js +212 -0
- package/dist/server-overview-profile.d.ts +8 -0
- package/dist/server-overview-profile.js +62 -0
- package/dist/server-overview-routes.d.ts +4 -0
- package/dist/server-overview-routes.js +41 -0
- package/dist/shard-contracts.d.ts +66 -0
- package/dist/shard-contracts.js +23 -0
- package/dist/shard-coordinator.d.ts +1 -1
- package/dist/shard-router.d.ts +9 -0
- package/dist/shard-router.js +6 -0
- package/dist/shard-supervisor.d.ts +1 -1
- package/dist/shard-supervisor.js +8 -0
- package/dist/shard-worker.d.ts +1 -1
- package/dist/shard-worker.js +8 -0
- package/dist/verification-service.d.ts +4 -0
- package/dist/verification-service.js +10 -0
- package/documentation/README.md +1 -0
- package/documentation/examples/.env.example +29 -85
- package/documentation/guides/help-command.md +62 -0
- package/documentation/reference/environment.md +7 -6
- package/documentation/runbooks/installation-and-upgrades.md +4 -1
- package/package.json +6 -6
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { canAccessGuildDashboard, canUseCommand } from "@helyx/core";
|
|
2
|
+
import { OVERVIEW_SOURCES, overviewResponseSchema, } from "@helyx/control-protocol";
|
|
3
|
+
import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
4
|
+
import { BotControlForbiddenError, BotControlTransportError, BotControlValidationError, } from "./control-errors.js";
|
|
5
|
+
import { declaredManagedResourcePermission, managedResourcePermissionId, } from "./control-permissions.js";
|
|
6
|
+
import { requireDashboardFeatures } from "./dashboard-feature-control.js";
|
|
7
|
+
import { overviewProfileContext } from "./server-overview-profile.js";
|
|
8
|
+
const countCollections = {
|
|
9
|
+
"helyx.automod:automod-rules": { collection: "rules" },
|
|
10
|
+
"helyx.creator-connections:invites": {
|
|
11
|
+
collection: "role_invites",
|
|
12
|
+
status: "state",
|
|
13
|
+
},
|
|
14
|
+
"helyx.reactionroles:panels": { collection: "panels" },
|
|
15
|
+
"helyx.verification:verification-panels": { collection: "panels" },
|
|
16
|
+
"helyx.account-gate:gate-panel": { collection: "gate_panels" },
|
|
17
|
+
"helyx.giveaways:giveaways": { collection: "giveaways" },
|
|
18
|
+
"helyx.community-goals:submissions": {
|
|
19
|
+
collection: "submissions",
|
|
20
|
+
status: "state",
|
|
21
|
+
},
|
|
22
|
+
"helyx.nexus-economy:redemptions": { collection: "redemptions" },
|
|
23
|
+
"helyx.nexus-economy:exchange-post": {
|
|
24
|
+
collection: "settings",
|
|
25
|
+
status: "post_status",
|
|
26
|
+
},
|
|
27
|
+
"helyx.moderation:moderation-cases": { collection: "cases", status: "state" },
|
|
28
|
+
};
|
|
29
|
+
export function createServerOverviewControl(deps) {
|
|
30
|
+
const counts = new Map();
|
|
31
|
+
const invalidation = new Map();
|
|
32
|
+
const authorize = async (input) => {
|
|
33
|
+
const [actor, policy] = await Promise.all([
|
|
34
|
+
deps.resolveActor(input.guildId, input.actorUserId),
|
|
35
|
+
deps.readGuildPolicy(input.guildId),
|
|
36
|
+
]);
|
|
37
|
+
if (!canAccessGuildDashboard(actor, policy))
|
|
38
|
+
throw new BotControlForbiddenError();
|
|
39
|
+
if (!deps.directory)
|
|
40
|
+
throw new BotControlTransportError(503, "Overview is unavailable on this runtime.");
|
|
41
|
+
return { actor, policy, directory: deps.directory };
|
|
42
|
+
};
|
|
43
|
+
const sourceAllowed = async (guildId, actor, policy, moduleId, resourceId, operation) => {
|
|
44
|
+
const module = deps.modules.get(moduleId);
|
|
45
|
+
const definition = module?.manifest.dashboard?.resources?.find((resource) => resource.id === resourceId);
|
|
46
|
+
if (!module ||
|
|
47
|
+
!definition ||
|
|
48
|
+
(definition.operations && !definition.operations.includes(operation)))
|
|
49
|
+
return false;
|
|
50
|
+
await deps.requireAccessible(guildId, moduleId);
|
|
51
|
+
await requireDashboardFeatures(deps.services, guildId, moduleId, [
|
|
52
|
+
definition.featureId,
|
|
53
|
+
]);
|
|
54
|
+
const permissionId = managedResourcePermissionId(module, resourceId, operation, undefined, declaredManagedResourcePermission(module, resourceId, operation));
|
|
55
|
+
return canUseCommand(actor, await deps.permissions.getEffectivePolicy(guildId, moduleId, permissionId), policy);
|
|
56
|
+
};
|
|
57
|
+
const records = () => deps.services.get(HELYX_SERVICE_NAMES.records);
|
|
58
|
+
const countSource = async (guildId, source) => {
|
|
59
|
+
const cacheKey = `${guildId}:${source.moduleId}:${source.resourceId}:${invalidation.get(guildId) ?? 0}`;
|
|
60
|
+
const cached = counts.get(cacheKey);
|
|
61
|
+
if (cached && cached.expires > Date.now())
|
|
62
|
+
return cached.value;
|
|
63
|
+
const value = (async () => {
|
|
64
|
+
if (source.moduleId === "helyx.verification" &&
|
|
65
|
+
source.resourceId === "attempts") {
|
|
66
|
+
const service = deps.services.get(HELYX_SERVICE_NAMES.verification);
|
|
67
|
+
if (!service.countPendingAttempts)
|
|
68
|
+
throw new Error("Verification summary unavailable");
|
|
69
|
+
const { installation } = await service.getInstallationContext({
|
|
70
|
+
guildId,
|
|
71
|
+
});
|
|
72
|
+
return service.countPendingAttempts({
|
|
73
|
+
installationId: installation.installationId,
|
|
74
|
+
guildId,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const config = countCollections[`${source.moduleId}:${source.resourceId}`];
|
|
78
|
+
if (!config ||
|
|
79
|
+
!deps.modules.get(source.moduleId)?.records?.[config.collection])
|
|
80
|
+
throw new Error("No safe summary adapter");
|
|
81
|
+
const totals = await Promise.all(source.statuses.map((status) => records().count({
|
|
82
|
+
moduleId: source.moduleId,
|
|
83
|
+
collection: source.moduleId === "helyx.community-goals" &&
|
|
84
|
+
status === "removal_pending"
|
|
85
|
+
? "removal_requests"
|
|
86
|
+
: config.collection,
|
|
87
|
+
where: {
|
|
88
|
+
guild_id: guildId,
|
|
89
|
+
[config.status ?? "status"]: source.moduleId === "helyx.community-goals" &&
|
|
90
|
+
status === "removal_pending"
|
|
91
|
+
? "pending"
|
|
92
|
+
: status,
|
|
93
|
+
},
|
|
94
|
+
})));
|
|
95
|
+
if (totals.some((total) => !Number.isSafeInteger(total) || total < 0))
|
|
96
|
+
throw new Error("Invalid source count");
|
|
97
|
+
return totals.reduce((sum, total) => sum + total, 0);
|
|
98
|
+
})();
|
|
99
|
+
counts.set(cacheKey, { expires: Date.now() + 60_000, value });
|
|
100
|
+
void value.catch(() => {
|
|
101
|
+
if (counts.get(cacheKey)?.value === value)
|
|
102
|
+
counts.delete(cacheKey);
|
|
103
|
+
});
|
|
104
|
+
while (counts.size > 1000)
|
|
105
|
+
counts.delete(counts.keys().next().value);
|
|
106
|
+
return value;
|
|
107
|
+
};
|
|
108
|
+
const mayManageInvites = (actor) => actor.isServerOwner ||
|
|
109
|
+
actor.isAdministrator ||
|
|
110
|
+
actor.permissions.includes("MANAGE_GUILD");
|
|
111
|
+
const ownership = async (guildId, invites, actor, policy) => {
|
|
112
|
+
const service = deps.services.has(HELYX_SERVICE_NAMES.creatorInvites)
|
|
113
|
+
? deps.services.get(HELYX_SERVICE_NAMES.creatorInvites)
|
|
114
|
+
: undefined;
|
|
115
|
+
let owned = [];
|
|
116
|
+
let ownershipAvailable = !deps.modules.has("helyx.creator-connections");
|
|
117
|
+
if (service?.identifyInvites) {
|
|
118
|
+
try {
|
|
119
|
+
owned = await service.identifyInvites({
|
|
120
|
+
guildId,
|
|
121
|
+
codes: invites.map((invite) => invite.code),
|
|
122
|
+
});
|
|
123
|
+
ownershipAvailable = true;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
ownershipAvailable = false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return Promise.all(invites.map(async (invite) => {
|
|
130
|
+
const match = owned.find((item) => item.code === invite.code);
|
|
131
|
+
const owner = match
|
|
132
|
+
? {
|
|
133
|
+
kind: "module",
|
|
134
|
+
moduleId: "helyx.creator-connections",
|
|
135
|
+
label: "Creator Connections",
|
|
136
|
+
}
|
|
137
|
+
: ownershipAvailable && invite.inviterIsBot === false
|
|
138
|
+
? { kind: "standalone" }
|
|
139
|
+
: { kind: "unknown" };
|
|
140
|
+
if (match && owner.kind === "module") {
|
|
141
|
+
// Resolve only this page's known owned references; never expose private ownership rows.
|
|
142
|
+
for (const [collection, resourceId, field] of [
|
|
143
|
+
["role_invites", "invites", "invite_id"],
|
|
144
|
+
["overview_links", "overviews", "overview_id"],
|
|
145
|
+
]) {
|
|
146
|
+
if (!deps.modules.get(owner.moduleId)?.records?.[collection])
|
|
147
|
+
continue;
|
|
148
|
+
try {
|
|
149
|
+
if (!(await sourceAllowed(guildId, actor, policy, owner.moduleId, resourceId, "read")))
|
|
150
|
+
continue;
|
|
151
|
+
const row = await records().findOne({
|
|
152
|
+
moduleId: owner.moduleId,
|
|
153
|
+
collection,
|
|
154
|
+
select: [field],
|
|
155
|
+
where: { guild_id: guildId, owned_reference: match.reference },
|
|
156
|
+
});
|
|
157
|
+
if (typeof row?.[field] === "string") {
|
|
158
|
+
owner.resourceId = resourceId;
|
|
159
|
+
owner.recordId = row[field];
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
/* Provenance remains protected even if its source record is unavailable. */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
...invite,
|
|
170
|
+
owner,
|
|
171
|
+
canRevoke: mayManageInvites(actor) && owner.kind === "standalone",
|
|
172
|
+
};
|
|
173
|
+
}));
|
|
174
|
+
};
|
|
175
|
+
const control = {
|
|
176
|
+
invalidate(guildId) {
|
|
177
|
+
invalidation.set(guildId, (invalidation.get(guildId) ?? 0) + 1);
|
|
178
|
+
for (const key of counts.keys())
|
|
179
|
+
if (key.startsWith(`${guildId}:`))
|
|
180
|
+
counts.delete(key);
|
|
181
|
+
while (invalidation.size > 1000)
|
|
182
|
+
invalidation.delete(invalidation.keys().next().value);
|
|
183
|
+
},
|
|
184
|
+
async readServerOverview(input) {
|
|
185
|
+
const { actor, policy, directory } = await authorize(input);
|
|
186
|
+
const observedAt = new Date().toISOString();
|
|
187
|
+
const common = { guildId: input.guildId, observedAt };
|
|
188
|
+
if (input.operation === "members")
|
|
189
|
+
return overviewResponseSchema.parse({
|
|
190
|
+
kind: "members",
|
|
191
|
+
...common,
|
|
192
|
+
...(await directory.members(input)),
|
|
193
|
+
});
|
|
194
|
+
const enabled = new Set((await deps.listModules(input.guildId))
|
|
195
|
+
.filter((module) => module.enabled)
|
|
196
|
+
.map((module) => module.moduleId));
|
|
197
|
+
if (input.operation === "summary") {
|
|
198
|
+
if (input.refresh)
|
|
199
|
+
control.invalidate(input.guildId);
|
|
200
|
+
const sources = [];
|
|
201
|
+
// One actor lookup per summary; counts are scoped database aggregates, never resource-list/Discord fan-out.
|
|
202
|
+
for (const source of OVERVIEW_SOURCES) {
|
|
203
|
+
if (!enabled.has(source.moduleId))
|
|
204
|
+
continue;
|
|
205
|
+
try {
|
|
206
|
+
if (!(await sourceAllowed(input.guildId, actor, policy, source.moduleId, source.resourceId, "list")))
|
|
207
|
+
continue;
|
|
208
|
+
let count = null;
|
|
209
|
+
let state = "available";
|
|
210
|
+
if (source.section === "attention" ||
|
|
211
|
+
source.section === "repairs") {
|
|
212
|
+
try {
|
|
213
|
+
count = await countSource(input.guildId, source);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
state = "partial";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
sources.push({
|
|
220
|
+
...source,
|
|
221
|
+
statuses: [...source.statuses],
|
|
222
|
+
count,
|
|
223
|
+
state,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (error instanceof BotControlForbiddenError ||
|
|
228
|
+
error instanceof BotControlValidationError)
|
|
229
|
+
continue;
|
|
230
|
+
sources.push({
|
|
231
|
+
...source,
|
|
232
|
+
statuses: [...source.statuses],
|
|
233
|
+
count: null,
|
|
234
|
+
state: "unavailable",
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return overviewResponseSchema.parse({
|
|
239
|
+
kind: "summary",
|
|
240
|
+
...common,
|
|
241
|
+
...(await directory.summary(input.guildId)),
|
|
242
|
+
sources,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (input.operation === "member") {
|
|
246
|
+
const memberId = input.memberId;
|
|
247
|
+
const member = await directory.member(input.guildId, memberId);
|
|
248
|
+
const profileRecords = [];
|
|
249
|
+
for (const moduleId of ["helyx.levels", "helyx.achievements"]) {
|
|
250
|
+
if (!enabled.has(moduleId))
|
|
251
|
+
continue;
|
|
252
|
+
try {
|
|
253
|
+
if (!(await sourceAllowed(input.guildId, actor, policy, moduleId, "members", "read")))
|
|
254
|
+
continue;
|
|
255
|
+
const resource = deps.modules
|
|
256
|
+
.get(moduleId)
|
|
257
|
+
.managedResources?.find((resource) => resource.id === "members");
|
|
258
|
+
if (!resource)
|
|
259
|
+
continue;
|
|
260
|
+
const detail = await resource.read(overviewProfileContext(deps.context(input.guildId, actor), memberId, member.member), memberId);
|
|
261
|
+
profileRecords.push({
|
|
262
|
+
moduleId,
|
|
263
|
+
resourceId: "members",
|
|
264
|
+
state: detail ? "available" : "no_data",
|
|
265
|
+
record: detail ? { ...detail } : null,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
profileRecords.push({
|
|
270
|
+
moduleId,
|
|
271
|
+
resourceId: "members",
|
|
272
|
+
state: "unavailable",
|
|
273
|
+
record: null,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (enabled.has("helyx.moderation")) {
|
|
278
|
+
try {
|
|
279
|
+
if (await sourceAllowed(input.guildId, actor, policy, "helyx.moderation", "moderation-cases", "list")) {
|
|
280
|
+
const resource = deps.modules
|
|
281
|
+
.get("helyx.moderation")
|
|
282
|
+
?.managedResources?.find((resource) => resource.id === "moderation-cases");
|
|
283
|
+
const page = await resource?.list(deps.context(input.guildId, actor), { limit: 15, search: memberId });
|
|
284
|
+
const items = page?.items.filter((item) => item.attributes?.subjectUserId === memberId) ?? [];
|
|
285
|
+
profileRecords.push({
|
|
286
|
+
moduleId: "helyx.moderation",
|
|
287
|
+
resourceId: "moderation-cases",
|
|
288
|
+
state: !page
|
|
289
|
+
? "unavailable"
|
|
290
|
+
: items.length || page.nextCursor
|
|
291
|
+
? "available"
|
|
292
|
+
: "no_data",
|
|
293
|
+
record: page
|
|
294
|
+
? { items, partial: Boolean(page.nextCursor) }
|
|
295
|
+
: null,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
profileRecords.push({
|
|
301
|
+
moduleId: "helyx.moderation",
|
|
302
|
+
resourceId: "moderation-cases",
|
|
303
|
+
state: "unavailable",
|
|
304
|
+
record: null,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return overviewResponseSchema.parse({
|
|
309
|
+
kind: "member",
|
|
310
|
+
...common,
|
|
311
|
+
...member,
|
|
312
|
+
records: profileRecords,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (!mayManageInvites(actor))
|
|
316
|
+
throw new BotControlForbiddenError("Manage Server is required to inspect server invite links.");
|
|
317
|
+
const all = await directory.invites(input.guildId, input.refresh === true);
|
|
318
|
+
all.sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));
|
|
319
|
+
const cursor = input.cursor;
|
|
320
|
+
const filtered = all.filter((invite) => (!cursor || invite.code > cursor) &&
|
|
321
|
+
(!input.search ||
|
|
322
|
+
invite.code.toLowerCase().includes(input.search.toLowerCase())));
|
|
323
|
+
const page = filtered.slice(0, input.limit ?? 15);
|
|
324
|
+
return overviewResponseSchema.parse({
|
|
325
|
+
kind: "invites",
|
|
326
|
+
...common,
|
|
327
|
+
items: await ownership(input.guildId, page, actor, policy),
|
|
328
|
+
total: input.search
|
|
329
|
+
? all.filter((invite) => invite.code.toLowerCase().includes(input.search.toLowerCase())).length
|
|
330
|
+
: all.length,
|
|
331
|
+
nextCursor: filtered.length > page.length ? page.at(-1).code : null,
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
async revokeServerOverviewInvite(input) {
|
|
335
|
+
const { actor, policy, directory } = await authorize(input);
|
|
336
|
+
if (!mayManageInvites(actor) || input.confirmed !== true)
|
|
337
|
+
throw new BotControlForbiddenError();
|
|
338
|
+
const invite = (await directory.invites(input.guildId, true)).find((invite) => invite.code === input.code);
|
|
339
|
+
if (!invite)
|
|
340
|
+
throw new BotControlValidationError("This invite is no longer active.");
|
|
341
|
+
const [current] = await ownership(input.guildId, [invite], actor, policy);
|
|
342
|
+
if (!current?.canRevoke)
|
|
343
|
+
throw new BotControlForbiddenError("This invite is module-owned or its ownership cannot be confirmed. Manage it in its source module.");
|
|
344
|
+
const correlationId = crypto.randomUUID();
|
|
345
|
+
await deps.auditInviteRevoke?.({
|
|
346
|
+
...input,
|
|
347
|
+
outcome: "requested",
|
|
348
|
+
correlationId,
|
|
349
|
+
});
|
|
350
|
+
await directory.revoke(input.guildId, input.code);
|
|
351
|
+
control.invalidate(input.guildId);
|
|
352
|
+
await deps.auditInviteRevoke?.({
|
|
353
|
+
...input,
|
|
354
|
+
outcome: "revoked",
|
|
355
|
+
correlationId,
|
|
356
|
+
});
|
|
357
|
+
return { revoked: true };
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
return control;
|
|
361
|
+
}
|
|
362
|
+
//# sourceMappingURL=server-overview-control.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Client } from "discord.js";
|
|
2
|
+
import type { OverviewMember, OverviewInvite, OverviewReadInput } from "@helyx/control-protocol";
|
|
3
|
+
type Snapshot = {
|
|
4
|
+
memberCount: number;
|
|
5
|
+
cachedMemberCount: number;
|
|
6
|
+
membersComplete: boolean;
|
|
7
|
+
roles: Array<{
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
colour: string;
|
|
11
|
+
count: number | null;
|
|
12
|
+
}>;
|
|
13
|
+
};
|
|
14
|
+
export interface OverviewDiscordDirectory {
|
|
15
|
+
summary(guildId: string): Promise<Snapshot>;
|
|
16
|
+
members(input: OverviewReadInput): Promise<{
|
|
17
|
+
items: OverviewMember[];
|
|
18
|
+
nextCursor: string | null;
|
|
19
|
+
total: number | null;
|
|
20
|
+
complete: boolean;
|
|
21
|
+
scope: "server" | "cached";
|
|
22
|
+
}>;
|
|
23
|
+
member(guildId: string, memberId: string): Promise<{
|
|
24
|
+
member: OverviewMember | null;
|
|
25
|
+
banStatus: "banned" | "not_banned" | "unknown";
|
|
26
|
+
}>;
|
|
27
|
+
invites(guildId: string, fresh?: boolean): Promise<Array<Omit<OverviewInvite, "owner" | "canRevoke">>>;
|
|
28
|
+
revoke(guildId: string, code: string): Promise<void>;
|
|
29
|
+
botUserId(): string | undefined;
|
|
30
|
+
}
|
|
31
|
+
export declare function createOverviewDiscordDirectory(client: Client): OverviewDiscordDirectory;
|
|
32
|
+
export {};
|
|
33
|
+
//# sourceMappingURL=server-overview-discord.d.ts.map
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { PermissionFlagsBits } from "discord.js";
|
|
2
|
+
import { BotControlNotFoundError, BotControlValidationError, } from "./control-errors.js";
|
|
3
|
+
function memberProjection(member) {
|
|
4
|
+
return {
|
|
5
|
+
id: member.id,
|
|
6
|
+
username: member.user.username,
|
|
7
|
+
displayName: member.displayName,
|
|
8
|
+
nickname: member.nickname,
|
|
9
|
+
avatarUrl: member.displayAvatarURL(),
|
|
10
|
+
accountCreatedAt: member.user.createdAt.toISOString(),
|
|
11
|
+
joinedAt: member.joinedAt?.toISOString() ?? null,
|
|
12
|
+
bot: member.user.bot,
|
|
13
|
+
screeningPending: member.pending,
|
|
14
|
+
boostingSince: member.premiumSince?.toISOString() ?? null,
|
|
15
|
+
timeoutUntil: member.communicationDisabledUntil?.toISOString() ?? null,
|
|
16
|
+
roles: [...member.roles.cache.values()]
|
|
17
|
+
.filter((role) => role.id !== member.guild.id)
|
|
18
|
+
.sort((a, b) => b.position - a.position)
|
|
19
|
+
.map((role) => ({ id: role.id, name: role.name, colour: role.hexColor })),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function createOverviewDiscordDirectory(client) {
|
|
23
|
+
const inviteCache = new Map();
|
|
24
|
+
const guildFor = (guildId) => {
|
|
25
|
+
const guild = client.guilds.cache.get(guildId);
|
|
26
|
+
if (!guild?.available)
|
|
27
|
+
throw new BotControlNotFoundError("This server is not available to this bot.");
|
|
28
|
+
return guild;
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
botUserId: () => client.user?.id,
|
|
32
|
+
summary(guildId) {
|
|
33
|
+
const guild = guildFor(guildId);
|
|
34
|
+
const complete = guild.members.cache.size >= guild.memberCount;
|
|
35
|
+
const roleCounts = new Map();
|
|
36
|
+
if (complete)
|
|
37
|
+
for (const member of guild.members.cache.values())
|
|
38
|
+
for (const id of member.roles.cache.keys())
|
|
39
|
+
roleCounts.set(id, (roleCounts.get(id) ?? 0) + 1);
|
|
40
|
+
return Promise.resolve({
|
|
41
|
+
memberCount: guild.memberCount,
|
|
42
|
+
cachedMemberCount: guild.members.cache.size,
|
|
43
|
+
membersComplete: complete,
|
|
44
|
+
roles: [...guild.roles.cache.values()]
|
|
45
|
+
.filter((role) => role.id !== guild.id && !role.managed)
|
|
46
|
+
.sort((a, b) => b.position - a.position)
|
|
47
|
+
.map((role) => ({
|
|
48
|
+
id: role.id,
|
|
49
|
+
name: role.name,
|
|
50
|
+
colour: role.hexColor,
|
|
51
|
+
count: complete ? (roleCounts.get(role.id) ?? 0) : null,
|
|
52
|
+
})),
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
async members(input) {
|
|
56
|
+
const guild = guildFor(input.guildId);
|
|
57
|
+
const limit = input.limit ?? 15;
|
|
58
|
+
const search = input.search?.toLowerCase() ?? "";
|
|
59
|
+
const complete = guild.members.cache.size >= guild.memberCount;
|
|
60
|
+
if (!complete && !input.roleId) {
|
|
61
|
+
// Discord supports a bounded member page or prefix search; never request an entire guild chunk.
|
|
62
|
+
if (search) {
|
|
63
|
+
if (input.cursor)
|
|
64
|
+
throw new BotControlValidationError("Search does not accept a page cursor.");
|
|
65
|
+
const found = /^\d{17,20}$/u.test(search)
|
|
66
|
+
? [
|
|
67
|
+
await guild.members
|
|
68
|
+
.fetch({ user: search, force: false })
|
|
69
|
+
.catch((error) => {
|
|
70
|
+
if (error &&
|
|
71
|
+
typeof error === "object" &&
|
|
72
|
+
"code" in error &&
|
|
73
|
+
error.code === 10007)
|
|
74
|
+
return null;
|
|
75
|
+
throw error;
|
|
76
|
+
}),
|
|
77
|
+
].filter((value) => value !== null)
|
|
78
|
+
: [
|
|
79
|
+
...(await guild.members.search({ query: input.search, limit })).values(),
|
|
80
|
+
];
|
|
81
|
+
return {
|
|
82
|
+
items: found.map(memberProjection),
|
|
83
|
+
nextCursor: null,
|
|
84
|
+
total: null,
|
|
85
|
+
complete: false,
|
|
86
|
+
scope: "server",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (input.cursor && !/^\d{17,20}$/u.test(input.cursor))
|
|
90
|
+
throw new BotControlValidationError("Invalid member cursor.");
|
|
91
|
+
const rows = [
|
|
92
|
+
...(await guild.members.list({
|
|
93
|
+
limit,
|
|
94
|
+
...(input.cursor ? { after: input.cursor } : {}),
|
|
95
|
+
})).values(),
|
|
96
|
+
];
|
|
97
|
+
return {
|
|
98
|
+
items: rows.map(memberProjection),
|
|
99
|
+
nextCursor: rows.length === limit ? rows.at(-1).id : null,
|
|
100
|
+
total: guild.memberCount,
|
|
101
|
+
complete: true,
|
|
102
|
+
scope: "server",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const scopeKey = JSON.stringify([
|
|
106
|
+
input.guildId,
|
|
107
|
+
search,
|
|
108
|
+
input.roleId ?? "",
|
|
109
|
+
limit,
|
|
110
|
+
]);
|
|
111
|
+
let offset = 0;
|
|
112
|
+
if (input.cursor) {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(Buffer.from(input.cursor, "base64url").toString());
|
|
115
|
+
if (parsed.scope !== scopeKey ||
|
|
116
|
+
!Number.isSafeInteger(parsed.offset) ||
|
|
117
|
+
parsed.offset < 0)
|
|
118
|
+
throw new Error();
|
|
119
|
+
offset = parsed.offset;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new BotControlValidationError("Invalid member cursor.");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const rows = [...guild.members.cache.values()]
|
|
126
|
+
.filter((member) => (!input.roleId || member.roles.cache.has(input.roleId)) &&
|
|
127
|
+
(!search ||
|
|
128
|
+
`${member.id} ${member.user.username} ${member.displayName}`
|
|
129
|
+
.toLowerCase()
|
|
130
|
+
.includes(search)))
|
|
131
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
132
|
+
return {
|
|
133
|
+
items: rows.slice(offset, offset + limit).map(memberProjection),
|
|
134
|
+
nextCursor: offset + limit < rows.length
|
|
135
|
+
? Buffer.from(JSON.stringify({ scope: scopeKey, offset: offset + limit })).toString("base64url")
|
|
136
|
+
: null,
|
|
137
|
+
total: rows.length,
|
|
138
|
+
complete,
|
|
139
|
+
scope: complete ? "server" : "cached",
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
async member(guildId, memberId) {
|
|
143
|
+
const guild = guildFor(guildId);
|
|
144
|
+
let member = guild.members.cache.get(memberId) ?? null;
|
|
145
|
+
let absent = false;
|
|
146
|
+
if (!member) {
|
|
147
|
+
try {
|
|
148
|
+
member = await guild.members.fetch({ user: memberId, force: false });
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (error &&
|
|
152
|
+
typeof error === "object" &&
|
|
153
|
+
"code" in error &&
|
|
154
|
+
error.code === 10007)
|
|
155
|
+
absent = true;
|
|
156
|
+
else
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// A missing cached ban is unknown. One explicit profile request can check this exact user.
|
|
161
|
+
let banStatus = guild.bans.cache.has(memberId)
|
|
162
|
+
? "banned"
|
|
163
|
+
: "unknown";
|
|
164
|
+
if (guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) {
|
|
165
|
+
try {
|
|
166
|
+
await guild.bans.fetch({ user: memberId, force: true });
|
|
167
|
+
banStatus = "banned";
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (error &&
|
|
171
|
+
typeof error === "object" &&
|
|
172
|
+
"code" in error &&
|
|
173
|
+
error.code === 10026)
|
|
174
|
+
banStatus = "not_banned";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!member && !absent)
|
|
178
|
+
throw new BotControlNotFoundError("Member information is unavailable.");
|
|
179
|
+
return { member: member ? memberProjection(member) : null, banStatus };
|
|
180
|
+
},
|
|
181
|
+
async invites(guildId, fresh = false) {
|
|
182
|
+
const cached = inviteCache.get(guildId);
|
|
183
|
+
if (!fresh && cached && cached.expires > Date.now())
|
|
184
|
+
return cached.request;
|
|
185
|
+
const request = guildFor(guildId)
|
|
186
|
+
.invites.fetch()
|
|
187
|
+
.then((invites) => [...invites.values()].map((invite) => ({
|
|
188
|
+
code: invite.code,
|
|
189
|
+
channelId: invite.channelId,
|
|
190
|
+
channelName: invite.channel?.name ?? null,
|
|
191
|
+
inviterId: invite.inviterId,
|
|
192
|
+
inviterIsBot: invite.inviter?.bot ?? null,
|
|
193
|
+
uses: invite.uses ?? 0,
|
|
194
|
+
maxUses: invite.maxUses ?? 0,
|
|
195
|
+
expiresAt: invite.expiresAt?.toISOString() ?? null,
|
|
196
|
+
})));
|
|
197
|
+
inviteCache.set(guildId, { expires: Date.now() + 60_000, request });
|
|
198
|
+
void request.catch(() => {
|
|
199
|
+
if (inviteCache.get(guildId)?.request === request)
|
|
200
|
+
inviteCache.delete(guildId);
|
|
201
|
+
});
|
|
202
|
+
while (inviteCache.size > 200)
|
|
203
|
+
inviteCache.delete(inviteCache.keys().next().value);
|
|
204
|
+
return request;
|
|
205
|
+
},
|
|
206
|
+
async revoke(guildId, code) {
|
|
207
|
+
await guildFor(guildId).invites.delete(code, "Standalone invite revoked through the Helyx dashboard");
|
|
208
|
+
inviteCache.delete(guildId);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=server-overview-discord.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ManagedResourceContext, type PermissionActor, type ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
import type { OverviewMember } from "@helyx/control-protocol";
|
|
3
|
+
/** Canonical profile resources reuse the member snapshot already read for this request.
|
|
4
|
+
* They cannot silently add Discord lookups as more fields are added to a module. */
|
|
5
|
+
export declare function overviewProfileContext(context: ManagedResourceContext, memberId: string, member: OverviewMember | null): ManagedResourceContext;
|
|
6
|
+
/** Overview readers have no Discord write capability; actions stay in module routes. */
|
|
7
|
+
export declare function overviewReadContext(guildId: string, actor: PermissionActor, services: ServiceAccess): ManagedResourceContext;
|
|
8
|
+
//# sourceMappingURL=server-overview-profile.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
/** Canonical profile resources reuse the member snapshot already read for this request.
|
|
3
|
+
* They cannot silently add Discord lookups as more fields are added to a module. */
|
|
4
|
+
export function overviewProfileContext(context, memberId, member) {
|
|
5
|
+
const scoped = (query) => {
|
|
6
|
+
if (query.guildId !== context.guildId ||
|
|
7
|
+
query.userIds.some((id) => id !== memberId))
|
|
8
|
+
throw new Error("Overview profile lookup is outside its member scope.");
|
|
9
|
+
};
|
|
10
|
+
const replacements = {
|
|
11
|
+
resolveMembers(query) {
|
|
12
|
+
scoped(query);
|
|
13
|
+
return Promise.resolve(query.userIds.map((userId) => ({
|
|
14
|
+
userId,
|
|
15
|
+
displayName: member?.displayName ?? null,
|
|
16
|
+
...(member ? { isBot: member.bot } : {}),
|
|
17
|
+
...(member?.avatarUrl ? { avatarUrl: member.avatarUrl } : {}),
|
|
18
|
+
})));
|
|
19
|
+
},
|
|
20
|
+
resolveMemberSnapshots(query) {
|
|
21
|
+
scoped(query);
|
|
22
|
+
return Promise.resolve(query.userIds.map((userId) => ({
|
|
23
|
+
userId,
|
|
24
|
+
exists: member !== null,
|
|
25
|
+
isBot: member?.bot ?? false,
|
|
26
|
+
roleIds: member?.roles.map((role) => role.id) ?? [],
|
|
27
|
+
joinedAt: member?.joinedAt ? new Date(member.joinedAt) : null,
|
|
28
|
+
})));
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const services = {
|
|
32
|
+
has: (name) => context.services.has(name),
|
|
33
|
+
get(name) {
|
|
34
|
+
if (name !== HELYX_SERVICE_NAMES.discordResources)
|
|
35
|
+
return context.services.get(name);
|
|
36
|
+
return new Proxy(replacements, {
|
|
37
|
+
get(target, property) {
|
|
38
|
+
if (Object.hasOwn(target, property))
|
|
39
|
+
return Reflect.get(target, property);
|
|
40
|
+
throw new Error("Additional Discord lookups are unavailable in an Overview profile.");
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
return { ...context, services };
|
|
46
|
+
}
|
|
47
|
+
/** Overview readers have no Discord write capability; actions stay in module routes. */
|
|
48
|
+
export function overviewReadContext(guildId, actor, services) {
|
|
49
|
+
const denied = () => Promise.reject(new Error("Overview reads cannot change Discord resources."));
|
|
50
|
+
return {
|
|
51
|
+
guildId,
|
|
52
|
+
actor,
|
|
53
|
+
services,
|
|
54
|
+
authorizationScope: "full",
|
|
55
|
+
correlationId: crypto.randomUUID(),
|
|
56
|
+
sendToChannel: denied,
|
|
57
|
+
deleteChannelMessage: denied,
|
|
58
|
+
editChannelMessage: denied,
|
|
59
|
+
addChannelMessageReaction: denied,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=server-overview-profile.js.map
|