|
| 1 | +// AI Chat (OpenAI-compatible) plugin |
| 2 | +// Lets users ask questions with an in-game command and/or a Discord slash command |
| 3 | +// without touching any core files. Designed to work with any OpenAI-compatible API |
| 4 | +// (e.g., Zuki/Journey free endpoints) by configuring the base URL, model and key. |
| 5 | + |
| 6 | +const Constants = require('../src/util/constants.js'); |
| 7 | + |
| 8 | +module.exports = { |
| 9 | + defaultEnabled: false, |
| 10 | + displayName: 'AI Chat (OpenAI-compatible)', |
| 11 | + description: 'Ask AI with !ai <question> or /ai, using a configurable OpenAI-compatible API (e.g., Zuki free model).', |
| 12 | + |
| 13 | + // All fields render as a short text input in the plugin modal. Parse as needed. |
| 14 | + // Keep at <= 5 fields to satisfy Discord modal limits |
| 15 | + configSchema: { |
| 16 | + command: { type: 'text', label: 'In-game command (without prefix)', default: 'ai' }, |
| 17 | + apiUrl: { type: 'text', label: 'API base (OpenAI-compatible)', default: 'https://api.zukijourney.com/v1' }, |
| 18 | + apiKey: { type: 'text', label: 'API key (optional if not required)', default: '' }, |
| 19 | + model: { type: 'text', label: 'Model name', default: 'gpt-3.5-turbo' }, |
| 20 | + systemPrompt: { type: 'text', label: 'System prompt (optional)', default: 'You are a helpful assistant. If the user\'s request is ambiguous yet plausibly about the video game Rust, interpret and answer in the Rust (video game) context. If it\'s clearly not about Rust, answer normally.' } |
| 21 | + }, |
| 22 | + |
| 23 | + // Simple in-memory cooldown per guild |
| 24 | + _cooldowns: {}, |
| 25 | + |
| 26 | + // In-game command handler: !ai <question> |
| 27 | + onInGameCommand: async ({ rustplus, client, command }) => { |
| 28 | + const guildId = rustplus.guildId; |
| 29 | + const instance = client.getInstance(guildId); |
| 30 | + const settings = (instance.pluginSettings && instance.pluginSettings['ai-chat-zuki.js']) || {}; |
| 31 | + |
| 32 | + const prefix = rustplus.generalSettings.prefix || '!'; |
| 33 | + const cmd = (settings.command || 'ai').trim().toLowerCase(); |
| 34 | + const expectedStart = `${prefix}${cmd}`; |
| 35 | + if (!command.toLowerCase().startsWith(expectedStart)) return false; |
| 36 | + |
| 37 | + const question = command.slice(expectedStart.length).trim(); |
| 38 | + if (!question) { |
| 39 | + await rustplus.sendInGameMessage(`Usage: ${expectedStart} <question>`); |
| 40 | + return true; |
| 41 | + } |
| 42 | + |
| 43 | + if (!module.exports._passCooldown(guildId, settings)) { |
| 44 | + await rustplus.sendInGameMessage('Please wait before sending another request.'); |
| 45 | + return true; |
| 46 | + } |
| 47 | + |
| 48 | + try { |
| 49 | + const resp = await module.exports._chatComplete(client, guildId, settings, question); |
| 50 | + const text = (resp || 'No response').replace(/\s+/g, ' ').trim(); |
| 51 | + // In-game message length is limited; trim to be safe |
| 52 | + const max = Math.max(32, Constants.MAX_LENGTH_TEAM_MESSAGE || 128); |
| 53 | + await rustplus.sendInGameMessage(text.length > max ? `${text.slice(0, max - 3)}...` : text); |
| 54 | + } catch (e) { |
| 55 | + await rustplus.sendInGameMessage(`AI error: ${e?.message || e}`); |
| 56 | + } |
| 57 | + return true; |
| 58 | + }, |
| 59 | + |
| 60 | + // Provide a /ai question:<string> slash command |
| 61 | + slashCommands: [ |
| 62 | + { |
| 63 | + name: 'ai', |
| 64 | + getData(client, guildId) { |
| 65 | + const Builder = require('@discordjs/builders'); |
| 66 | + return new Builder.SlashCommandBuilder() |
| 67 | + .setName('ai') |
| 68 | + .setDescription('Ask the configured AI model a question') |
| 69 | + .addStringOption(option => option |
| 70 | + .setName('question') |
| 71 | + .setDescription('Your question') |
| 72 | + .setRequired(true)); |
| 73 | + }, |
| 74 | + async execute(client, interaction) { |
| 75 | + const verifyId = Math.floor(100000 + Math.random() * 900000); |
| 76 | + client.logInteraction(interaction, verifyId, 'slashCommand'); |
| 77 | + if (!await client.validatePermissions(interaction)) return; |
| 78 | + await interaction.deferReply({ ephemeral: false }); |
| 79 | + |
| 80 | + const guildId = interaction.guildId; |
| 81 | + const instance = client.getInstance(guildId); |
| 82 | + const settings = (instance.pluginSettings && instance.pluginSettings['ai-chat-zuki.js']) || {}; |
| 83 | + |
| 84 | + const question = interaction.options.getString('question'); |
| 85 | + if (!module.exports._passCooldown(guildId, settings)) { |
| 86 | + await client.interactionEditReply(interaction, { content: 'Please wait before sending another request.' }); |
| 87 | + return; |
| 88 | + } |
| 89 | + try { |
| 90 | + const answer = await module.exports._chatComplete(client, guildId, settings, question); |
| 91 | + await client.interactionEditReply(interaction, { content: answer || 'No response' }); |
| 92 | + } catch (e) { |
| 93 | + await client.interactionEditReply(interaction, { content: `AI error: ${e?.message || e}` }); |
| 94 | + } |
| 95 | + } |
| 96 | + } |
| 97 | + ], |
| 98 | + |
| 99 | + // Helpers |
| 100 | + _passCooldown(guildId, settings) { |
| 101 | + const cd = Math.max(0, parseInt((settings.cooldownSeconds ?? '10'), 10) || 0); |
| 102 | + const now = Date.now(); |
| 103 | + const last = module.exports._cooldowns[guildId] || 0; |
| 104 | + if (now - last < cd * 1000) return false; |
| 105 | + module.exports._cooldowns[guildId] = now; |
| 106 | + return true; |
| 107 | + }, |
| 108 | + |
| 109 | + async _chatComplete(client, guildId, settings, userContent) { |
| 110 | + const base = (settings.apiUrl || 'https://api.zukijourney.com/v1').trim().replace(/\/$/, ''); |
| 111 | + const url = `${base}/chat/completions`; |
| 112 | + const apiKey = (settings.apiKey || '').trim(); |
| 113 | + const model = (settings.model || 'gpt-3.5-turbo').trim(); |
| 114 | + const system = (settings.systemPrompt || 'You are a helpful assistant. If the user\'s request is ambiguous yet plausibly about the video game Rust, interpret and answer in the Rust (video game) context. If it\'s clearly not about Rust, answer normally.').trim(); |
| 115 | + const temperature = Math.max(0, Math.min(2, parseFloat(settings.temperature ?? '0.7') || 0.7)); |
| 116 | + const maxTokens = Math.max(1, parseInt(settings.maxTokens ?? '512', 10) || 512); |
| 117 | + |
| 118 | + const body = { |
| 119 | + model, |
| 120 | + messages: [ |
| 121 | + { role: 'system', content: system }, |
| 122 | + { role: 'user', content: userContent } |
| 123 | + ], |
| 124 | + temperature, |
| 125 | + max_tokens: maxTokens |
| 126 | + }; |
| 127 | + |
| 128 | + const headers = { 'Content-Type': 'application/json' }; |
| 129 | + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; |
| 130 | + |
| 131 | + let json; |
| 132 | + try { |
| 133 | + const resp = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) }); |
| 134 | + if (!resp.ok) { |
| 135 | + let txt = ''; |
| 136 | + try { txt = await resp.text(); } catch (_) {} |
| 137 | + throw new Error(`${resp.status} ${txt}`); |
| 138 | + } |
| 139 | + json = await resp.json(); |
| 140 | + } catch (e) { |
| 141 | + client.log(client.intlGet(null, 'errorCap'), `[ai-chat] request failed: ${e?.message || e}`, 'error'); |
| 142 | + throw e; |
| 143 | + } |
| 144 | + |
| 145 | + try { |
| 146 | + return (json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content) || null; |
| 147 | + } catch (_) { return null; } |
| 148 | + } |
| 149 | +}; |
0 commit comments