From c3a84e4021bfa3460f94097e28d5b6879aafda0d Mon Sep 17 00:00:00 2001 From: YashRaj <91825864+YashRajCodes@users.noreply.github.com> Date: Wed, 25 Feb 2026 08:32:23 +0530 Subject: [PATCH] feat: add a plugin to solve discord trivias using ai (openai compatible endpoints) (#788) * feat(triviaAI): initial commit * feat(triviaAI): configurable api endpoint * feat(triviaAI): better descriptions and stuff ig * feat(triviaAI): add decancer support * chore(triviaAI): set autorespond to false by default * refactor: use logger instead of console.log * chore: ai wants periods * unfeat: remove decancer because no external libs * chore: consistent imports * refactor: readability * refactor: error handling * refactor: simplify loop * refactor: import insertTextIntoChatInputBox from @utils/discord * feat: ok maybe toasts are not unnecessary * feat: check if api key, endpoint or models are set before doing anything * refactor: remove too many if statements --- src/equicordplugins/triviaAI/index.tsx | 66 ++++++++ src/equicordplugins/triviaAI/settings.ts | 53 +++++++ src/equicordplugins/triviaAI/utils.ts | 188 +++++++++++++++++++++++ src/utils/constants.ts | 6 +- 4 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 src/equicordplugins/triviaAI/index.tsx create mode 100644 src/equicordplugins/triviaAI/settings.ts create mode 100644 src/equicordplugins/triviaAI/utils.ts diff --git a/src/equicordplugins/triviaAI/index.tsx b/src/equicordplugins/triviaAI/index.tsx new file mode 100644 index 000000000..f4c860347 --- /dev/null +++ b/src/equicordplugins/triviaAI/index.tsx @@ -0,0 +1,66 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin, { IconComponent } from "@utils/types"; +import { Message } from "@vencord/discord-types"; +import { findExportedComponentLazy } from "@webpack"; +import { ChannelStore, Menu } from "@webpack/common"; + +import { settings } from "./settings"; +import { getResponse, handleResponse, parseMessageContent } from "./utils"; + +const RobotIconLazy = findExportedComponentLazy("RobotIcon"); +const RobotIcon: IconComponent = props => ; + +const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }: { message: Message; }) => { + const payload = parseMessageContent(message); + if (!payload) return; + + const group = findGroupChildrenByChildId("copy-text", children); + if (!group) return; + + group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, ( + { + const ans = await getResponse(payload); + handleResponse(message, ans); + }} + /> + )); +}; + +export default definePlugin({ + name: "TriviaAI", + description: "A plugin that helps you answer trivia questions using AI.", + authors: [EquicordDevs.yash], + settings, + contextMenus: { + "message": messageCtxPatch + }, + messagePopoverButton: { + icon: RobotIcon, + render(message: Message) { + const payload = parseMessageContent(message); + if (!payload) return null; + + return { + label: "Answer With AI", + icon: RobotIcon, + message, + channel: ChannelStore.getChannel(message.channel_id), + onClick: async () => { + const ans = await getResponse(payload); + handleResponse(message, ans); + } + }; + } + } +}); diff --git a/src/equicordplugins/triviaAI/settings.ts b/src/equicordplugins/triviaAI/settings.ts new file mode 100644 index 000000000..b4a97e823 --- /dev/null +++ b/src/equicordplugins/triviaAI/settings.ts @@ -0,0 +1,53 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; + +export const settings = definePluginSettings({ + apiKey: { + type: OptionType.STRING, + description: "API Key.", + default: "", + placeholder: "Enter API Key here for your AI endpoint.", + componentProps: { + type: "password" + } + }, + model: { + type: OptionType.STRING, + description: "AI Model to use.", + default: "google/gemini-3-flash-preview", + placeholder: "e.g. google/gemini-3-flash-preview, inception/mercury, openai/gpt-5.2-chat, etc." + }, + systemPrompt: { + type: OptionType.STRING, + description: "System Prompt for the AI.", + default: "You are a helpful assistant who answers questions for the user in a concise and short way while using the least amount of words and punctuation.", + placeholder: "Enter system prompt." + }, + maxTokens: { + type: OptionType.NUMBER, + description: "Maximum number of tokens in the response.", + default: 500 + }, + endpoint: { + type: OptionType.STRING, + description: "OpenAI Compatible AI Endpoint.", + default: "https://openrouter.ai/api/v1/chat/completions", + placeholder: "Enter your OpenAI compatible AI endpoint here." + }, + autoRespond: { + type: OptionType.BOOLEAN, + description: "Automatically respond to messages on receiving a response.", + default: false + }, + supportImages: { + type: OptionType.BOOLEAN, + description: "Pass images to the AI for context (if any). This is not supported by all models.", + default: true + } +}); diff --git a/src/equicordplugins/triviaAI/utils.ts b/src/equicordplugins/triviaAI/utils.ts new file mode 100644 index 000000000..501a14ffb --- /dev/null +++ b/src/equicordplugins/triviaAI/utils.ts @@ -0,0 +1,188 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { insertTextIntoChatInputBox, sendMessage } from "@utils/discord"; +import { Logger } from "@utils/Logger"; +import { Message } from "@vencord/discord-types"; +import { showToast, Toasts } from "@webpack/common"; + +import { settings } from "./settings"; + +const logger = new Logger("TriviaAI"); + +type TextPart = { + type: "text"; + text: string; +}; + +type ImagePart = { + type: "image_url"; + image_url: { + url: string; + detail?: "auto" | "high" | "low"; + }; +}; + +export type ContentPayload = string | (TextPart | ImagePart)[]; + +export function parseMessageContent(message: Message): ContentPayload | null { + const textParts: string[] = []; + + if (message.content && message.content.trim().length > 0) { + textParts.push(message.content); + } + + message.embeds.forEach(embed => { + const embedBuffer: string[] = []; + + const parts = [ + embed.provider?.name ? `> ${embed.provider.name}` : null, + embed.author?.name ? `**${embed.author.name}**` : null, + embed.rawTitle ? `## ${embed.rawTitle}` : null, + embed.rawDescription ?? null, + ...(embed.fields?.map(f => (f.rawName && f.rawValue) ? `**${f.rawName}**: ${f.rawValue}` : null) ?? []), + embed.footer?.text ? `_${embed.footer.text}_` : null, + ]; + + parts.forEach(p => { + if (p) embedBuffer.push(p); + }); + + if (embedBuffer.length > 0) textParts.push(embedBuffer.join("\n")); + }); + + const combinedText = textParts.join("\n\n"); + + if (!settings.store.supportImages) { + return combinedText || null; + } + + const imageUrls = new Set(); + + message.attachments + .filter(att => att.content_type?.startsWith("image/")) + .forEach(att => imageUrls.add(att.url)); + + message.embeds.forEach(embed => { + const potentialUrls = [ + embed.image?.url, + embed.thumbnail?.url, + ...(embed.images?.map(img => img.url) ?? []) + ]; + + potentialUrls.forEach(url => { + if (url) imageUrls.add(url); + }); + }); + + if (imageUrls.size === 0) { + return combinedText || null; + } + + const payload: (TextPart | ImagePart)[] = []; + + if (combinedText.length > 0) { + payload.push({ + type: "text", + text: combinedText + }); + } + + imageUrls.forEach(url => { + payload.push({ + type: "image_url", + image_url: { url } + }); + }); + + return payload; +} + +export async function handleResponse(message: Message, response: string): Promise { + if (settings.store.autoRespond) { + sendMessage( + message.channel_id, + { content: response }, + true, + { messageReference: { channel_id: message.channel_id, message_id: message.id } } + ); + return response; + } else { + insertTextIntoChatInputBox(response); + return response; + } +} + +export async function getResponse(payload: ContentPayload): Promise { + if (!settings.store.apiKey) { + showToast("TriviaAI: API Key is not set.", Toasts.Type.FAILURE); + return ""; + } + + if (!settings.store.endpoint) { + showToast("TriviaAI: Endpoint is not set.", Toasts.Type.FAILURE); + return ""; + } + + if (!settings.store.model) { + showToast("TriviaAI: Model is not set.", Toasts.Type.FAILURE); + return ""; + } + + try { + const req = await fetch(settings.store.endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${settings.store.apiKey}` + }, + body: JSON.stringify({ + model: settings.store.model, + messages: [ + { + role: "system", + content: settings.store.systemPrompt + }, + { + role: "user", + content: payload + } + ], + max_tokens: settings.store.maxTokens, + }) + }); + + if (!req.ok) { + const errorBody = await req.text(); + const errorMessage = `API request failed with status ${req.status}: ${errorBody}`; + logger.error(errorMessage); + showToast(errorMessage, Toasts.Type.FAILURE); + return ""; + } + + const data = await req.json(); + + if (data.error) { + const errorMessage = data.error.message ?? "An unknown error occurred"; + logger.error(`API Error: ${errorMessage}`); + showToast(errorMessage, Toasts.Type.FAILURE); + return ""; + } + + const response = data.choices[0].message.content; + + if (!response?.trim()) { + logger.warn("no response from AI model"); + return ""; + } + + return response; + } catch (e) { + logger.error("Error getting response from AI model", e); + showToast("Error getting response from AI model", Toasts.Type.FAILURE); + return ""; + } +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 35ad0c963..4041fe3b8 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1255,7 +1255,11 @@ export const EquicordDevs = Object.freeze({ davri: { name: "Davri", id: 457579346282938368n, - } + }, + yash: { + name: "yash", + id: 889150838658977874n, + }, } satisfies Record); // iife so #__PURE__ works correctly