Add ngate
Some checks failed
Build and Publish Docker Image / build_and_push (push) Failing after 1m17s

This commit is contained in:
Jack Hadrill
2025-02-02 23:06:52 +00:00
parent 5c29e93549
commit 0b2118354f
4 changed files with 1288 additions and 1016 deletions

2029
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,23 +17,23 @@
"license": "ISC",
"description": "",
"dependencies": {
"axios": "^1.7.7",
"date-fns": "^3.6.0",
"discord.js": "^14.16.1",
"dotenv": "^16.4.5",
"glob": "^11.0.0",
"mongodb": "^6.8.1",
"openai": "^4.62.0",
"replicate": "^0.32.1"
"axios": "^1.7.9",
"date-fns": "^4.1.0",
"discord.js": "^14.17.3",
"dotenv": "^16.4.7",
"glob": "^11.0.1",
"mongodb": "^6.13.0",
"openai": "^4.82.0",
"replicate": "^1.0.1"
},
"devDependencies": {
"@types/glob": "^8.1.0",
"@types/node": "^20.4.0",
"eslint": "^9.9.1",
"prettier": "^3.3.3",
"ts-node": "^10.9.1",
"tsup": "^8.2.4",
"tsx": "^4.19.1",
"typescript": "^5.6.2"
"@types/node": "^22.13.0",
"eslint": "^9.19.0",
"prettier": "^3.4.2",
"ts-node": "^10.9.2",
"tsup": "^8.3.6",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

203
src/commands/ngate.ts Normal file
View File

@@ -0,0 +1,203 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from "discord.js";
import OpenAI from "openai";
import config from "../config";
const client = new OpenAI({
apiKey: config.openaiApiKey,
});
const ngateModel = "ft:gpt-4o-mini-2024-07-18:personal:ngate:AvnNWGXx";
const systemPrompt = `You are a satirical news writer for the popular news aggregator site, Hacker News.
You are known for your witty, cynical and often sarcastic take on the world of technology and startups.
You write in a style that is both informative and entertaining, often using humor to make complex topics more accessible.
You believe that Hacker News comments are often deceptive, delusional or misleading, and you enjoy pointing out the absurdities and contradictions in them.
You will write a brief news summary for a given Hacker News item based on its title and the comments.`;
// Initialise the command data.
export const data = new SlashCommandBuilder()
.setName("ngate")
.setDescription(
"Generates a satirical news summary for the provided Hacker News URL, inspired by n-gate."
)
.addStringOption((option) =>
option
.setName("url")
.setDescription("The Hacker News URL to generate an n-gate summary for.")
.setRequired(true)
);
console.log(`Loaded ${data.name} command.`);
// Define the shape of a comment after filtering.
interface Comment {
author: string;
text: string;
children: Comment[];
}
/**
* Given a URL like "http://news.ycombinator.com/item?id=15634609",
* returns the id ("15634609") by splitting on "=".
*/
export function extractId(url: string): string {
const parts = url.split("=");
return parts[parts.length - 1];
}
/**
* Fetches the comments for a given id from the HN Algolia API.
* Returns the parsed JSON if the HTTP status is 200.
*/
export async function fetchComments(id: string): Promise<any> {
const url = `https://hn.algolia.com/api/v1/items/${id}`;
const response = await fetch(url);
if (response.status === 200) {
return response.json();
}
// You can choose to throw an error or return undefined if the status is not 200.
return undefined;
}
/**
* A simple function to un-escape a few common HTML entities.
* (You could replace this with a more complete solution or a library if needed.)
*/
function unescapeHTML(html: string): string {
return html
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'");
}
/**
* Filters the comments by:
* - Keeping only the "author", "text", and "children" fields.
* - Recursively processing child comments (up to a given depth).
*
* @param comments - The list of comments to filter.
* @param depth - How deep to process child comments (default is 3).
* @returns The filtered comments.
*/
export function filterComments(comments: any[], depth: number = 3): Comment[] {
if (depth === 0) {
return [];
}
const filteredComments: Comment[] = [];
for (const comment of comments) {
let text: string = comment.text;
try {
text = text ? unescapeHTML(text) : "";
} catch (e) {
console.error(`Error un-escaping HTML: ${e}`);
}
const filteredComment: Comment = {
author: comment.author,
text,
children: filterComments(comment.children, depth - 1),
};
filteredComments.push(filteredComment);
}
return filteredComments;
}
/**
* Recursively builds a pretty-printed string of comments,
* using indentation to show nesting.
*
* @param comments - The list of comments to print.
* @param indent - The current indent level (default is 0).
* @returns A string with the pretty-printed comments.
*/
export function prettyPrintComments(
comments: Comment[],
indent: number = 0
): string {
let result = "";
for (const comment of comments) {
result += "- ".repeat(indent + 1) + `${comment.author}: ${comment.text}\n`;
result += prettyPrintComments(comment.children, indent + 1);
}
return result;
}
/**
* Generates a satirical news summary for the provided Hacker News URL, inspired by n-gate.
* @param interaction The interaction that triggered the command.
* @returns A promise that resolves when the command is finished executing.
*/
export async function execute(
interaction: ChatInputCommandInteraction
): Promise<void> {
await interaction.deferReply();
const url = interaction.options.getString("url", true).trim();
// Verify URL format based on https://news.ycombinator.com/item?id=15634609 pattern.
if (!url.startsWith("https://news.ycombinator.com/item?id=")) {
await interaction.editReply("Invalid Hacker News URL format.");
return;
}
const id = extractId(url);
// Verify that the ID is a valid number.
if (isNaN(Number(id))) {
await interaction.editReply("Invalid Hacker News ID.");
return;
}
const comments = await fetchComments(id);
// Verify that comments were fetched successfully.
if (!comments) {
await interaction.editReply("Failed to fetch comments.");
return;
}
const filteredComments = filterComments(comments.children);
// Verify that comments were filtered successfully.
if (!filteredComments.length) {
await interaction.editReply("No comments found.");
return;
}
const formattedComments = prettyPrintComments(filteredComments);
// Generate a prompt based on the title and comments.
const title = comments.title;
const userPrompt = `Write a news summary for the following Hacker News item:
Title: ${title}
Comments:
${formattedComments}`;
try {
// Create a chat completion.
const result = await client.chat.completions.create({
model: ngateModel,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
});
// Extract the response.
const response = result.choices[0]!.message?.content;
if (!response) {
await interaction.editReply("No response was generated.");
return;
}
await interaction.editReply(response);
} catch (error: any) {
await interaction.editReply(
`An error occurred while generating the response: ${error.message || "Unknown error"}`
);
}
}

View File

@@ -1,22 +1,23 @@
import { Collection, REST, Routes } from 'discord.js';
import * as birthday from '../commands/birthday';
import * as corrupt from '../commands/corrupt';
import * as countdown from '../commands/countdown';
import * as eyecandy from '../commands/eyecandy';
import * as game from '../commands/game';
import * as gerard from '../commands/gerard';
import * as image from '../commands/image';
import * as imdb from '../commands/imdb';
import * as kanye from '../commands/kanye';
import * as magicEightBall from '../commands/magic8Ball';
import * as payday from '../commands/payday';
import * as plant from '../commands/plant';
import * as reminder from '../commands/reminder';
import * as servertime from '../commands/servertime';
import * as taylor from '../commands/taylor';
import * as twentyTwenty from '../commands/twentyTwenty';
import config from '../config';
import { Command } from './types';
import { Collection, REST, Routes } from "discord.js";
import * as birthday from "../commands/birthday";
import * as corrupt from "../commands/corrupt";
import * as countdown from "../commands/countdown";
import * as eyecandy from "../commands/eyecandy";
import * as game from "../commands/game";
import * as gerard from "../commands/gerard";
import * as image from "../commands/image";
import * as imdb from "../commands/imdb";
import * as kanye from "../commands/kanye";
import * as magicEightBall from "../commands/magic8Ball";
import * as ngate from "../commands/ngate";
import * as payday from "../commands/payday";
import * as plant from "../commands/plant";
import * as reminder from "../commands/reminder";
import * as servertime from "../commands/servertime";
import * as taylor from "../commands/taylor";
import * as twentyTwenty from "../commands/twentyTwenty";
import config from "../config";
import { Command } from "./types";
/**
* Get all commands as a collection.
@@ -35,6 +36,7 @@ export function getCommands(): Collection<string, Command> {
commands.set(imdb.data.name, imdb);
commands.set(kanye.data.name, kanye);
commands.set(magicEightBall.data.name, magicEightBall);
commands.set(ngate.data.name, ngate);
commands.set(payday.data.name, payday);
commands.set(plant.data.name, plant);
commands.set(reminder.data.name, reminder);
@@ -53,7 +55,7 @@ export async function registerSlashCommands(
commands: Collection<string, Command>
) {
const commandData = commands.map((command) => command.data.toJSON());
const rest = new REST({ version: '10' }).setToken(config.discordApiKey);
const rest = new REST({ version: "10" }).setToken(config.discordApiKey);
await rest.put(Routes.applicationCommands(config.discordApplicationId), {
body: commandData,
});