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
This commit is contained in:
parent
69ad255cba
commit
c3a84e4021
4 changed files with 312 additions and 1 deletions
66
src/equicordplugins/triviaAI/index.tsx
Normal file
66
src/equicordplugins/triviaAI/index.tsx
Normal file
|
|
@ -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 => <RobotIconLazy {...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, (
|
||||
<Menu.MenuItem
|
||||
id="vc-trivia-ai"
|
||||
label="Answer With AI"
|
||||
icon={RobotIcon}
|
||||
action={async () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
53
src/equicordplugins/triviaAI/settings.ts
Normal file
53
src/equicordplugins/triviaAI/settings.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
});
|
||||
188
src/equicordplugins/triviaAI/utils.ts
Normal file
188
src/equicordplugins/triviaAI/utils.ts
Normal file
|
|
@ -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<string>();
|
||||
|
||||
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<string> {
|
||||
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<string> {
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
|
|
@ -1255,7 +1255,11 @@ export const EquicordDevs = Object.freeze({
|
|||
davri: {
|
||||
name: "Davri",
|
||||
id: 457579346282938368n,
|
||||
}
|
||||
},
|
||||
yash: {
|
||||
name: "yash",
|
||||
id: 889150838658977874n,
|
||||
},
|
||||
} satisfies Record<string, Dev>);
|
||||
|
||||
// iife so #__PURE__ works correctly
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue