@helyx/bot 1.6.0 → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/command-root-catalogue.json +13 -5
  3. package/dist/cloud-connector.js +2 -0
  4. package/dist/cloud-control-dispatcher.js +20 -0
  5. package/dist/command-registration-policy.js +1 -0
  6. package/dist/control-directory-contracts.d.ts +50 -0
  7. package/dist/control-directory-contracts.js +2 -0
  8. package/dist/control-server-schemas.d.ts +3 -3
  9. package/dist/control-server.d.ts +10 -46
  10. package/dist/control-server.js +50 -3
  11. package/dist/creator-invite-service.d.ts +7 -0
  12. package/dist/creator-invite-service.js +17 -0
  13. package/dist/discord-guild-health.d.ts +2 -2
  14. package/dist/events.d.ts +1 -0
  15. package/dist/events.js +2 -0
  16. package/dist/help-credentials.d.ts +25 -0
  17. package/dist/help-credentials.js +149 -0
  18. package/dist/help-module.d.ts +27 -0
  19. package/dist/help-module.js +288 -0
  20. package/dist/help-runtime.d.ts +3 -0
  21. package/dist/help-runtime.js +42 -0
  22. package/dist/help-transport.d.ts +53 -0
  23. package/dist/help-transport.js +267 -0
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.js +6 -0
  26. package/dist/interactions.js +10 -6
  27. package/dist/server-overview-control.d.ts +35 -0
  28. package/dist/server-overview-control.js +362 -0
  29. package/dist/server-overview-discord.d.ts +33 -0
  30. package/dist/server-overview-discord.js +212 -0
  31. package/dist/server-overview-profile.d.ts +8 -0
  32. package/dist/server-overview-profile.js +62 -0
  33. package/dist/server-overview-routes.d.ts +4 -0
  34. package/dist/server-overview-routes.js +41 -0
  35. package/dist/shard-contracts.d.ts +66 -0
  36. package/dist/shard-contracts.js +23 -0
  37. package/dist/shard-coordinator.d.ts +1 -1
  38. package/dist/shard-router.d.ts +9 -0
  39. package/dist/shard-router.js +6 -0
  40. package/dist/shard-supervisor.d.ts +1 -1
  41. package/dist/shard-supervisor.js +4 -0
  42. package/dist/shard-worker.d.ts +1 -1
  43. package/dist/shard-worker.js +4 -0
  44. package/dist/verification-service.d.ts +4 -0
  45. package/dist/verification-service.js +10 -0
  46. package/documentation/README.md +1 -0
  47. package/documentation/guides/help-command.md +62 -0
  48. package/package.json +6 -6
@@ -0,0 +1,267 @@
1
+ import { z } from "zod";
2
+ import { createHelpRuntimeCollection, HELP_DOCS_URL, } from "@helyx/core";
3
+ import { HELP_API_ORIGIN, parseHelpCredential, } from "./help-credentials.js";
4
+ export class HelpTransportError extends Error {
5
+ code;
6
+ constructor(code = "unavailable") {
7
+ super(`Help ${code.replaceAll("_", " ")}`);
8
+ this.code = code;
9
+ }
10
+ }
11
+ const responseSchema = z
12
+ .object({
13
+ contractVersion: z.literal(1),
14
+ requestId: z.string().uuid(),
15
+ revision: z.string().regex(/^[a-f0-9]{64}$/u),
16
+ mode: z.enum(["lexical", "hybrid"]),
17
+ kind: z.enum(["answer", "choices", "no_match", "invalid"]),
18
+ degraded: z
19
+ .enum(["provider_disabled", "provider_unavailable", "budget_exhausted"])
20
+ .optional(),
21
+ faq: z.unknown().optional(),
22
+ faqs: z.array(z.unknown()).max(3).optional(),
23
+ choiceToken: z.string().max(2048).optional(),
24
+ reason: z.string().optional(),
25
+ docsUrl: z.string().optional(),
26
+ })
27
+ .strict();
28
+ function parseResponse(value) {
29
+ const parsed = responseSchema.parse(value), base = {
30
+ mode: parsed.mode,
31
+ revision: parsed.revision,
32
+ ...(parsed.degraded ? { degraded: parsed.degraded } : {}),
33
+ };
34
+ if (parsed.kind === "answer") {
35
+ const faqs = createHelpRuntimeCollection([
36
+ parsed.faq,
37
+ ]).faqs;
38
+ return { ...base, kind: "answer", faq: faqs[0] };
39
+ }
40
+ if (parsed.kind === "choices") {
41
+ if (!parsed.faqs?.length || !parsed.choiceToken)
42
+ throw new HelpTransportError();
43
+ const input = parsed.faqs;
44
+ const valid = new Map(createHelpRuntimeCollection(input).faqs.map((faq) => [faq.id, faq]));
45
+ const faqs = input.map((faq) => valid.get(faq.id));
46
+ return { ...base, kind: "choices", faqs, choiceToken: parsed.choiceToken };
47
+ }
48
+ if (parsed.kind === "no_match" &&
49
+ ["empty", "weak_match"].includes(parsed.reason ?? "") &&
50
+ parsed.docsUrl === HELP_DOCS_URL)
51
+ return {
52
+ ...base,
53
+ kind: "no_match",
54
+ reason: parsed.reason,
55
+ docsUrl: HELP_DOCS_URL,
56
+ };
57
+ if (parsed.kind === "invalid" && parsed.reason === "too_long")
58
+ return { ...base, kind: "invalid", reason: "too_long" };
59
+ throw new HelpTransportError();
60
+ }
61
+ export class HttpHelpTransport {
62
+ options;
63
+ blocked = false;
64
+ blockedUntil = 0;
65
+ failures = 0;
66
+ origin;
67
+ controller = new AbortController();
68
+ constructor(options) {
69
+ this.options = options;
70
+ const url = new URL(options.baseUrl ?? HELP_API_ORIGIN);
71
+ if (url.username ||
72
+ url.password ||
73
+ url.pathname !== "/" ||
74
+ url.search ||
75
+ url.hash ||
76
+ (url.protocol !== "https:" &&
77
+ !(url.protocol === "http:" &&
78
+ ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) ||
79
+ (options.kind === "self-host" && url.origin !== HELP_API_ORIGIN))
80
+ throw new HelpTransportError();
81
+ this.origin = url.origin;
82
+ }
83
+ query(input) {
84
+ return this.request("query", {
85
+ question: input.question,
86
+ ...(input.module ? { module: input.module } : {}),
87
+ }, input);
88
+ }
89
+ choose(input) {
90
+ return this.request("choose", { choiceToken: input.choiceToken, faqId: input.faqId }, input);
91
+ }
92
+ close() {
93
+ this.controller.abort();
94
+ }
95
+ async request(path, body, identity) {
96
+ if (this.blocked ||
97
+ Date.now() < this.blockedUntil ||
98
+ this.controller.signal.aborted)
99
+ throw new HelpTransportError();
100
+ try {
101
+ const response = await (this.options.fetch ?? fetch)(`${this.origin}/v1/help/${path}`, {
102
+ method: "POST",
103
+ redirect: "error",
104
+ signal: AbortSignal.any([
105
+ this.controller.signal,
106
+ AbortSignal.timeout(6000),
107
+ ]),
108
+ headers: {
109
+ Authorization: `Bearer ${this.options.token}`,
110
+ "Content-Type": "application/json",
111
+ },
112
+ body: JSON.stringify({
113
+ contractVersion: 1,
114
+ locale: "en-GB",
115
+ botVersion: this.options.botVersion,
116
+ ...body,
117
+ ...(this.options.kind === "hosted"
118
+ ? {
119
+ subject: {
120
+ userId: identity.userId,
121
+ ...(identity.guildId ? { guildId: identity.guildId } : {}),
122
+ },
123
+ }
124
+ : {}),
125
+ }),
126
+ });
127
+ if (response.status === 401 || response.status === 403)
128
+ this.blocked = true;
129
+ if (response.status === 429)
130
+ this.blockedUntil =
131
+ Date.now() +
132
+ Math.min(3600, Math.max(60, Number(response.headers.get("Retry-After")) || 60)) *
133
+ 1000;
134
+ if (!response.ok) {
135
+ await response.body?.cancel();
136
+ if (response.status === 410)
137
+ throw new HelpTransportError("expired_choice");
138
+ throw new HelpTransportError();
139
+ }
140
+ const value = parseResponse(await boundedJson(response));
141
+ this.failures = 0;
142
+ return value;
143
+ }
144
+ catch (error) {
145
+ if (error instanceof HelpTransportError &&
146
+ error.code === "expired_choice")
147
+ throw error;
148
+ if (++this.failures >= 3)
149
+ this.blockedUntil = Math.max(this.blockedUntil, Date.now() + 30000);
150
+ throw new HelpTransportError();
151
+ }
152
+ }
153
+ }
154
+ /** Read per request so local disconnect/replacement takes effect without a restart. */
155
+ export class FileHelpTransport {
156
+ store;
157
+ botVersion;
158
+ fetcher;
159
+ current = null;
160
+ constructor(store, botVersion, fetcher) {
161
+ this.store = store;
162
+ this.botVersion = botVersion;
163
+ this.fetcher = fetcher;
164
+ }
165
+ async transport() {
166
+ const credential = await this.store.read();
167
+ if (!credential || Date.parse(credential.expiresAt) <= Date.now()) {
168
+ this.current?.transport.close();
169
+ this.current = null;
170
+ throw new HelpTransportError();
171
+ }
172
+ if (this.current?.token !== credential.token) {
173
+ this.current?.transport.close();
174
+ this.current = {
175
+ token: credential.token,
176
+ transport: new HttpHelpTransport({
177
+ token: credential.token,
178
+ kind: "self-host",
179
+ botVersion: this.botVersion,
180
+ ...(this.fetcher ? { fetch: this.fetcher } : {}),
181
+ }),
182
+ };
183
+ }
184
+ return this.current.transport;
185
+ }
186
+ async query(input) {
187
+ return (await this.transport()).query(input);
188
+ }
189
+ async choose(input) {
190
+ return (await this.transport()).choose(input);
191
+ }
192
+ close() {
193
+ this.current?.transport.close();
194
+ this.current = null;
195
+ }
196
+ }
197
+ export async function exchangeHelpEnrolment(code, fetcher = fetch) {
198
+ if (!/^hhe_[A-Za-z0-9_-]{43}$/u.test(code))
199
+ throw new HelpTransportError();
200
+ try {
201
+ const response = await fetcher(`${HELP_API_ORIGIN}/v1/help/enrolments/exchange`, {
202
+ method: "POST",
203
+ redirect: "error",
204
+ signal: AbortSignal.timeout(10000),
205
+ headers: { "Content-Type": "application/json" },
206
+ body: JSON.stringify({ code }),
207
+ });
208
+ if (!response.ok) {
209
+ await response.body?.cancel();
210
+ throw new HelpTransportError();
211
+ }
212
+ return parseHelpCredential(await boundedJson(response));
213
+ }
214
+ catch {
215
+ throw new HelpTransportError();
216
+ }
217
+ }
218
+ export async function revokeHelpCredential(token, fetcher = fetch) {
219
+ if (!/^hhp_[A-Za-z0-9_-]{43}$/u.test(token))
220
+ throw new HelpTransportError();
221
+ try {
222
+ const response = await fetcher(`${HELP_API_ORIGIN}/v1/help/disconnect`, {
223
+ method: "POST",
224
+ redirect: "error",
225
+ signal: AbortSignal.timeout(10000),
226
+ headers: {
227
+ Authorization: `Bearer ${token}`,
228
+ "Content-Type": "application/json",
229
+ },
230
+ body: JSON.stringify({ contractVersion: 1 }),
231
+ });
232
+ if (!response.ok) {
233
+ await response.body?.cancel();
234
+ throw new HelpTransportError();
235
+ }
236
+ z.object({ revoked: z.literal(true) })
237
+ .strict()
238
+ .parse(await boundedJson(response));
239
+ }
240
+ catch {
241
+ throw new HelpTransportError();
242
+ }
243
+ }
244
+ async function boundedJson(response) {
245
+ const max = 512 * 1024;
246
+ if (!response.body ||
247
+ Number(response.headers.get("Content-Length") ?? 0) > max)
248
+ throw new HelpTransportError();
249
+ const reader = response.body.getReader(), chunks = [];
250
+ let size = 0;
251
+ try {
252
+ while (true) {
253
+ const chunk = await reader.read();
254
+ if (chunk.done)
255
+ break;
256
+ const value = chunk.value;
257
+ if (!(value instanceof Uint8Array) || (size += value.byteLength) > max)
258
+ throw new HelpTransportError();
259
+ chunks.push(value);
260
+ }
261
+ }
262
+ finally {
263
+ await reader.cancel().catch(() => undefined);
264
+ }
265
+ return JSON.parse(Buffer.concat(chunks, size).toString("utf8"));
266
+ }
267
+ //# sourceMappingURL=help-transport.js.map
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { type ModuleDefinition } from "@helyx/sdk";
2
2
  import { GatewayIntentBits } from "discord.js";
3
3
  import { type ShardHealthSnapshot } from "./shard-contracts.js";
4
+ export { HelpCredentialStore } from "./help-credentials.js";
5
+ export { exchangeHelpEnrolment, revokeHelpCredential, } from "./help-transport.js";
4
6
  export { loadConfiguredModulePackages } from "./modules.js";
5
7
  export { assertConfiguredModuleDatabaseCompatibility } from "./modules.js";
6
8
  export { resolveModulePackageSpecifiers } from "./module-configuration.js";
package/dist/index.js CHANGED
@@ -36,6 +36,9 @@ import { createSupportModule } from "./support-module.js";
36
36
  import { createOAuthModule } from "./oauth-module.js";
37
37
  import { createPrivacyModule } from "./privacy-module.js";
38
38
  import { createProcessInfoModule } from "./process-info-module.js";
39
+ import { createRuntimeHelpModule } from "./help-runtime.js";
40
+ export { HelpCredentialStore } from "./help-credentials.js";
41
+ export { exchangeHelpEnrolment, revokeHelpCredential, } from "./help-transport.js";
39
42
  import { installModerationPlatformServices } from "./moderation-platform-services.js";
40
43
  import { EckoEvidenceReceiver } from "./ecko-evidence-receiver.js";
41
44
  import { EckoTransportAuthenticator } from "./ecko-transport-auth.js";
@@ -363,6 +366,7 @@ async function startUnshardedBot(processEnvironment) {
363
366
  const supportModule = createSupportModule(cloudConnector);
364
367
  const privacyModule = createPrivacyModule();
365
368
  const processInfoModule = createProcessInfoModule("unsharded bot process");
369
+ const helpModule = createRuntimeHelpModule(environment);
366
370
  const premiumEnvironment = requirePremiumProviderEnvironment(featureModules, environment.STRIPE_ENVIRONMENT);
367
371
  const premiumActivationModule = !premiumEnvironment || !database
368
372
  ? undefined
@@ -383,6 +387,7 @@ async function startUnshardedBot(processEnvironment) {
383
387
  supportModule,
384
388
  privacyModule,
385
389
  processInfoModule,
390
+ helpModule,
386
391
  ...(premiumActivationModule ? [premiumActivationModule] : []),
387
392
  ...(oauthModule ? [oauthModule] : []),
388
393
  ...(cloudManagementModule ? [cloudManagementModule] : []),
@@ -462,6 +467,7 @@ async function startUnshardedBot(processEnvironment) {
462
467
  supportModule,
463
468
  privacyModule,
464
469
  processInfoModule,
470
+ helpModule,
465
471
  ...(premiumActivationModule ? [premiumActivationModule] : []),
466
472
  ...(oauthModule ? [oauthModule] : []),
467
473
  ...(cloudManagementModule ? [cloudManagementModule] : []),
@@ -141,15 +141,19 @@ export function installInteractions(client, modules, registration, logger, servi
141
141
  const subcommand = resolveCommandSubcommand(registered?.contribution, subcommandName, groupName);
142
142
  const owner = resolveCommandOwner(registered, subcommandName, groupName);
143
143
  const ownerModuleId = owner?.moduleId ?? registered?.moduleId;
144
+ const autocompleteOwner = subcommand ??
145
+ (!hasNestedCommands(registered?.contribution)
146
+ ? registered?.contribution
147
+ : undefined);
144
148
  if (!registered ||
145
- !subcommand?.autocomplete ||
149
+ !autocompleteOwner?.autocomplete ||
146
150
  !ownerModuleId ||
147
151
  !(await authorizeAutocomplete(interaction, ownerModuleId, resolveDirectCommandPath(registered.contribution, subcommand, groupName).permissionId, services, registration.selfAuthorizingGuildModuleIds?.has(ownerModuleId) ??
148
- false, subcommand.authorization === "resource-scoped", isInstallationIndependentModule(modulesById, ownerModuleId), subcommand.authorization, requiredCommandModule(modulesById, ownerModuleId)))) {
152
+ false, autocompleteOwner.authorization === "resource-scoped", isInstallationIndependentModule(modulesById, ownerModuleId), autocompleteOwner.authorization, requiredCommandModule(modulesById, ownerModuleId)))) {
149
153
  await response.respond([]);
150
154
  return;
151
155
  }
152
- await subcommand.autocomplete(createAutocompleteContext(interaction, services, response.respond));
156
+ await autocompleteOwner.autocomplete(createAutocompleteContext(interaction, services, response.respond));
153
157
  }
154
158
  catch (error) {
155
159
  logger.error({
@@ -217,15 +221,15 @@ export function installInteractions(client, modules, registration, logger, servi
217
221
  const acknowledgement = subcommand?.acknowledgement ?? command.acknowledgement;
218
222
  const authorization = subcommand?.authorization ?? command.authorization;
219
223
  const rateLimit = subcommand?.rateLimit ?? command.rateLimit;
224
+ if (acknowledgement === "deferred-ephemeral") {
225
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
226
+ }
220
227
  if (installationIndependentCommand &&
221
228
  authorization === "public" &&
222
229
  !rateLimit) {
223
230
  await execute(createCommandContext(interaction, services));
224
231
  return;
225
232
  }
226
- if (acknowledgement === "deferred-ephemeral") {
227
- await interaction.deferReply({ flags: MessageFlags.Ephemeral });
228
- }
229
233
  if (await authorizeCommand(interaction, ownerModuleId, permissionId, rateLimit, services, registration.selfAuthorizingGuildModuleIds?.has(ownerModuleId) ??
230
234
  false, subcommand?.authorization === "resource-scoped", owner?.moduleName, installationIndependentCommand, authorization, requiredCommandModule(modulesById, ownerModuleId))) {
231
235
  const context = createCommandContext(interaction, services);
@@ -0,0 +1,35 @@
1
+ import { type OverviewReadInput, type OverviewResponse, type OverviewRevokeInput } from "@helyx/control-protocol";
2
+ import { type GuildAccessPolicy, type ManagedResourceContext, type ModuleDefinition, type PermissionActor, type PermissionPolicyService, type ServiceAccess } from "@helyx/sdk";
3
+ import type { OverviewDiscordDirectory } from "./server-overview-discord.js";
4
+ export declare function createServerOverviewControl(deps: {
5
+ modules: ReadonlyMap<string, ModuleDefinition>;
6
+ services: ServiceAccess;
7
+ permissions: PermissionPolicyService;
8
+ resolveActor(guildId: string, actorUserId: string): Promise<PermissionActor>;
9
+ readGuildPolicy(guildId: string): Promise<GuildAccessPolicy>;
10
+ listModules(guildId: string): Promise<readonly {
11
+ moduleId: string;
12
+ enabled: boolean;
13
+ }[]>;
14
+ requireAccessible(guildId: string, moduleId: string): Promise<void>;
15
+ directory?: OverviewDiscordDirectory | undefined;
16
+ auditInviteRevoke?(input: {
17
+ guildId: string;
18
+ actorUserId: string;
19
+ code: string;
20
+ outcome: "requested" | "revoked";
21
+ correlationId: string;
22
+ }): Promise<void>;
23
+ context(guildId: string, actor: PermissionActor): ManagedResourceContext;
24
+ }): {
25
+ invalidate(guildId: string): void;
26
+ readServerOverview(input: OverviewReadInput & {
27
+ actorUserId: string;
28
+ }): Promise<OverviewResponse>;
29
+ revokeServerOverviewInvite(input: OverviewRevokeInput & {
30
+ actorUserId: string;
31
+ }): Promise<{
32
+ revoked: true;
33
+ }>;
34
+ };
35
+ //# sourceMappingURL=server-overview-control.d.ts.map