11 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
4d9bf52a11 Update image generation model 2024-12-10 20:26:25 +00:00
9d916177a7 Merge pull request 'develop' (#6) from develop into main
Reviewed-on: #6
2024-09-18 17:33:09 +01:00
f82e21ffde Merge pull request 'develop' (#5) from develop into main
All checks were successful
Build and Publish Docker Image / build_and_push (push) Successful in 22s
Reviewed-on: #5
2024-09-17 23:05:04 +01:00
6a964da152 Merge pull request 'develop' (#3) from develop into main
All checks were successful
Build and Publish Docker Image / build_and_push (push) Successful in 17s
Reviewed-on: #3
2024-09-16 17:01:28 +01:00
9 changed files with 1382 additions and 1047 deletions

1
.npmrc
View File

@@ -1 +1,2 @@
@3t.network:registry=https://git.3t.network/api/packages/3t.network/npm/ @3t.network:registry=https://git.3t.network/api/packages/3t.network/npm/
//git.3t.network/api/packages/3t.network/npm/:_authToken=${NPM_TOKEN}

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}"
}
]
}

View File

@@ -13,7 +13,7 @@ WORKDIR /app
FROM base AS build FROM base AS build
# Create app directory and copy the app # Create app directory and copy the app
COPY package*.json tsconfig.json .npmrc ./ COPY package*.json tsconfig.json ./
# Install # Install
RUN npm install RUN npm install
@@ -28,8 +28,8 @@ RUN npm run build
FROM node:lts-alpine FROM node:lts-alpine
# Install dependencies but skip dev dependencies # Install dependencies but skip dev dependencies
COPY --from=build /app/package*.json /app/.npmrc ./ COPY --from=build /app/package*.json ./
RUN npm install --only=production --omit=dev RUN npm install --only=production
# Copy the app # Copy the app
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist

2075
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -51,7 +51,6 @@ fi
# cd to the project root directory. # cd to the project root directory.
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
source .env
docker build -t butlerbot:$VERSION . docker build -t butlerbot:$VERSION .
# Tag the Docker image. # Tag the Docker image.

View File

@@ -2,10 +2,10 @@ import {
SlashCommandBuilder, SlashCommandBuilder,
AttachmentBuilder, AttachmentBuilder,
ChatInputCommandInteraction, ChatInputCommandInteraction,
} from 'discord.js'; } from "discord.js";
import axios from 'axios'; import axios from "axios";
import Replicate, { Prediction } from 'replicate'; import Replicate, { Prediction } from "replicate";
import config from '../config'; import config from "../config";
const replicate = new Replicate({ const replicate = new Replicate({
auth: config.replicateApiKey, auth: config.replicateApiKey,
@@ -13,12 +13,12 @@ const replicate = new Replicate({
// Initialise the command data. // Initialise the command data.
export const data = new SlashCommandBuilder() export const data = new SlashCommandBuilder()
.setName('image') .setName("image")
.setDescription('Generate an image based on a prompt.') .setDescription("Generate an image based on a prompt.")
.addStringOption((option) => .addStringOption((option) =>
option option
.setName('prompt') .setName("prompt")
.setDescription('The prompt to generate an image from') .setDescription("The prompt to generate an image from")
.setRequired(true) .setRequired(true)
); );
@@ -34,12 +34,12 @@ export async function execute(
): Promise<void> { ): Promise<void> {
await interaction.deferReply(); await interaction.deferReply();
const prompt = interaction.options.get('prompt')?.value as string; const prompt = interaction.options.get("prompt")?.value as string;
try { try {
// Create image generation prediction // Create image generation prediction
const prediction = await replicate.predictions.create({ const prediction = await replicate.predictions.create({
model: 'black-forest-labs/flux-schnell', model: "black-forest-labs/flux-1.1-pro-ultra",
input: { prompt }, input: { prompt },
}); });
@@ -47,17 +47,17 @@ export async function execute(
const completedPrediction = await pollPredictionStatus(prediction.id); const completedPrediction = await pollPredictionStatus(prediction.id);
if (!completedPrediction || !completedPrediction.output) { 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 // Download the generated image
const imageBuffer = await downloadImage(imageUrl); const imageBuffer = await downloadImage(imageUrl);
// Create an attachment to send the image back to the user // Create an attachment to send the image back to the user
const attachment = new AttachmentBuilder(imageBuffer, { const attachment = new AttachmentBuilder(imageBuffer, {
name: 'image.png', name: "image.jpg",
}); });
// Edit the deferred reply to include the generated image // Edit the deferred reply to include the generated image
@@ -78,15 +78,15 @@ export async function execute(
*/ */
async function pollPredictionStatus( async function pollPredictionStatus(
predictionId: string, predictionId: string,
maxAttempts = 5, maxAttempts = 20,
interval = 2000 interval = 2000
): Promise<Prediction> { ): Promise<Prediction> {
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
const latestPrediction = await replicate.predictions.get(predictionId); const latestPrediction = await replicate.predictions.get(predictionId);
if ( if (
latestPrediction.status !== 'starting' && latestPrediction.status !== "starting" &&
latestPrediction.status !== 'processing' latestPrediction.status !== "processing"
) { ) {
return latestPrediction; return latestPrediction;
} }
@@ -95,7 +95,7 @@ async function pollPredictionStatus(
await new Promise((resolve) => setTimeout(resolve, interval)); 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> { async function downloadImage(url: string): Promise<Buffer> {
try { try {
const response = await axios.get(url, { responseType: 'arraybuffer' }); const response = await axios.get(url, { responseType: "arraybuffer" });
return Buffer.from(response.data); return Buffer.from(response.data);
} catch (error) { } 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 { Collection, REST, Routes } from "discord.js";
import * as birthday from '../commands/birthday'; import * as birthday from "../commands/birthday";
import * as corrupt from '../commands/corrupt'; import * as corrupt from "../commands/corrupt";
import * as countdown from '../commands/countdown'; import * as countdown from "../commands/countdown";
import * as eyecandy from '../commands/eyecandy'; import * as eyecandy from "../commands/eyecandy";
import * as game from '../commands/game'; import * as game from "../commands/game";
import * as gerard from '../commands/gerard'; import * as gerard from "../commands/gerard";
import * as image from '../commands/image'; import * as image from "../commands/image";
import * as imdb from '../commands/imdb'; import * as imdb from "../commands/imdb";
import * as kanye from '../commands/kanye'; import * as kanye from "../commands/kanye";
import * as magicEightBall from '../commands/magic8Ball'; import * as magicEightBall from "../commands/magic8Ball";
import * as payday from '../commands/payday'; import * as ngate from "../commands/ngate";
import * as plant from '../commands/plant'; import * as payday from "../commands/payday";
import * as reminder from '../commands/reminder'; import * as plant from "../commands/plant";
import * as servertime from '../commands/servertime'; import * as reminder from "../commands/reminder";
import * as taylor from '../commands/taylor'; import * as servertime from "../commands/servertime";
import * as twentyTwenty from '../commands/twentyTwenty'; import * as taylor from "../commands/taylor";
import config from '../config'; import * as twentyTwenty from "../commands/twentyTwenty";
import { Command } from './types'; import config from "../config";
import { Command } from "./types";
/** /**
* Get all commands as a collection. * Get all commands as a collection.
@@ -35,6 +36,7 @@ export function getCommands(): Collection<string, Command> {
commands.set(imdb.data.name, imdb); commands.set(imdb.data.name, imdb);
commands.set(kanye.data.name, kanye); commands.set(kanye.data.name, kanye);
commands.set(magicEightBall.data.name, magicEightBall); commands.set(magicEightBall.data.name, magicEightBall);
commands.set(ngate.data.name, ngate);
commands.set(payday.data.name, payday); commands.set(payday.data.name, payday);
commands.set(plant.data.name, plant); commands.set(plant.data.name, plant);
commands.set(reminder.data.name, reminder); commands.set(reminder.data.name, reminder);
@@ -53,7 +55,7 @@ export async function registerSlashCommands(
commands: Collection<string, Command> commands: Collection<string, Command>
) { ) {
const commandData = commands.map((command) => command.data.toJSON()); 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), { await rest.put(Routes.applicationCommands(config.discordApplicationId), {
body: commandData, body: commandData,
}); });