diff options
Diffstat (limited to 'packages/interactions/discord')
| -rw-r--r-- | packages/interactions/discord/commands.ts | 125 | ||||
| -rw-r--r-- | packages/interactions/discord/embeds.ts | 181 | ||||
| -rw-r--r-- | packages/interactions/discord/interfaces.ts | 86 | ||||
| -rw-r--r-- | packages/interactions/discord/responses.ts | 15 | ||||
| -rw-r--r-- | packages/interactions/discord/types.ts | 1 | ||||
| -rw-r--r-- | packages/interactions/discord/verification.ts | 24 |
6 files changed, 432 insertions, 0 deletions
diff --git a/packages/interactions/discord/commands.ts b/packages/interactions/discord/commands.ts new file mode 100644 index 0000000..601591b --- /dev/null +++ b/packages/interactions/discord/commands.ts @@ -0,0 +1,125 @@ +import type { DiscordCommand } from "./interfaces.ts"; + +export type { DiscordCommand }; + +export const HOT_COMMAND: DiscordCommand = { + name: "hot", + description: "Fetch a random hot post from r/okbuddyumamusume", +}; + +export const ROLEPLAY_COMMAND: DiscordCommand = { + name: "roleplay", + description: "Fetch a random hot roleplay post from r/okbuddyumamusume", +}; + +export const NSFW_COMMAND: DiscordCommand = { + name: "nsfw", + description: + "Fetch a random NSFW post from r/okbuddyumamusume (NSFW channels only)", +}; + +export const TOP_COMMAND: DiscordCommand = { + name: "top", + description: + "Fetch a random top post from r/okbuddyumamusume (defaults to today)", + options: [ + { + type: 3, + name: "time", + description: "Time period for top posts (defaults to today)", + required: false, + choices: [ + { + name: "Now", + value: "hour", + }, + { + name: "Today", + value: "day", + }, + { + name: "This Week", + value: "week", + }, + { + name: "This Month", + value: "month", + }, + { + name: "This Year", + value: "year", + }, + { + name: "All Time", + value: "all", + }, + ], + }, + ], +}; + +export const COMPLAIN_COMMAND: DiscordCommand = { + name: "complain", + description: "Submit a complaint to the moderators", + contexts: [0], + options: [ + { + type: 3, + name: "message", + description: "Your complaint message", + required: true, + }, + ], +}; + +export const APPEAL_COMMAND: DiscordCommand = { + name: "appeal", + description: "Submit an appeal to the moderators", + contexts: [0], + options: [ + { + type: 3, + name: "message", + description: "Your appeal message", + required: true, + }, + ], +}; + +export const COLOURS_COMMAND: DiscordCommand = { + name: "colours", + description: "Show the distribution of colour roles in the server", +}; + +export const ROLEPLAY_SERIOUS_COMMAND: DiscordCommand = { + name: "roleplay-serious", + description: "Manage the serious roleplay role (Admin/Roleplay Curator only)", + options: [ + { + type: 3, + name: "action", + description: "Action to perform on the role", + required: true, + choices: [ + { + name: "Add Role", + value: "add", + }, + { + name: "Remove Role", + value: "remove", + }, + { + name: "Toggle Role", + value: "toggle", + }, + ], + }, + { + type: 6, + name: "user", + description: "User to perform the action on", + required: true, + }, + ], +}; diff --git a/packages/interactions/discord/embeds.ts b/packages/interactions/discord/embeds.ts new file mode 100644 index 0000000..3f7c344 --- /dev/null +++ b/packages/interactions/discord/embeds.ts @@ -0,0 +1,181 @@ +import type { DiscordEmbed } from "./interfaces.ts"; +import type { RedditPost } from "../reddit.ts"; + +const decodeHtmlEntities = (str: string): string => { + return str + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(///g, "/") + .replace(/`/g, "`") + .replace(/=/g, "="); +}; + +export const createPostEmbed = (post: RedditPost): DiscordEmbed => { + const mediaUrl = + post.media?.reddit_video?.fallback_url || + post.secure_media?.reddit_video?.fallback_url || + post.url; + + let description = post.selftext || ""; + + if (description.length > 1000) + description = description.substring(0, 997).trim() + " ..."; + + const embed: DiscordEmbed = { + title: post.title, + description: description, + url: `https://reddit.com${post.permalink}`, + color: 0xff4500, + author: { + name: `u/${post.author}`, + url: `https://reddit.com/u/${post.author}`, + }, + fields: [ + { + name: "Score", + value: `${post.score} ā¬ļø`, + inline: true, + }, + { + name: "Comments", + value: `${post.num_comments} š¬`, + inline: true, + }, + ], + timestamp: new Date(post.created_utc * 1000).toISOString(), + footer: { + text: "r/okbuddyumamusume", + }, + }; + + if (mediaUrl) + if (post.media?.reddit_video || post.secure_media?.reddit_video) { + if (!description) description = ""; + + description += + "\n\nš¹ **This post contains a video** - [Click here to view](" + + mediaUrl + + ")"; + embed.description = description; + + if (post.preview?.images?.[0]?.source?.url) { + const decodedURL = decodeHtmlEntities( + post.preview.images[0].source.url, + ); + + console.log("Using preview image:", decodedURL); + + embed.image = { url: decodedURL }; + } else if ( + post.thumbnail && + post.thumbnail !== "self" && + post.thumbnail !== "default" + ) { + const decodedThumbnail = decodeHtmlEntities(post.thumbnail); + + console.log("Using thumbnail:", decodedThumbnail); + + embed.image = { url: decodedThumbnail }; + } else { + console.log("No suitable thumbnail found for video post"); + } + } else { + embed.image = { url: mediaUrl }; + } + + return embed; +}; + +export const createRoleDistributionEmbed = ( + roleDistribution: Array<{ name: string; count: number }>, +): DiscordEmbed => { + const totalMembers = roleDistribution.reduce( + (sum, role) => sum + role.count, + 0, + ); + + return { + title: "šØ Colour Role Distribution", + description: `Total members with colour roles: **${totalMembers}**`, + color: 0x5865f2, + fields: roleDistribution.map((role) => ({ + name: role.name, + value: `${role.count} member${role.count !== 1 ? "s" : ""}`, + inline: true, + })), + footer: { + text: "Sorted by member count (highest to lowest)", + }, + }; +}; + +export const createComplaintEmbed = ( + complaintContent: string, + complainant: { username: string; id: string; avatar?: string }, + timestamp: number, + isDM: boolean = true, +): DiscordEmbed => { + return { + title: "šØ New Complaint", + description: complaintContent, + color: 0xff6b6b, + fields: [ + { + name: "Complainant", + value: `${complainant.username} (${complainant.id})`, + inline: true, + }, + { + name: "Timestamp", + value: `<t:${Math.floor(timestamp / 1000)}:F>`, + inline: true, + }, + ], + thumbnail: complainant.avatar + ? { + url: `https://cdn.discordapp.com/avatars/${complainant.id}/${complainant.avatar}.png?size=256`, + } + : undefined, + footer: { + text: isDM + ? "Complaint submitted via DM" + : "Complaint submitted from server", + }, + }; +}; + +export const createAppealEmbed = ( + appealContent: string, + appellant: { username: string; id: string; avatar?: string }, + timestamp: number, + isDM: boolean = true, +): DiscordEmbed => { + return { + title: "š New Appeal", + description: appealContent, + color: 0x5865f2, + fields: [ + { + name: "Appellant", + value: `${appellant.username} (${appellant.id})`, + inline: true, + }, + { + name: "Timestamp", + value: `<t:${Math.floor(timestamp / 1000)}:F>`, + inline: true, + }, + ], + thumbnail: appellant.avatar + ? { + url: `https://cdn.discordapp.com/avatars/${appellant.id}/${appellant.avatar}.png?size=256`, + } + : undefined, + footer: { + text: isDM ? "Appeal submitted via DM" : "Appeal submitted from server", + }, + }; +}; diff --git a/packages/interactions/discord/interfaces.ts b/packages/interactions/discord/interfaces.ts new file mode 100644 index 0000000..bc8683c --- /dev/null +++ b/packages/interactions/discord/interfaces.ts @@ -0,0 +1,86 @@ +export interface Environment { + DISCORD_APPLICATION_ID: string; + DISCORD_PUBLIC_KEY: string; + DISCORD_TOKEN: string; +} + +export interface DiscordInteraction { + type: number; + data: { + name: string; + options?: Array<{ + name: string; + value: string; + }>; + }; + channel_id?: string; + channel?: { + nsfw: boolean; + }; + guild_id?: string; + user?: { + id: string; + username: string; + avatar?: string; + }; + member?: { + user?: { + id: string; + username: string; + avatar?: string; + }; + roles?: string[]; + permissions?: string; + }; +} + +export interface DiscordEmbed { + title: string; + description: string; + url?: string; + color: number; + author?: { + name: string; + url: string; + }; + fields?: Array<{ + name: string; + value: string; + inline: boolean; + }>; + timestamp?: string; + footer?: { + text: string; + }; + image?: { url: string }; + thumbnail?: { url: string }; +} + +export interface DiscordResponse { + type: number; + data?: { + content?: string; + embeds?: DiscordEmbed[]; + flags?: number; + }; +} + +export interface DiscordCommand { + name: string; + description: string; + options?: DiscordCommandOption[]; + contexts?: number[]; +} + +export interface DiscordCommandOption { + type: number; + name: string; + description: string; + required?: boolean; + choices?: DiscordCommandChoice[]; +} + +export interface DiscordCommandChoice { + name: string; + value: string; +} diff --git a/packages/interactions/discord/responses.ts b/packages/interactions/discord/responses.ts new file mode 100644 index 0000000..4dcc777 --- /dev/null +++ b/packages/interactions/discord/responses.ts @@ -0,0 +1,15 @@ +import type { DiscordResponse } from "./interfaces.ts"; + +export class JSONResponse extends Response { + constructor(body: DiscordResponse | { error: string }, init?: ResponseInit) { + const jsonBody = JSON.stringify(body); + + init = init || { + headers: { + "content-type": "application/json;charset=UTF-8", + }, + }; + + super(jsonBody, init); + } +} diff --git a/packages/interactions/discord/types.ts b/packages/interactions/discord/types.ts new file mode 100644 index 0000000..4f6e85e --- /dev/null +++ b/packages/interactions/discord/types.ts @@ -0,0 +1 @@ +export type TimePeriod = "hour" | "day" | "week" | "month" | "year" | "all"; diff --git a/packages/interactions/discord/verification.ts b/packages/interactions/discord/verification.ts new file mode 100644 index 0000000..89d26db --- /dev/null +++ b/packages/interactions/discord/verification.ts @@ -0,0 +1,24 @@ +import { verifyKey } from "discord-interactions"; +import type { Environment, DiscordInteraction } from "./interfaces.ts"; + +export const verifyDiscordRequest = async ( + request: Request, + environment: Environment, +): Promise<{ isValid: boolean; interaction?: DiscordInteraction }> => { + const signature = request.headers.get("x-signature-ed25519"); + const timestamp = request.headers.get("x-signature-timestamp"); + const body = await request.text(); + const isValidRequest = + signature && + timestamp && + (await verifyKey( + body, + signature, + timestamp, + environment.DISCORD_PUBLIC_KEY, + )); + + if (!isValidRequest) return { isValid: false }; + + return { interaction: JSON.parse(body) as DiscordInteraction, isValid: true }; +}; |