feat: build out initial prototype (#1)
Some checks failed
Node.js CI / Lint and Test (push) Has been cancelled

### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #1
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #1.
This commit is contained in:
2025-08-11 19:40:09 -07:00
committed by Naomi Carrigan
parent cdaeff99d1
commit cd56334483
33 changed files with 6135 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { type Entitlement, EntitlementType } from "discord.js";
import { SKU } from "../interfaces/skus.js";
import type { Lynira } from "../interfaces/lynira.js";
/**
* Fetches an entitlement for a user. Only looks at the first ACTIVE entitlement.
* @param lynira - Lynira's Discord instance.
* @param userId - The ID of the user to fetch the entitlement for.
* @returns The entitlement if found, or null if not found.
*/
export const getEntitlement = async(
lynira: Lynira,
userId: string,
): Promise<Entitlement | null> => {
if (userId === "465650873650118659") {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Mock entitlement for Naomi.
return {
applicationId: lynira.discord.application?.id ?? "",
consumed: false,
deleted: false,
endsAt: null,
endsTimestamp: null,
guild: null,
guildId: null,
id: "enterprise-entitlement-id",
isActive: () => {
return true;
},
skuId: SKU.ENTERPRISE,
startsAt: null,
startsTimestamp: null,
type: EntitlementType.ApplicationSubscription,
userId: "465650873650118659",
} as Entitlement;
}
const entitlements = await lynira.discord.application?.entitlements.fetch({
excludeDeleted: true,
excludeEnded: true,
user: userId,
}).catch(() => {
return null;
});
return entitlements?.first() ?? null;
};

View File

@@ -0,0 +1,33 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { ContainerBuilder, TextDisplayBuilder } from "discord.js";
import type { Links } from "@prisma/client";
/**
* Generates an array of components for displaying users' short URLs.
* @param links - The array of links to display.
* @returns An array of ContainerBuilder components.
*/
export const getLinkComponents
= (links: Array<Links>): Array<ContainerBuilder> => {
const components: Array<ContainerBuilder> = [];
while (links.length > 0) {
const slice = links.splice(0, 50);
components.push(
new ContainerBuilder().
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
slice.map((link) => {
return `- \`${link.slug}\`: ${link.url}`;
}).
join("\n"),
),
),
);
}
return components;
};

View File

@@ -0,0 +1,32 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison -- We want this function to accept arbitrary SKU IDs. */
import { SKU } from "../interfaces/skus.js";
/**
* Get the URL limit for a given SKU.
* @param sku - The SKU to check.
* @returns The URL limit for the SKU. Returns 0 if the SKU is not recognized.
*/
export const getSkuLimit = (sku: SKU | string): number => {
if (sku === SKU.PERSONAL) {
return 10;
}
if (sku === SKU.PROFESSIONAL) {
return 25;
}
if (sku === SKU.BUSINESS) {
return 100;
}
if (sku === SKU.ORGANISATION) {
return 250;
}
if (sku === SKU.ENTERPRISE) {
return 1000;
}
return 0;
};

View File

@@ -0,0 +1,23 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags } from "discord.js";
import { handlers } from "../config/handlers.js";
import type { Command } from "../interfaces/command.js";
/**
* Process a command interaction.
* @param lynira - The Lynira instance.
* @param interaction - The interaction to process.
*/
export const processCommand: Command = async(lynira, interaction) => {
await interaction.deferReply({
flags: [ MessageFlags.Ephemeral ],
});
const { commandName } = interaction;
// eslint-disable-next-line no-underscore-dangle -- Accessing private property for command handler.
await (handlers[commandName] ?? handlers._default)(lynira, interaction);
};

View File

@@ -0,0 +1,48 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
MessageFlags,
TextDisplayBuilder,
type ChatInputCommandInteraction,
} from "discord.js";
import { SKU } from "../interfaces/skus.js";
/**
* Responds with a default image and a button to subscribe.
* @param interaction - The interaction object from Discord.
*/
export const sendUnentitledResponse = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
const components = [
new TextDisplayBuilder().setContent(
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Hmm, you do not seem to have an active subscription. If you wish to continue using my services, you will need to rectify that.",
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Premium).
setSKUId(SKU.PERSONAL),
new ButtonBuilder().
setStyle(ButtonStyle.Premium).
setSKUId(SKU.PROFESSIONAL),
new ButtonBuilder().
setStyle(ButtonStyle.Premium).
setSKUId(SKU.BUSINESS),
new ButtonBuilder().
setStyle(ButtonStyle.Premium).
setSKUId(SKU.ENTERPRISE),
),
];
await interaction.editReply({
components: components,
flags: [ MessageFlags.IsComponentsV2 ],
});
};