diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..1d953f4 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use nix diff --git a/bun.lockb b/bun.lockb index 717ff73..c8476fc 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index f4555f4..4fd6905 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "bufferutil": "^4.0.8", "dayjs": "^1.11.13", "discord.js": "^14.16.3", + "fastest-levenshtein": "^1.0.16", "groq-sdk": "^0.9.1", "human-interval": "^2.0.1", "node-cron": "^3.0.3", diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..7945534 --- /dev/null +++ b/shell.nix @@ -0,0 +1,11 @@ +let + pkgs = import {}; +in +pkgs.mkShell { + buildInputs = with pkgs; [ + bun + corepack_latest + nodejs_latest + eslint + ]; + } diff --git a/src/commands/summarize.ts b/src/commands/summarize.ts index bd61951..7c47367 100644 --- a/src/commands/summarize.ts +++ b/src/commands/summarize.ts @@ -6,12 +6,7 @@ import { type ChatInputCommandInteraction, type PublicThreadChannel, } from "discord.js"; -import Groq from "groq-sdk"; -import human from "human-interval"; - -import { env } from "../env"; - -const groq = new Groq({ apiKey: env.GROQ_API_KEY }); +import { summarize } from "../utils/summarization"; dayjs.extend(relativeTime); @@ -37,28 +32,13 @@ export async function command(interaction: ChatInputCommandInteraction) { const { options } = interaction; const topic = options.getString("topic"); - const timeframe = options.getString("timeframe") ?? "1 hour"; + const timeframe = options.getString("timeframe"); if (!topic) { await interaction.reply("Please provide a topic to summarize"); return; } - const timeframeMs = human(timeframe); - - if (!timeframeMs) { - await interaction.reply("Invalid timeframe provided"); - return; - } - - const date = new Date(Date.now() - timeframeMs); - const formatted = dayjs(date).fromNow(); - - await interaction.reply({ - content: `Summarizing messages related to ${topic} from ${formatted}.`, - flags: MessageFlags.Ephemeral, - }); - const isChannel = interaction.channel; if (!isChannel) { @@ -66,76 +46,5 @@ export async function command(interaction: ChatInputCommandInteraction) { return; } - const snowflake = ( - (BigInt(date.valueOf()) - BigInt(1420070400000)) << - BigInt(22) - ).toString(); - - const messages = await interaction.channel.messages.fetch({ - limit: 100, - after: snowflake, - }); - - const corpus = messages - .reverse() - .map( - (message) => - `[${message.author.displayName} ${new Date(message.createdTimestamp).toISOString()}] ${message.content}`, - ) - .join("\n"); - - const content = ` -${corpus} - - -Using the above message corpus, generate a bulleted summary of anything relevant to the following topic: **${topic}**. Mention specific things people said and anything useful to document. Pull all details relevant to ${topic}. - -When reading a message, the first part is the username and the second part is the timestamp. For example, [User A 2021-08-01T00:00:00.000Z]. - -Avoid pinging users, only use their username (e.g. Ray said ...). Follow all markdown rules relevant to Discord. - -Use an analytical tone. Include relevant details. For example, "User A mentioned that they were going to the store. User B responded with a question about the store's location." -Include as much detail as possible. At the end summarize any conclusions or decisions made. -`.trim(); - - const response = await groq.chat.completions.create({ - messages: [ - { - role: "user", - content, - }, - ], - model: "llama-3.3-70b-versatile", - }); - - const thread = (await interaction.channel.threads.create({ - name: `Summary of ${topic} from ${formatted}`, - autoArchiveDuration: 60, - reason: `Summarizing messages related to ${topic} from ${formatted}.`, - })) as PublicThreadChannel; - - const message = response.choices[0].message; - - if (!message.content) { - console.error("No content"); - await thread.send("Error: No content"); - return; - } - - if (message.content.length > 2000) { - const chunks = message.content.match(/[\s\S]{1,2000}/g); - - if (!chunks) { - console.error("No chunks"); - await thread.send("Error: No chunks"); - return; - } - - for (const chunk of chunks) { - console.log(chunk); - await thread.send(chunk); - } - } else { - await thread.send(message.content); - } + await summarize(timeframe, topic, interaction); } diff --git a/src/events/message_create/grok.ts b/src/events/message_create/grok.ts new file mode 100644 index 0000000..e53c364 --- /dev/null +++ b/src/events/message_create/grok.ts @@ -0,0 +1,35 @@ +import { TextChannel, type Message } from "discord.js"; +import { distance } from "fastest-levenshtein"; +import { summarize } from "../../utils/summarization"; + +const ASSISTANT_TRIGGER = "grok"; +const SUMMARIZE_TRIGGER = "summary"; + +export default async function handler(message: Message) { + if (message.author.bot) return; + if (message.channel.isDMBased()) return; + if (!(message.channel instanceof TextChannel)) return; + + // message.startThread(options); + + const result = message.content.replace(/\s+/g, " ").trim(); + const parts = result.split(" "); + + if (parts.length < 2 || !parts[0].startsWith("@")) return; + + const [ref, invocation, ...time1] = parts; + + const isThisReal = message.content.match(/is\s+this\s+real/); + const time = isThisReal ? time1.slice(2) : time1; + + const refs = ref.substring(1); + if (distance(ASSISTANT_TRIGGER, refs) > 3) return; + + if (distance(SUMMARIZE_TRIGGER, invocation) > 5 && !isThisReal) return; + + await summarize( + time.length > 0 ? time.join(" ") : null, + null, + message as Message, + ); +} diff --git a/src/events/message_create/index.ts b/src/events/message_create/index.ts index cd96676..10a50ac 100644 --- a/src/events/message_create/index.ts +++ b/src/events/message_create/index.ts @@ -5,6 +5,7 @@ import dashboard from "./dashboard"; import evergreenIt from "./evergreen-it"; import voiceMessageTranscription from "./voice-transcription"; import welcomer from "./welcomer"; +import grok from "./grok"; export const eventType = Events.MessageCreate; export { @@ -13,4 +14,5 @@ export { evergreenIt, voiceMessageTranscription, welcomer, + grok, }; diff --git a/src/utils/summarization.ts b/src/utils/summarization.ts new file mode 100644 index 0000000..34496c5 --- /dev/null +++ b/src/utils/summarization.ts @@ -0,0 +1,118 @@ +import dayjs from "dayjs"; +import { + ChatInputCommandInteraction, + Message, + MessageFlags, + MessagePayload, + TextChannel, + type GuildTextBasedChannel, + type MessageReplyOptions, + type OmitPartialGroupDMChannel, + type PublicThreadChannel, + type TextBasedChannel, +} from "discord.js"; +import human from "human-interval"; +import Groq from "groq-sdk"; +import { env } from "../env"; +const groq = new Groq({ apiKey: env.GROQ_API_KEY }); + +export async function summarize( + timeframe: string | null, + top: string | null, + replyable: Message | ChatInputCommandInteraction, +) { + const timeframeMs = human(timeframe ?? "1 hour"); + const topic = + top || "whatever the most common theme of the previous messages is"; + const displayTopic = top || "WHATEVER"; + + if (!timeframeMs) { + await replyable.reply("Invalid timeframe provided"); + return; + } + + const date = new Date(Date.now() - timeframeMs); + const formatted = dayjs(date).fromNow(); + + if (replyable instanceof ChatInputCommandInteraction) { + await replyable.reply({ + content: `Summarizing messages related to ${topic} from ${formatted}.`, + flags: MessageFlags.Ephemeral, + }); + } + + const snowflake = ( + (BigInt(date.valueOf()) - BigInt(1420070400000)) << + BigInt(22) + ).toString(); + + const channel = replyable.channel as TextChannel; + + const messages = await channel.messages.fetch({ + limit: 100, + after: snowflake, + }); + + const corpus = messages + .reverse() + .map( + (message) => + `[${message.author.displayName} ${new Date(message.createdTimestamp).toISOString()}] ${message.content}`, + ) + .join("\n"); + + const content = ` + ${corpus} + + + Using the above message corpus, generate a bulleted summary of anything relevant to the following topic: **${topic}**. Mention specific things people said and anything useful to document. Pull all details relevant to ${topic}. + + When reading a message, the first part is the username and the second part is the timestamp. For example, [User A 2021-08-01T00:00:00.000Z]. + + Avoid pinging users, only use their username (e.g. Ray said ...). Follow all markdown rules relevant to Discord. + + Use an analytical tone. Include relevant details. For example, "User A mentioned that they were going to the store. User B responded with a question about the store's location." + Include as much detail as possible. At the end summarize any conclusions or decisions made. + `.trim(); + + const response = await groq.chat.completions.create({ + messages: [ + { + role: "user", + content, + }, + ], + model: "llama-3.3-70b-versatile", + }); + + const thread = (await channel.threads.create({ + name: `Summary of ${displayTopic} from ${formatted}`, + autoArchiveDuration: 60, + reason: `Summarizing messages related to ${displayTopic} from ${formatted}.`, + })) as PublicThreadChannel; + + const message = response.choices[0].message; + + if (!message.content) { + console.error("No content"); + await thread.send("Error: No content"); + return; + } + + if (message.content.length > 2000) { + const chunks = message.content.match(/[\s\S]{1,2000}/g); + + if (!chunks) { + console.error("No chunks"); + await thread.send("Error: No chunks"); + return; + } + + for (const chunk of chunks) { + console.log(chunk); + await thread.send(chunk); + } + } else { + await thread.send(message.content); + } +}