From 1f99e26b500bdbc91e73c6bf0ac0f5897bf4d745 Mon Sep 17 00:00:00 2001 From: jrmyr Date: Thu, 1 May 2025 20:14:48 +0000 Subject: [PATCH 1/2] Initial git support. --- _opt/gitUtils.js | 77 ++++++++++++++++++++++++++++++++++++ _opt/messageQueue-example.js | 28 +++++++++++++ _src/loader.js | 17 ++++---- config.js | 1 + package-lock.json | 22 +++++++++++ package.json | 1 + 6 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 _opt/gitUtils.js create mode 100644 _opt/messageQueue-example.js diff --git a/_opt/gitUtils.js b/_opt/gitUtils.js new file mode 100644 index 0000000..8af6d80 --- /dev/null +++ b/_opt/gitUtils.js @@ -0,0 +1,77 @@ +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +const execAsync = promisify(exec); + +/** + * Git-related slash commands: /gitstatus and /gitpull + */ +export const commands = [ + // Show current branch and commit status + { + data: new SlashCommandBuilder() + .setName('gitstatus') + .setDescription('Show current git branch and remote status'), + async execute(interaction, client) { + try { + // Get current branch and commit + const { stdout: branchOut } = await execAsync('git rev-parse --abbrev-ref HEAD'); + const branch = branchOut.trim(); + const { stdout: localOut } = await execAsync('git rev-parse HEAD'); + const local = localOut.trim().slice(0, 7); + // Fetch updates and get remote commit + await execAsync('git fetch --quiet'); + const { stdout: remoteOut } = await execAsync(`git rev-parse origin/${branch}`); + const remote = remoteOut.trim().slice(0, 7); + // Determine ahead/behind counts + const { stdout: behindOut } = await execAsync(`git rev-list --count HEAD..origin/${branch}`); + const behind = parseInt(behindOut.trim(), 10); + const { stdout: aheadOut } = await execAsync(`git rev-list --count origin/${branch}..HEAD`); + const ahead = parseInt(aheadOut.trim(), 10); + const status = (ahead === 0 && behind === 0) + ? 'Up-to-date' : `${ahead} ahead, ${behind} behind`; + // Reply with status + await interaction.reply({ + content: `Branch: \`${branch}\`\nLocal: \`${local}\`\nRemote: \`${remote}\`\nStatus: ${status}`, + ephemeral: true + }); + } catch (err) { + client.logger.error(`Error in gitstatus: ${err.message}`); + await interaction.reply({ content: `Failed to get git status: ${err.message}`, ephemeral: true }); + } + } + }, + // Pull latest changes and restart bot (owner only) + { + data: new SlashCommandBuilder() + .setName('gitpull') + .setDescription('Pull latest changes and restart bot (Owner only)') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + async execute(interaction, client) { + const ownerId = client.config.owner; + if (interaction.user.id !== ownerId) { + return interaction.reply({ content: 'Only the bot owner can perform this action.', ephemeral: true }); + } + await interaction.deferReply({ ephemeral: true }); + try { + // Pull with fast-forward only + const { stdout, stderr } = await execAsync('git pull --ff-only'); + const output = stdout.trim() || stderr.trim(); + await interaction.editReply({ + content: `Git pull output:\n\`\`\`\n${output}\n\`\`\`\nRestarting...` + }); + setTimeout(() => process.exit(0), 1000); + } catch (err) { + client.logger.error(`Error in gitpull: ${err.message}`); + await interaction.editReply({ content: `Git pull failed: ${err.message}`, ephemeral: true }); + } + } + } +]; + +/** + * No special init logic for git utilities + */ +export async function init(client, config) { + client.logger.info('Git utilities module loaded'); +} \ No newline at end of file diff --git a/_opt/messageQueue-example.js b/_opt/messageQueue-example.js new file mode 100644 index 0000000..bbf1674 --- /dev/null +++ b/_opt/messageQueue-example.js @@ -0,0 +1,28 @@ +// _opt/messageQueue-example.js +import { onMessageQueueEvent } from './pbUtils.js'; + +/** + * Example module that listens for 'test' messages in the message_queue collection. + */ +export const init = async (client, config) => { + client.logger.info('Initializing Message Queue Example module'); + onMessageQueueEvent(client, async (action, record) => { + // Only process newly created records + if (action !== 'create') return; + // Only process messages meant for this client + if (record.destination !== client.config.id) return; + // Only handle test dataType + if (record.dataType !== 'test') return; + + // At this point we have a test message for us + client.logger.info('test received'); + + // Delete the processed message from the queue + try { + await client.pb.deleteMessageQueue(record.id); + client.logger.debug(`Deleted message_queue record ${record.id}`); + } catch (err) { + client.logger.error(`Failed to delete message_queue record ${record.id}: ${err.message}`); + } + }); +}; \ No newline at end of file diff --git a/_src/loader.js b/_src/loader.js index 12693ba..6bec33d 100644 --- a/_src/loader.js +++ b/_src/loader.js @@ -19,13 +19,16 @@ export const loadModules = async (clientConfig, client) => { // Load each module for (const moduleName of modules) { try { - const modulePath = path.join(modulesDir, `${moduleName}.js`); - - // Check if module exists - if (!fs.existsSync(modulePath)) { - client.logger.warn(`Module not found: ${modulePath}`); - continue; - } + // Try _opt first, then fallback to core _src modules + let modulePath = path.join(modulesDir, `${moduleName}.js`); + if (!fs.existsSync(modulePath)) { + // Fallback to core source directory + modulePath = path.join(rootDir, '_src', `${moduleName}.js`); + if (!fs.existsSync(modulePath)) { + client.logger.warn(`Module not found in _opt or _src: ${moduleName}.js`); + continue; + } + } // Import module (using dynamic import for ES modules) // Import module diff --git a/config.js b/config.js index cf61dd2..57ea88b 100644 --- a/config.js +++ b/config.js @@ -70,6 +70,7 @@ export default { 'pbUtils', 'responses', 'responsesQuery', + 'gitUtils' ] }, diff --git a/package-lock.json b/package-lock.json index b36a723..33460e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "discord-api-types": "^0.37.120", "discord.js": "^14.18.0", "dotenv": "^16.5.0", + "eventsource": "^3.0.6", "node-cron": "^3.0.3", "openai": "^4.95.1", "pocketbase": "^0.25.2", @@ -519,6 +520,27 @@ "node": ">=6" } }, + "node_modules/eventsource": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", + "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", + "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index 54298e8..268165b 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "discord-api-types": "^0.37.120", "discord.js": "^14.18.0", "dotenv": "^16.5.0", + "eventsource": "^3.0.6", "node-cron": "^3.0.3", "openai": "^4.95.1", "pocketbase": "^0.25.2", From c35aeec42fc8313cba50637880de11a076b24379 Mon Sep 17 00:00:00 2001 From: jrmyr Date: Thu, 1 May 2025 21:10:20 +0000 Subject: [PATCH 2/2] gitUtils added --- _opt/gitUtils.js | 141 ++++++++++++++++++++++++++++------------------- config.js | 2 +- 2 files changed, 84 insertions(+), 59 deletions(-) diff --git a/_opt/gitUtils.js b/_opt/gitUtils.js index 8af6d80..f743c06 100644 --- a/_opt/gitUtils.js +++ b/_opt/gitUtils.js @@ -1,77 +1,102 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { SlashCommandBuilder } from 'discord.js'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); -/** - * Git-related slash commands: /gitstatus and /gitpull - */ +// Wrap Git errors +class GitError extends Error { + constructor(message) { + super(message); + this.name = 'GitError'; + } +} + +// Run `git ` and return trimmed output or throw +async function runGit(args) { + try { + const { stdout, stderr } = await execAsync(`git ${args.join(' ')}`); + const out = stdout.trim() || stderr.trim(); + return out || '(no output)'; + } catch (err) { + const msg = err.stderr?.trim() || err.message; + throw new GitError(msg); + } +} + +// Wrap content in Markdown code block +function formatCodeBlock(content, lang = '') { + const fence = '```'; + return lang + ? `${fence}${lang}\n${content}\n${fence}` + : `${fence}\n${content}\n${fence}`; +} + +// Split string into chunks of at most chunkSize +function chunkString(str, chunkSize) { + const chunks = []; + for (let i = 0; i < str.length; i += chunkSize) { + chunks.push(str.slice(i, i + chunkSize)); + } + return chunks; +} + +// Single /git command: run arbitrary git export const commands = [ - // Show current branch and commit status { data: new SlashCommandBuilder() - .setName('gitstatus') - .setDescription('Show current git branch and remote status'), - async execute(interaction, client) { - try { - // Get current branch and commit - const { stdout: branchOut } = await execAsync('git rev-parse --abbrev-ref HEAD'); - const branch = branchOut.trim(); - const { stdout: localOut } = await execAsync('git rev-parse HEAD'); - const local = localOut.trim().slice(0, 7); - // Fetch updates and get remote commit - await execAsync('git fetch --quiet'); - const { stdout: remoteOut } = await execAsync(`git rev-parse origin/${branch}`); - const remote = remoteOut.trim().slice(0, 7); - // Determine ahead/behind counts - const { stdout: behindOut } = await execAsync(`git rev-list --count HEAD..origin/${branch}`); - const behind = parseInt(behindOut.trim(), 10); - const { stdout: aheadOut } = await execAsync(`git rev-list --count origin/${branch}..HEAD`); - const ahead = parseInt(aheadOut.trim(), 10); - const status = (ahead === 0 && behind === 0) - ? 'Up-to-date' : `${ahead} ahead, ${behind} behind`; - // Reply with status - await interaction.reply({ - content: `Branch: \`${branch}\`\nLocal: \`${local}\`\nRemote: \`${remote}\`\nStatus: ${status}`, - ephemeral: true - }); - } catch (err) { - client.logger.error(`Error in gitstatus: ${err.message}`); - await interaction.reply({ content: `Failed to get git status: ${err.message}`, ephemeral: true }); - } - } - }, - // Pull latest changes and restart bot (owner only) - { - data: new SlashCommandBuilder() - .setName('gitpull') - .setDescription('Pull latest changes and restart bot (Owner only)') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + .setName('git') + .setDescription('Run an arbitrary git command (owner only)') + .addStringOption(opt => + opt.setName('args') + .setDescription('Arguments to pass to git') + .setRequired(true)) + .addBooleanOption(opt => + opt.setName('ephemeral') + .setDescription('Make the reply ephemeral') + .setRequired(false)), async execute(interaction, client) { const ownerId = client.config.owner; if (interaction.user.id !== ownerId) { - return interaction.reply({ content: 'Only the bot owner can perform this action.', ephemeral: true }); + return interaction.reply({ content: 'Only the bot owner can run git commands.', ephemeral: true }); } - await interaction.deferReply({ ephemeral: true }); + const raw = interaction.options.getString('args'); + // Disallow semicolons to prevent command chaining + if (raw.includes(';')) { + return interaction.reply({ content: 'Semicolons are not allowed in git arguments.', ephemeral: true }); + } + const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; + const args = raw.match(/(?:[^\s"]+|"[^"]*")+/g) + .map(s => s.replace(/^"(.+)"$/, '$1')); + try { - // Pull with fast-forward only - const { stdout, stderr } = await execAsync('git pull --ff-only'); - const output = stdout.trim() || stderr.trim(); - await interaction.editReply({ - content: `Git pull output:\n\`\`\`\n${output}\n\`\`\`\nRestarting...` - }); - setTimeout(() => process.exit(0), 1000); + // Log the exact git command being executed + const cmdStr = args.join(' '); + client.logger.warn(`Executing git command: git ${cmdStr}`); + const output = await runGit(args); + // Prepend the git command as a header; keep it intact when chunking + const header = `git ${cmdStr}\n`; + // Discord message limit ~2000; reserve for code fences + const maxContent = 1990; + // Calculate how much output can fit after the header in the first chunk + const firstChunkSize = Math.max(0, maxContent - header.length); + // Split the raw output into chunks + const outputChunks = chunkString(output, firstChunkSize); + // Send first block with header + first output chunk + const firstBlock = header + (outputChunks[0] || ''); + await interaction.reply({ content: formatCodeBlock(firstBlock), ephemeral }); + // Send any remaining blocks without the header + for (let i = 1; i < outputChunks.length; i++) { + await interaction.followUp({ content: formatCodeBlock(outputChunks[i]), ephemeral }); + } } catch (err) { - client.logger.error(`Error in gitpull: ${err.message}`); - await interaction.editReply({ content: `Git pull failed: ${err.message}`, ephemeral: true }); + const msg = err instanceof GitError ? err.message : String(err); + await interaction.reply({ content: `Error: ${msg}`, ephemeral: true }); } } } ]; -/** - * No special init logic for git utilities - */ -export async function init(client, config) { - client.logger.info('Git utilities module loaded'); +// No special init logic +export async function init(client) { + client.logger.warn('Git utilities module loaded - dangerous module, use with caution'); } \ No newline at end of file diff --git a/config.js b/config.js index 57ea88b..6a1ebba 100644 --- a/config.js +++ b/config.js @@ -7,7 +7,7 @@ export default { { id: 'IO3', enabled: true, - owner: 378741522822070272, + owner: process.env.OWNER_ID, discord: { appId: process.env.IO3_DISCORD_APPID,