7 Commits

Author SHA1 Message Date
Jack Hadrill
0b2118354f Add ngate
Some checks failed
Build and Publish Docker Image / build_and_push (push) Failing after 1m17s
2025-02-02 23:06:52 +00:00
Jack Hadrill
5c29e93549 Increase max attempts for image generation completion
Some checks failed
Build and Publish Docker Image / build_and_push (push) Failing after 20s
2024-12-10 22:01:59 +00:00
Jack Hadrill
ab9d66af54 Fix image generation
Some checks failed
Build and Publish Docker Image / build_and_push (push) Failing after 37s
2024-12-10 21:57:52 +00:00
e9f7b6e28e Update src/commands/image.ts
Some checks failed
Build and Publish Docker Image / build_and_push (push) Failing after 19s
2024-12-10 21:50:28 +00:00
ecf4a07668 Merge pull request 'fix/ci' (#8) from fix/ci into main
All checks were successful
Build and Publish Docker Image / build_and_push (push) Successful in 43s
Reviewed-on: #8
2024-12-10 20:35:59 +00:00
97543dca9c Update .gitea/workflows/build-and-publish.yml 2024-12-10 20:35:39 +00:00
75e293f193 Update package.json 2024-12-10 20:35:05 +00:00
7 changed files with 1377 additions and 1044 deletions

View File

@@ -55,8 +55,6 @@ jobs:
file: ./Dockerfile
push: true
tags: ${{ steps.set_tags.outputs.image_tags }}
env:
NPM_TOKEN: ${{secrets.NPM_TOKEN}}
- name: Set Portainer Webhook URL
run: |

30
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,30 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch via NPM",
"request": "launch",
"runtimeArgs": [
"run",
"dev"
],
"runtimeExecutable": "npm",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
},
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${file}"
}
]
}

2075
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,24 +17,23 @@
"license": "ISC",
"description": "",
"dependencies": {
"@3t.network/orchestra": "^1.0.0",
"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"
}
}

View File

@@ -2,10 +2,10 @@ import {
SlashCommandBuilder,
AttachmentBuilder,
ChatInputCommandInteraction,
} from 'discord.js';
import axios from 'axios';
import Replicate, { Prediction } from 'replicate';
import config from '../config';
} from "discord.js";
import axios from "axios";
import Replicate, { Prediction } from "replicate";
import config from "../config";
const replicate = new Replicate({
auth: config.replicateApiKey,
@@ -13,12 +13,12 @@ const replicate = new Replicate({
// Initialise the command data.
export const data = new SlashCommandBuilder()
.setName('image')
.setDescription('Generate an image based on a prompt.')
.setName("image")
.setDescription("Generate an image based on a prompt.")
.addStringOption((option) =>
option
.setName('prompt')
.setDescription('The prompt to generate an image from')
.setName("prompt")
.setDescription("The prompt to generate an image from")
.setRequired(true)
);
@@ -34,12 +34,12 @@ export async function execute(
): Promise<void> {
await interaction.deferReply();
const prompt = interaction.options.get('prompt')?.value as string;
const prompt = interaction.options.get("prompt")?.value as string;
try {
// Create image generation prediction
const prediction = await replicate.predictions.create({
model: 'black-forest-labs/flux-1.1-pro-ultra',
model: "black-forest-labs/flux-1.1-pro-ultra",
input: { prompt },
});
@@ -47,17 +47,17 @@ export async function execute(
const completedPrediction = await pollPredictionStatus(prediction.id);
if (!completedPrediction || !completedPrediction.output) {
throw new Error('Failed to generate the image.');
throw new Error("Failed to generate the image.");
}
const imageUrl = completedPrediction.output[0];
const imageUrl = completedPrediction.output;
// Download the generated image
const imageBuffer = await downloadImage(imageUrl);
// Create an attachment to send the image back to the user
const attachment = new AttachmentBuilder(imageBuffer, {
name: 'image.png',
name: "image.jpg",
});
// Edit the deferred reply to include the generated image
@@ -78,15 +78,15 @@ export async function execute(
*/
async function pollPredictionStatus(
predictionId: string,
maxAttempts = 5,
maxAttempts = 20,
interval = 2000
): Promise<Prediction> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const latestPrediction = await replicate.predictions.get(predictionId);
if (
latestPrediction.status !== 'starting' &&
latestPrediction.status !== 'processing'
latestPrediction.status !== "starting" &&
latestPrediction.status !== "processing"
) {
return latestPrediction;
}
@@ -95,7 +95,7 @@ async function pollPredictionStatus(
await new Promise((resolve) => setTimeout(resolve, interval));
}
throw new Error('Prediction timed out.');
throw new Error("Prediction timed out.");
}
/**
@@ -105,9 +105,9 @@ async function pollPredictionStatus(
*/
async function downloadImage(url: string): Promise<Buffer> {
try {
const response = await axios.get(url, { responseType: 'arraybuffer' });
const response = await axios.get(url, { responseType: "arraybuffer" });
return Buffer.from(response.data);
} catch (error) {
throw new Error('Failed to download the image.');
throw new Error("Failed to download the image.");
}
}

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,
});