@minesa-org/mini-interaction 0.4.4 → 0.4.6

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.
@@ -1,3 +1,4 @@
1
+ import { type DiscordUser, type OAuthTokens } from "../oauth/DiscordOAuth.js";
1
2
  type TimeoutConfig = {
2
3
  initialResponseTimeout?: number;
3
4
  autoDeferSlowOperations?: boolean;
@@ -14,6 +15,17 @@ export type MiniInteractionOptions = {
14
15
  publicKey?: string;
15
16
  applicationId?: string;
16
17
  token?: string;
18
+ guildId?: string;
19
+ };
20
+ type OAuthPageTemplate = {
21
+ htmlFile: string;
22
+ };
23
+ type OAuthCallbackTemplates = {
24
+ success: OAuthPageTemplate;
25
+ missingCode: OAuthPageTemplate;
26
+ oauthError: OAuthPageTemplate;
27
+ invalidState: OAuthPageTemplate;
28
+ serverError: OAuthPageTemplate;
17
29
  };
18
30
  type NodeRequest = {
19
31
  body?: unknown;
@@ -22,6 +34,7 @@ type NodeRequest = {
22
34
  get(name: string): string | null;
23
35
  };
24
36
  method?: string;
37
+ url?: string;
25
38
  [Symbol.asyncIterator]?: () => AsyncIterableIterator<Uint8Array>;
26
39
  on?: (event: string, listener: (...args: unknown[]) => void) => void;
27
40
  };
@@ -40,6 +53,22 @@ export declare class MiniInteraction {
40
53
  private loadedModulesPromise?;
41
54
  constructor(options?: MiniInteractionOptions);
42
55
  createNodeHandler(): (req: NodeRequest, res: NodeResponse) => Promise<void>;
56
+ registerCommands(tokenOverride?: string): Promise<unknown>;
57
+ discordOAuthVerificationPage(options: {
58
+ htmlFile: string;
59
+ scopes?: string[];
60
+ }): (req: NodeRequest, res: NodeResponse) => Promise<void>;
61
+ connectedOAuthPage(htmlFile: string): OAuthPageTemplate;
62
+ failedOAuthPage(htmlFile: string): OAuthPageTemplate;
63
+ discordOAuthCallback(options: {
64
+ templates: OAuthCallbackTemplates;
65
+ onAuthorize?: (payload: {
66
+ user: DiscordUser;
67
+ tokens: OAuthTokens;
68
+ req: NodeRequest;
69
+ res: NodeResponse;
70
+ }) => Promise<void> | void;
71
+ }): (req: NodeRequest, res: NodeResponse) => Promise<void>;
43
72
  private dispatch;
44
73
  private executeCommandHandler;
45
74
  private executeComponentHandler;
@@ -54,11 +83,17 @@ export declare class MiniInteraction {
54
83
  private isImportableModule;
55
84
  private isInteractionCommand;
56
85
  private getCommandName;
86
+ private resolveCommandPayload;
57
87
  private isCustomIdHandler;
58
88
  private looksLikeModalFile;
59
89
  private readRawBody;
60
90
  private getHeader;
61
91
  private sendJson;
92
+ private loadHtmlFile;
93
+ private renderOAuthTemplate;
94
+ private sendHtml;
95
+ private getOAuthConfig;
96
+ private getCookie;
62
97
  }
63
98
  export declare const LegacyMiniInteractionAdapter: typeof MiniInteraction;
64
99
  export {};
@@ -1,4 +1,4 @@
1
- import { readdir } from "node:fs/promises";
1
+ import { readFile, readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { ApplicationCommandType, InteractionResponseType, InteractionType, } from "discord-api-types/v10";
@@ -8,6 +8,7 @@ import { createMessageComponentInteraction } from "../utils/MessageComponentInte
8
8
  import { createModalSubmitInteraction } from "../utils/ModalSubmitInteraction.js";
9
9
  import { DiscordRestClient } from "../core/http/DiscordRestClient.js";
10
10
  import { verifyAndParseInteraction } from "../core/interactions/InteractionVerifier.js";
11
+ import { generateOAuthUrl, getDiscordUser, getOAuthTokens, } from "../oauth/DiscordOAuth.js";
11
12
  export class MiniInteraction {
12
13
  options;
13
14
  projectRoot;
@@ -71,6 +72,90 @@ export class MiniInteraction {
71
72
  }
72
73
  };
73
74
  }
75
+ async registerCommands(tokenOverride) {
76
+ const modules = await this.loadModules();
77
+ const payload = modules.commands.map((command) => this.resolveCommandPayload(command));
78
+ const applicationId = this.options.applicationId ??
79
+ process.env.DISCORD_APPLICATION_ID ??
80
+ process.env.DISCORD_APP_ID;
81
+ if (!applicationId) {
82
+ throw new Error("[MiniInteraction] Missing applicationId for command registration.");
83
+ }
84
+ const token = tokenOverride ??
85
+ this.options.token ??
86
+ process.env.DISCORD_BOT_TOKEN ??
87
+ process.env.DISCORD_TOKEN;
88
+ if (!token) {
89
+ throw new Error("[MiniInteraction] Missing bot token for command registration.");
90
+ }
91
+ const rest = new DiscordRestClient({ applicationId, token });
92
+ const guildId = this.options.guildId ?? process.env.DISCORD_GUILD_ID;
93
+ const route = guildId
94
+ ? `/applications/${applicationId}/guilds/${guildId}/commands`
95
+ : `/applications/${applicationId}/commands`;
96
+ if (this.options.debug) {
97
+ console.debug(`[MiniInteraction] Registering ${payload.length} command(s) on ${guildId ? `guild ${guildId}` : "global"} scope.`);
98
+ }
99
+ return rest.request(route, {
100
+ method: "PUT",
101
+ body: JSON.stringify(payload),
102
+ });
103
+ }
104
+ discordOAuthVerificationPage(options) {
105
+ return async (_req, res) => {
106
+ const oauthConfig = this.getOAuthConfig();
107
+ const { url, state } = generateOAuthUrl(oauthConfig, options.scopes ?? [
108
+ "applications.commands",
109
+ "identify",
110
+ "guilds",
111
+ "role_connections.write",
112
+ ]);
113
+ const html = await this.loadHtmlFile(options.htmlFile);
114
+ const rendered = html.replaceAll("{{OAUTH_URL_RAW}}", url);
115
+ res.setHeader?.("Set-Cookie", `mini_oauth_state=${encodeURIComponent(state)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=900`);
116
+ this.sendHtml(res, 200, rendered);
117
+ };
118
+ }
119
+ connectedOAuthPage(htmlFile) {
120
+ return { htmlFile };
121
+ }
122
+ failedOAuthPage(htmlFile) {
123
+ return { htmlFile };
124
+ }
125
+ discordOAuthCallback(options) {
126
+ return async (req, res) => {
127
+ try {
128
+ const requestUrl = new URL(req.url ?? "/", process.env.DISCORD_REDIRECT_URI ?? "http://localhost");
129
+ const error = requestUrl.searchParams.get("error");
130
+ const code = requestUrl.searchParams.get("code");
131
+ const state = requestUrl.searchParams.get("state");
132
+ const cookieState = this.getCookie(req, "mini_oauth_state");
133
+ if (error) {
134
+ await this.renderOAuthTemplate(res, options.templates.oauthError);
135
+ return;
136
+ }
137
+ if (!code) {
138
+ await this.renderOAuthTemplate(res, options.templates.missingCode);
139
+ return;
140
+ }
141
+ if (state && cookieState && state !== cookieState) {
142
+ await this.renderOAuthTemplate(res, options.templates.invalidState);
143
+ return;
144
+ }
145
+ const tokens = await getOAuthTokens(code, this.getOAuthConfig());
146
+ const user = await getDiscordUser(tokens.access_token);
147
+ await options.onAuthorize?.({ user, tokens, req, res });
148
+ res.setHeader?.("Set-Cookie", "mini_oauth_state=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
149
+ await this.renderOAuthTemplate(res, options.templates.success);
150
+ }
151
+ catch (error) {
152
+ if (this.options.debug) {
153
+ console.error("[MiniInteraction] discordOAuthCallback failed", error);
154
+ }
155
+ await this.renderOAuthTemplate(res, options.templates.serverError);
156
+ }
157
+ };
158
+ }
74
159
  async dispatch(interaction) {
75
160
  const modules = await this.loadModules();
76
161
  if (interaction.type === InteractionType.ApplicationCommand) {
@@ -118,8 +203,13 @@ export class MiniInteraction {
118
203
  }
119
204
  async runWithResponseLifecycle(interaction, executor) {
120
205
  let ackResponse;
206
+ let initialResponseCommitted = false;
121
207
  const helpers = {
122
- canRespond: (interactionId) => (this.responseStates.get(interactionId) ?? "pending") === "pending",
208
+ // Legacy helper contracts use canRespond for both initial acknowledgements
209
+ // and later editReply/followUp calls. The compat layer does not currently
210
+ // track Discord token expiry, so we only block on real expiry outside of
211
+ // this helper and allow the wrapped interaction methods to complete.
212
+ canRespond: (_interactionId) => true,
123
213
  trackResponse: (interactionId, _token, state) => {
124
214
  this.responseStates.set(interactionId, state);
125
215
  },
@@ -127,6 +217,14 @@ export class MiniInteraction {
127
217
  ackResponse = response;
128
218
  },
129
219
  sendFollowUp: async (token, response, messageId) => {
220
+ // If the initial interaction response has not been sent yet, collapse the
221
+ // deferred/edit flow back into a single immediate response instead of
222
+ // calling the webhook endpoints early.
223
+ if (!initialResponseCommitted) {
224
+ ackResponse = response;
225
+ this.responseStates.set(interaction.id, "responded");
226
+ return;
227
+ }
130
228
  const responseData = "data" in response ? response.data ?? {} : {};
131
229
  if (messageId === "@original") {
132
230
  await this.rest.editOriginal(token, responseData);
@@ -162,6 +260,7 @@ export class MiniInteraction {
162
260
  if (this.options.debug || this.options.timeoutConfig?.enableResponseDebugLogging) {
163
261
  console.debug(`[MiniInteraction] Interaction ${interaction.id} completed with ${result ? "explicit" : "fallback"} response.`);
164
262
  }
263
+ initialResponseCommitted = true;
165
264
  return result ?? ackResponse;
166
265
  }
167
266
  finally {
@@ -271,6 +370,13 @@ export class MiniInteraction {
271
370
  }
272
371
  return data.name;
273
372
  }
373
+ resolveCommandPayload(command) {
374
+ const data = command.data;
375
+ if (typeof data.toJSON === "function") {
376
+ return data.toJSON();
377
+ }
378
+ return command.data;
379
+ }
274
380
  isCustomIdHandler(value) {
275
381
  return (typeof value === "object" &&
276
382
  value !== null &&
@@ -332,5 +438,44 @@ export class MiniInteraction {
332
438
  res.setHeader?.("Content-Type", "application/json; charset=utf-8");
333
439
  res.end(JSON.stringify(body));
334
440
  }
441
+ async loadHtmlFile(htmlFile) {
442
+ const absolutePath = path.resolve(this.projectRoot, htmlFile);
443
+ return readFile(absolutePath, "utf8");
444
+ }
445
+ async renderOAuthTemplate(res, template) {
446
+ const html = await this.loadHtmlFile(template.htmlFile);
447
+ this.sendHtml(res, 200, html);
448
+ }
449
+ sendHtml(res, statusCode, html) {
450
+ if (typeof res.status === "function" && typeof res.end === "function") {
451
+ res.status(statusCode);
452
+ }
453
+ res.statusCode = statusCode;
454
+ res.setHeader?.("Content-Type", "text/html; charset=utf-8");
455
+ res.end(html);
456
+ }
457
+ getOAuthConfig() {
458
+ const appId = this.options.applicationId ??
459
+ process.env.DISCORD_APPLICATION_ID ??
460
+ process.env.DISCORD_APP_ID;
461
+ const appSecret = process.env.DISCORD_CLIENT_SECRET ?? process.env.DISCORD_APPLICATION_SECRET;
462
+ const redirectUri = process.env.DISCORD_REDIRECT_URI;
463
+ if (!appId || !appSecret || !redirectUri) {
464
+ throw new Error("[MiniInteraction] Missing OAuth config. Expected DISCORD_APPLICATION_ID, DISCORD_CLIENT_SECRET and DISCORD_REDIRECT_URI.");
465
+ }
466
+ return { appId, appSecret, redirectUri };
467
+ }
468
+ getCookie(req, name) {
469
+ const cookieHeader = this.getHeader(req.headers, "cookie");
470
+ if (!cookieHeader)
471
+ return undefined;
472
+ for (const rawPart of cookieHeader.split(";")) {
473
+ const [rawKey, ...rawValue] = rawPart.trim().split("=");
474
+ if (rawKey === name) {
475
+ return decodeURIComponent(rawValue.join("="));
476
+ }
477
+ }
478
+ return undefined;
479
+ }
335
480
  }
336
481
  export const LegacyMiniInteractionAdapter = MiniInteraction;
@@ -4,7 +4,7 @@ import type { APICheckboxComponent } from "./checkbox.js";
4
4
  /** Defines a component structure for use in ActionRow builders. */
5
5
  export type ActionRowComponent = APIComponentInActionRow | APIRadioComponent | APICheckboxComponent;
6
6
  /** Defines a message component structure for use in message builders. */
7
- export type MessageActionRowComponent = APIComponentInMessageActionRow | APIRadioComponent | APICheckboxComponent;
7
+ export type MessageActionRowComponent = APIComponentInMessageActionRow;
8
8
  /** Structure for an action row containing mini-interaction components. */
9
9
  export interface MiniActionRow<T extends ActionRowComponent = ActionRowComponent> {
10
10
  type: 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minesa-org/mini-interaction",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Mini interaction, connecting your app with Discord via HTTP-interaction (Vercel support).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",