Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b2118354f | ||
|
|
5c29e93549 | ||
|
|
ab9d66af54 | ||
| e9f7b6e28e | |||
| ecf4a07668 | |||
| 97543dca9c | |||
| 75e293f193 |
3
.npmrc
3
.npmrc
@@ -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
30
.vscode/launch.json
vendored
Normal 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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,7 +13,7 @@ WORKDIR /app
|
||||
FROM base AS build
|
||||
|
||||
# Create app directory and copy the app
|
||||
COPY package*.json tsconfig.json .npmrc ./
|
||||
COPY package*.json tsconfig.json ./
|
||||
|
||||
# Install
|
||||
RUN npm install
|
||||
@@ -28,8 +28,8 @@ RUN npm run build
|
||||
FROM node:lts-alpine
|
||||
|
||||
# Install dependencies but skip dev dependencies
|
||||
COPY --from=build /app/package*.json /app/.npmrc ./
|
||||
RUN npm install --only=production --omit=dev
|
||||
COPY --from=build /app/package*.json ./
|
||||
RUN npm install --only=production
|
||||
|
||||
# Copy the app
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
2075
package-lock.json
generated
2075
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ fi
|
||||
|
||||
# cd to the project root directory.
|
||||
cd "$(dirname "$0")/.."
|
||||
source .env
|
||||
docker build -t butlerbot:$VERSION .
|
||||
|
||||
# Tag the Docker image.
|
||||
|
||||
@@ -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
203
src/commands/ngate.ts
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user