From 3eca864c25a7ed2516bc48e553142f134a759043 Mon Sep 17 00:00:00 2001 From: MrRGnome <5963774+MrRGnome@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:37:54 -0600 Subject: [PATCH] Article features update --- .gitignore | 3 + commands/article.js | 341 +++++++++++++++ commands/deletearticle.js | 163 +++++++ example.env | 13 +- index.js | 158 ++++++- services/articles.js | 885 ++++++++++++++++++++++++++++++++++++++ utils/discordutils.js | 22 +- 7 files changed, 1578 insertions(+), 7 deletions(-) create mode 100644 commands/article.js create mode 100644 commands/deletearticle.js create mode 100644 services/articles.js diff --git a/.gitignore b/.gitignore index 58c60cb..0232966 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,6 @@ dist # VScode config .vscode + +# Local temp scripts/output +tmp/ diff --git a/commands/article.js b/commands/article.js new file mode 100644 index 0000000..07ec4d7 --- /dev/null +++ b/commands/article.js @@ -0,0 +1,341 @@ +const { ChannelType } = require('discord.js'); +const { createDraftArticle, setArticleForumPostUrl } = require('../services/articles'); +const { canEditData } = require('../utils/discordutils'); + +const DISCORD_MESSAGE_LINK_REGEX = /?/gi; + +function canUserCreateArticle(member) { + return canEditData(member); +} + +async function formatAuthorProfile(sourceMessage) { + const discordUsername = sourceMessage?.author?.username; + if (!discordUsername) { + return { + username: 'Anonymous', + image: '' + }; + } + + let displayName = sourceMessage?.member?.displayName; + + if (!displayName && sourceMessage?.guild && sourceMessage?.author?.id) { + const fetchedMember = await sourceMessage.guild.members.fetch(sourceMessage.author.id).catch(() => null); + displayName = fetchedMember?.displayName; + } + + if (!displayName || /unknown|deleted/i.test(discordUsername) || /unknown|deleted/i.test(displayName)) { + return { + username: 'Anonymous', + image: '' + }; + } + + return { + username: `${discordUsername} (${displayName})`, + image: sourceMessage?.author?.displayAvatarURL?.() ?? '' + }; +} + +function addUniqueAuthor(authorMap, profile) { + const username = String(profile?.username || '').trim() || 'Anonymous'; + const image = String(profile?.image || ''); + + if (!authorMap.has(username)) { + authorMap.set(username, image); + return; + } + + const existing = authorMap.get(username); + if (!existing && image) + authorMap.set(username, image); +} + +function parseArticleCommandInput(message) { + const prefix = message.client.prefix || '!'; + const raw = String(message.content || ''); + + if (!raw.startsWith(prefix)) + return { title: '', bodyTemplate: '' }; + + const withoutPrefix = raw.slice(prefix.length); + const commandMatch = withoutPrefix.match(/^\s*article\b/i); + if (!commandMatch) + return { title: '', bodyTemplate: '' }; + + const remainder = withoutPrefix.slice(commandMatch[0].length).replace(/^\s*/, ''); + if (!remainder) + return { title: '', bodyTemplate: '' }; + + const newlineIndex = remainder.indexOf('\n'); + if (newlineIndex === -1) { + return { + title: remainder.trim(), + bodyTemplate: '' + }; + } + + return { + title: remainder.slice(0, newlineIndex).trim(), + bodyTemplate: remainder.slice(newlineIndex + 1).trim() + }; +} + +async function resolveArticleForumChannel(guild) { + const configuredChannel = process.env.ARTICLE_CHANNEL; + if (!configuredChannel || !guild) + return null; + + let channel = guild.channels.cache.get(configuredChannel) + || guild.channels.cache.find(ch => ch.name === configuredChannel); + + if (!channel && /^\d+$/.test(configuredChannel)) { + channel = await guild.channels.fetch(configuredChannel).catch(() => null); + } + + if (!channel || channel.type !== ChannelType.GuildForum) + return null; + + return channel; +} + +async function fetchLinkedDiscordMessage(commandMessage, guildId, channelId, messageId) { + if (!commandMessage.guild) + throw new Error('Articles can only be created inside a server.'); + + if (String(commandMessage.guild.id) !== String(guildId)) + throw new Error('All linked messages must be from this server.'); + + let channel = commandMessage.guild.channels.cache.get(channelId); + if (!channel) { + channel = await commandMessage.guild.channels.fetch(channelId).catch(() => null); + } + + if (!channel || typeof channel.messages?.fetch !== 'function') + throw new Error(`Unable to access linked channel ${channelId}.`); + + const linkedMessage = await channel.messages.fetch(messageId).catch(() => null); + if (!linkedMessage) + throw new Error(`Unable to load linked message ${messageId}.`); + + return linkedMessage; +} + +function isImageAttachment(attachment) { + if (!attachment) + return false; + + const contentType = String(attachment.contentType || '').toLowerCase(); + if (contentType.startsWith('image/')) + return true; + + if (typeof attachment.width === 'number' && attachment.width > 0) + return true; + + const url = String(attachment.url || '').toLowerCase(); + return /\.(png|jpe?g|gif|webp|bmp|svg)(?:\?.*)?$/.test(url); +} + +function collectLinkedMessageContent(message) { + const messageBody = String(message?.content || ''); + const imageLinks = Array + .from(message?.attachments?.values?.() || []) + .filter(isImageAttachment) + .map(attachment => String(attachment.url || '').trim()) + .filter(Boolean); + + if (!imageLinks.length) + return messageBody; + + if (!messageBody.trim()) + return imageLinks.join('\n'); + + return `${messageBody}\n${imageLinks.join('\n')}`; +} + +async function resolvePromotionEmojiToken(guild) { + const fallback = ':btc:'; + if (!guild?.emojis) + return fallback; + + let emoji = guild.emojis.cache.find(entry => String(entry.name || '').toLowerCase() === 'btc'); + if (!emoji) { + await guild.emojis.fetch().catch(() => null); + emoji = guild.emojis.cache.find(entry => String(entry.name || '').toLowerCase() === 'btc'); + } + + if (!emoji) + return fallback; + + const animatedPrefix = emoji.animated ? 'a' : ''; + return `<${animatedPrefix}:${emoji.name}:${emoji.id}>`; +} + +function buildPreviewContent(article, promotionEmoji) { + const articleUrl = `https://btcmaxis.com/article.html?id=${article.id}`; + const emojiToken = String(promotionEmoji || ':btc:'); + const snippet = String(article.content || '').slice(0, 1700); + + return `[Full Article Here](${articleUrl})\n${snippet}... [Read More](${articleUrl})\n\nReact with ${emojiToken} to promote this article to the front page of btcmaxis.com`; +} +async function articleCommand(message, args) { + if (process.env.ENABLE_ARTICLES !== '1') { + await message.reply('Article system is disabled.'); + return; + } + + if (!message.guild) { + await message.reply('Articles can only be created in a server channel.'); + return; + } + + const forumChannel = await resolveArticleForumChannel(message.guild); + if (!forumChannel) { + await message.reply('ARTICLE_CHANNEL is missing or not a forum channel.'); + return; + } + + let member = message.member; + if (!member) { + member = await message.guild.members.fetch(message.author.id).catch(() => null); + } + + if (!canUserCreateArticle(member)) { + await message.reply('You do not have permission to create articles.'); + return; + } + + const { title, bodyTemplate } = parseArticleCommandInput(message); + if (!title || !bodyTemplate) { + await message.reply(`Usage: \`${message.client.prefix}article \` then body text with Discord message links.`); + return; + } + + DISCORD_MESSAGE_LINK_REGEX.lastIndex = 0; + const matches = Array.from(bodyTemplate.matchAll(DISCORD_MESSAGE_LINK_REGEX)); + if (matches.length === 0) { + await message.reply('Include at least one Discord message link in the article body.'); + return; + } + + const contributorMap = new Map(); + const linkedMessageIds = []; + + const callerProfile = await formatAuthorProfile(message); + const authorProfile = { + username: String(callerProfile?.username || 'Anonymous').trim() || 'Anonymous', + image: String(callerProfile?.image || '') + }; + + const replacementByToken = new Map(); + const replacementByMessageId = new Map(); + + for (const match of matches) { + const rawToken = String(match[0]); + const guildId = String(match[1]); + const channelId = String(match[2]); + const linkedMessageId = String(match[3]); + + if (replacementByMessageId.has(linkedMessageId)) { + replacementByToken.set(rawToken, replacementByMessageId.get(linkedMessageId)); + continue; + } + + let linkedMessage; + try { + linkedMessage = await fetchLinkedDiscordMessage(message, guildId, channelId, linkedMessageId); + } catch (error) { + await message.reply(error.message); + return; + } + + const linkedProfile = await formatAuthorProfile(linkedMessage); + const linkedUsername = String(linkedProfile?.username || '').trim() || 'Anonymous'; + if (linkedUsername.toLowerCase() !== authorProfile.username.toLowerCase()) + addUniqueAuthor(contributorMap, linkedProfile); + + const linkedContent = collectLinkedMessageContent(linkedMessage); + replacementByMessageId.set(linkedMessageId, linkedContent); + replacementByToken.set(rawToken, linkedContent); + linkedMessageIds.push(linkedMessageId); + } + + let articleContent = bodyTemplate; + for (const [rawToken, linkedContent] of replacementByToken.entries()) { + articleContent = articleContent.split(rawToken).join(linkedContent); + } + + if (!articleContent.trim()) { + await message.reply('The assembled article content is empty. Add text content to the linked messages or body.'); + return; + } + + const contributorProfiles = Array.from(contributorMap.entries()).map(([username, image]) => ({ + username, + image + })); + + const { created, article, error } = await createDraftArticle({ + title, + content: articleContent, + article_time: message.createdAt?.toISOString?.() ?? new Date().toISOString(), + published: false, + authors: [authorProfile], + contributors: contributorProfiles, + source_message_id: message.id, + source_channel_id: message.channel.id, + source_guild_id: message.guild.id, + linked_message_ids: Array.from(new Set(linkedMessageIds)) + }); + + if (!created || !article) { + const retryHint = error && /status code 409|status code 422/i.test(error) + ? ' The articles repo is busy; retry in a few seconds.' + : ''; + const detail = error ? ` Error: ${error}` : ''; + await message.reply(`Unable to create the article draft right now.${retryHint}${detail}`); + return; + } + + const promotionEmoji = await resolvePromotionEmojiToken(message.guild); + + let thread; + try { + thread = await forumChannel.threads.create({ + name: title.slice(0, 100), + message: { + content: buildPreviewContent(article, promotionEmoji) + } + }); + } catch (error) { + console.error('Error creating article forum post:', error); + await message.reply(`Draft saved with id ${article.id}, but forum post creation failed.`); + return; + } + + const forumUrlUpdate = await setArticleForumPostUrl(article.id, thread.url); + if (!forumUrlUpdate.updated) { + const detail = forumUrlUpdate.error ? ` (${forumUrlUpdate.error})` : ''; + console.error(`Draft forum URL update failed for ${article.id}${detail}`); + } + + await message.reply(`Draft created: <https://btcmaxis.com/article.html?id=${article.id}> | <${thread.url}>`); +} + +module.exports = { + article: { + execute: articleCommand + } +}; + + + + + + + + + + + + diff --git a/commands/deletearticle.js b/commands/deletearticle.js new file mode 100644 index 0000000..085739c --- /dev/null +++ b/commands/deletearticle.js @@ -0,0 +1,163 @@ +const { ChannelType } = require('discord.js'); +const { deleteArticleById } = require('../services/articles'); +const { canEditData } = require('../utils/discordutils'); + +const ARTICLE_LINK_REGEX = /https:\/\/btcmaxis\.com\/article\.html\?id=([0-9a-fA-F-]{36})/i; + +function canUserDeleteArticle(member) { + return canEditData(member); +} + + +function isInConfiguredArticleForumThread(message) { + if (!message?.channel?.isThread?.()) + return false; + + const parentChannel = message.channel.parent; + if (!parentChannel || parentChannel.type !== ChannelType.GuildForum) + return false; + + const configuredChannel = String(process.env.ARTICLE_CHANNEL || '').trim(); + if (!configuredChannel) + return false; + + return parentChannel.id === configuredChannel || parentChannel.name === configuredChannel; +} +async function fetchReplyTarget(message) { + if (!message?.reference?.messageId) + return null; + + return message.fetchReference().catch(() => null); +} + +async function fetchThreadParentPost(message) { + if (!message?.channel?.isThread?.()) + return null; + + if (message.channel.parent?.type !== ChannelType.GuildForum) + return null; + + if (typeof message.channel.fetchStarterMessage === 'function') { + const starterMessage = await message.channel.fetchStarterMessage().catch(() => null); + if (starterMessage) + return starterMessage; + } + + if (typeof message.channel.parent?.messages?.fetch === 'function') + return message.channel.parent.messages.fetch(message.channel.id).catch(() => null); + + return null; +} + +function extractArticleIdFromMessage(message) { + const content = String(message?.content || ''); + const match = content.match(ARTICLE_LINK_REGEX); + return match?.[1] || null; +} + +function isForumParentPost(message) { + if (!message?.channel?.isThread?.()) + return false; + + if (message.channel.parent?.type !== ChannelType.GuildForum) + return false; + + return message.id === message.channel.id; +} + +async function deleteArticleCommand(message, args) { + if (process.env.ENABLE_ARTICLES !== '1') { + await message.reply('Article system is disabled.'); + return; + } + + if (!message.guild) { + await message.reply('This command can only be used in a server.'); + return; + } + + let member = message.member; + if (!member) + member = await message.guild.members.fetch(message.author.id).catch(() => null); + + if (!canUserDeleteArticle(member)) { + await message.reply('You do not have permission to delete articles.'); + return; + } + + let articleId = extractArticleIdFromMessage(message); + let target = null; + let targetSource = null; + + if (!articleId) { + target = await fetchReplyTarget(message); + targetSource = target ? 'reply' : null; + + if (!target) { + const inArticleThread = isInConfiguredArticleForumThread(message); + if (!inArticleThread) { + await message.reply( + `Reply to an article post with \`${message.client.prefix}delete\`, or include a btcmaxis article link.` + ); + return; + } + + target = await fetchThreadParentPost(message); + if (target) + targetSource = 'thread_parent'; + } + + if (!target) { + // In an article forum thread without a reply target, fail silently. + return; + } + + articleId = extractArticleIdFromMessage(target); + if (!articleId) { + if (targetSource === 'thread_parent') + return; + + const targetDescriptor = targetSource === 'thread_parent' + ? 'this thread parent post' + : 'the replied message'; + await message.reply(`Could not find an article id in ${targetDescriptor}.`); + return; + } + } + + const result = await deleteArticleById(articleId); + if (!result.deleted && !result.notFound) { + const detail = result.error ? ` Error: ${result.error}` : ''; + await message.reply(`Unable to delete article data right now.${detail}`); + return; + } + + try { + if (target && isForumParentPost(target)) { + await target.channel.delete(`Article removed by ${message.author.tag}`); + return; + } + + if (target?.deletable) + await target.delete(); + + await message.reply(`Article ${articleId} removed.`); + } catch (error) { + const detail = error?.message ? ` (${error.message})` : ''; + await message.reply(`Article data was removed, but deleting the Discord post failed${detail}.`); + } +} + +module.exports = { + delete: { + execute: deleteArticleCommand + }, + deletearticle: { + execute: deleteArticleCommand + }, + delarticle: { + execute: deleteArticleCommand + } +}; + + diff --git a/example.env b/example.env index 34918a9..fa97f15 100644 --- a/example.env +++ b/example.env @@ -38,4 +38,15 @@ EASTER_EGG_TRIGGER=keyword that triggers easter egg. (OPTIONAL if ENABLE_EASTER_ EASTER_EGG_PERCENT_CHANCE=1-100 (OPTIONAL if ENABLE_EASTER_EGG=0) EASTER_EGG=Secret message to be played by bot when easter egg triggers (OPTIONAL if ENABLE_EASTER_EGG=0) -ENABLE_DELETE_STICKERS=0 or 1 to delete all stickers posted \ No newline at end of file +ENABLE_DELETE_STICKERS=0 or 1 to delete all stickers posted +ARTICLES_GITHUB_TOKEN=your-fine-grained-github-token-with-contents-write +ARTICLES_GITHUB_OWNER=MrRGnome +ARTICLES_GITHUB_REPO=articles +ARTICLES_GITHUB_BRANCH=master +ARTICLES_GITHUB_INDEX_PATH=articles.json +ARTICLES_GITHUB_ARTICLE_DIR=articles + +ENABLE_ARTICLES=0 +ARTICLE_CHANNEL=your-article-channel-id-or-name +ARTICLE_PROMOTE_THRESHOLD=2 + diff --git a/index.js b/index.js index 7720927..c7820fa 100644 --- a/index.js +++ b/index.js @@ -4,9 +4,10 @@ const { formatCurrency, getBitcoinPriceUSD } = require('./services/yahoofinance' const { getCaptchaImage, captchaForUser } = require('./services/captcha'); const { isMemo } = require('./services/memos'); const { initMutes } = require('./services/mutes'); -const { Reason } = require('./utils/discordutils'); +const { Reason, canEditData } = require('./utils/discordutils'); const { modLogAdd } = require('./services/moderation'); const { initNicks, canUserUpdateNickname } = require('./services/tempnick'); +const { publishArticleById } = require('./services/articles'); const TOKEN = process.env.DISCORD_TOKEN; @@ -330,7 +331,7 @@ client.on('messageReactionAdd', async (reaction, user) => { return; //only approved users contribute to starboarding, otherwise disallow star reactions - if (member?.roles?.cache?.some(role => process.env.EDIT_DATA_ROLES.includes(role.id))) { + if (canEditData(member)) { await reaction.users.fetch(); //This becomes necessary for some reason if the message was not cached if (reaction.users.cache.size < STAR_THRESHOLD) return; @@ -385,4 +386,157 @@ client.on('messageReactionAdd', async (reaction, user) => { } }); + +const ARTICLE_PROMOTE_EMOJI = 'btc'; +const ARTICLE_PROMOTE_THRESHOLD = Math.max(1, Number.parseInt(process.env.ARTICLE_PROMOTE_THRESHOLD || '2', 10) || 2); +const ENABLE_ARTICLES = process.env.ENABLE_ARTICLES === '1'; +const ARTICLE_ID_LINK_REGEX = /https:\/\/btcmaxis\.com\/article\.html\?id=([0-9a-fA-F-]{36})/i; + +function isModerator(member) { + if (!member) + return false; + + return member.roles?.cache?.some(role => role.name === process.env.MOD_ROLE); +} + +function canUserTriggerArticle(member) { + return canEditData(member); +} + +function isArticleChannel(message) { + const configuredChannel = process.env.ARTICLE_CHANNEL; + + if (!configuredChannel || !message?.channel) + return false; + + if (message.channel.id === configuredChannel || message.channel.name === configuredChannel) + return true; + + if (!message.channel.isThread?.()) + return false; + + const parentChannel = message.channel.parent; + if (!parentChannel) + return false; + + return parentChannel.id === configuredChannel || parentChannel.name === configuredChannel; +} + +function isForumParentPost(message) { + if (!message?.channel?.isThread?.()) + return false; + + if (message.channel.parent?.type !== ChannelType.GuildForum) + return false; + + return message.id === message.channel.id; +} + +function extractArticleIdFromPreview(messageContent) { + const match = String(messageContent || '').match(ARTICLE_ID_LINK_REGEX); + return match?.[1] || null; +} + +client.on('messageReactionAdd', async (reaction, user) => { + try { + if (!ENABLE_ARTICLES) + return; + + if (user.bot) + return; + + if (reaction.emoji.name?.toLowerCase() !== ARTICLE_PROMOTE_EMOJI) + return; + + let message = reaction.message; + + if (!message.guild) + return; + + if (!message.content) { + // We are dealing with an uncached message + message = await message.channel.messages.fetch(message.id); + } + + if (!isArticleChannel(message)) + return; + + if (!isForumParentPost(message)) + return; + + if (message.author?.id !== client.user.id) + return; + + const articleId = extractArticleIdFromPreview(message.content); + if (!articleId) + return; + + let member = message.guild.members.cache.get(user.id); + if (!member) { + member = await message.guild.members.fetch(user.id).catch(() => null); + } + + if (!canUserTriggerArticle(member)) { + await reaction.users.remove(user.id).catch(() => null); + return; + } + + await reaction.users.fetch(); + + let voteWeight = 0; + for (const reactingUser of reaction.users.cache.values()) { + if (reactingUser.bot) + continue; + + let reactingMember = message.guild.members.cache.get(reactingUser.id); + if (!reactingMember) { + reactingMember = await message.guild.members.fetch(reactingUser.id).catch(() => null); + } + + if (!canUserTriggerArticle(reactingMember)) { + await reaction.users.remove(reactingUser.id).catch(() => null); + continue; + } + + voteWeight += isModerator(reactingMember) ? ARTICLE_PROMOTE_THRESHOLD : 1; + } + + if (voteWeight < ARTICLE_PROMOTE_THRESHOLD) + return; + + const { published, alreadyPublished, error } = await publishArticleById(articleId, { forum_post_url: message.url }); + if (!published) { + if (error) { + console.error(`Article publish failed for ${articleId}: ${error}`); + await message.reply(`article publish failed: ${error}`); + } + return; + } + + if (alreadyPublished) + return; + + await message.reply("Article published and available at https://btcmaxies.com/articles.html"); + } catch (error) { + console.error('Error processing article reactions:', error); + } +}); + client.login(TOKEN); + + + + + + + + + + + + + + + + + diff --git a/services/articles.js b/services/articles.js new file mode 100644 index 0000000..1bc2fc3 --- /dev/null +++ b/services/articles.js @@ -0,0 +1,885 @@ +const axios = require('axios'); +const crypto = require('crypto'); + +function getConfig() { + return { + token: process.env.ARTICLES_GITHUB_TOKEN, + owner: process.env.ARTICLES_GITHUB_OWNER, + repo: process.env.ARTICLES_GITHUB_REPO, + branch: process.env.ARTICLES_GITHUB_BRANCH, + indexPath: process.env.ARTICLES_GITHUB_INDEX_PATH, + articleDirectory: process.env.ARTICLES_GITHUB_ARTICLE_DIR + }; +} + +function requireConfig() { + const config = getConfig(); + const missing = []; + + if (!config.token) missing.push('ARTICLES_GITHUB_TOKEN'); + if (!config.owner) missing.push('ARTICLES_GITHUB_OWNER'); + if (!config.repo) missing.push('ARTICLES_GITHUB_REPO'); + if (!config.branch) missing.push('ARTICLES_GITHUB_BRANCH'); + if (!config.indexPath) missing.push('ARTICLES_GITHUB_INDEX_PATH'); + if (!config.articleDirectory) missing.push('ARTICLES_GITHUB_ARTICLE_DIR'); + + if (missing.length > 0) + throw new Error(`Missing GitHub article config: ${missing.join(', ')}`); + + return config; +} + +function encodePath(rawPath) { + return String(rawPath) + .split('/') + .map(part => encodeURIComponent(part)) + .join('/'); +} + +let articleWriteQueue = Promise.resolve(); + +function enqueueArticleWrite(task) { + const queuedTask = articleWriteQueue.then(task, task); + articleWriteQueue = queuedTask.catch(() => {}); + return queuedTask; +} + +function toQualifiedBranchName(branch) { + const normalized = String(branch || '').trim(); + if (!normalized) + return normalized; + + if (normalized.startsWith('refs/heads/')) + return normalized; + + return `refs/heads/${normalized}`; +} + +function githubClient(config) { + return axios.create({ + baseURL: 'https://api.github.com', + timeout: 15000, + headers: { + Authorization: `Bearer ${config.token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'nodeBTCbot-articles' + } + }); +} + +function emptyIndex() { + return { + articles: {}, + updated_at: null + }; +} + +function parseIndex(content) { + if (!content) + return emptyIndex(); + + try { + const parsed = JSON.parse(content); + if (!parsed || typeof parsed !== 'object') + return emptyIndex(); + + if (!parsed.articles || typeof parsed.articles !== 'object' || Array.isArray(parsed.articles)) + parsed.articles = {}; + + return parsed; + } catch { + return emptyIndex(); + } +} + +function parseArticle(content) { + if (!content) + return null; + + try { + const parsed = JSON.parse(content); + if (!parsed || typeof parsed !== 'object') + return null; + + return parsed; + } catch { + return null; + } +} + +function getErrorMessage(error) { + const graphQlError = error?.response?.data?.errors?.[0]?.message; + if (graphQlError) + return graphQlError; + + const apiError = error?.response?.data?.message; + if (apiError) + return apiError; + + return error?.message || 'Unknown error'; +} + +function isHeadConflictMessage(message) { + const normalized = String(message || ''); + return /expectedHeadOid|Head branch was modified|branch was modified|is at .* but expected/i.test(normalized); +} + +async function commitFilesSingleApiCall(config, changes, headline, expectedHeadOid = null) { + const client = githubClient(config); + + const query = ` + mutation CreateCommitOnBranch($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { + commit { + oid + } + } + } + `; + + const changeObject = Array.isArray(changes) + ? { additions: changes } + : (changes && typeof changes === 'object' ? changes : {}); + + const additions = Array.isArray(changeObject.additions) + ? changeObject.additions.map(file => ({ + path: file.path, + contents: Buffer.from(String(file.content || ''), 'utf8').toString('base64') + })) + : []; + + const deletions = Array.isArray(changeObject.deletions) + ? changeObject.deletions.map(file => ({ + path: String(file?.path || file || '') + })).filter(file => file.path) + : []; + + const fileChanges = {}; + if (additions.length > 0) + fileChanges.additions = additions; + + if (deletions.length > 0) + fileChanges.deletions = deletions; + + if (!fileChanges.additions && !fileChanges.deletions) + throw new Error('No file changes were provided for commitFilesSingleApiCall.'); + + const input = { + branch: { + repositoryNameWithOwner: `${config.owner}/${config.repo}`, + branchName: toQualifiedBranchName(config.branch) + }, + message: { + headline + }, + fileChanges + }; + + if (expectedHeadOid) + input.expectedHeadOid = expectedHeadOid; + + const response = await client.post('/graphql', { + query, + variables: { input } + }); + + const errors = response.data?.errors; + if (Array.isArray(errors) && errors.length > 0) + throw new Error(errors[0].message || 'GraphQL createCommitOnBranch failed'); + + const oid = response.data?.data?.createCommitOnBranch?.commit?.oid; + if (!oid) + throw new Error('GraphQL createCommitOnBranch did not return a commit oid'); + + return oid; +} + +async function getBranchHeadOid(config) { + const client = githubClient(config); + const encodedBranch = encodePath(config.branch); + const response = await client.get(`/repos/${config.owner}/${config.repo}/git/ref/heads/${encodedBranch}`); + return String(response.data?.object?.sha || '').trim() || null; +} + +async function getRepoFile(config, filePath, ref = null) { + const client = githubClient(config); + const encodedPath = encodePath(filePath); + + try { + const response = await client.get(`/repos/${config.owner}/${config.repo}/contents/${encodedPath}`, { + params: { ref: ref || config.branch } + }); + + const base64Content = String(response.data.content || '').replace(/\n/g, ''); + const content = Buffer.from(base64Content, 'base64').toString('utf8'); + + return { + exists: true, + sha: response.data.sha, + content, + path: response.data.path + }; + } catch (error) { + if (error.response?.status === 404) { + return { + exists: false, + sha: null, + content: null, + path: filePath + }; + } + + throw error; + } +} + +function toArticleFilename(config, article) { + return `${config.articleDirectory}/${article.id}.json`; +} + +function normalizeUniqueStrings(values) { + if (!Array.isArray(values)) + return []; + + const unique = []; + const seen = new Set(); + + for (const value of values) { + const normalized = String(value || '').trim(); + if (!normalized) + continue; + + if (seen.has(normalized)) + continue; + + seen.add(normalized); + unique.push(normalized); + } + + return unique; +} + +function normalizeProfileObjects(values) { + if (!Array.isArray(values)) + return []; + + const normalized = []; + const seen = new Set(); + + for (const value of values) { + const username = String(value?.username || '').trim(); + if (!username) + continue; + + const key = username.toLowerCase(); + if (seen.has(key)) + continue; + + seen.add(key); + normalized.push({ + username, + image: String(value?.image || '') + }); + } + + return normalized; +} + +function normalizeTagText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); +} + +function normalizeHttpUrl(value) { + const normalized = String(value || '').trim(); + if (!normalized) + return ''; + + return /^https?:\/\/\S+$/i.test(normalized) ? normalized : ''; +} + +function extractTaggableContent(content) { + const raw = String(content || '').replace(/\r/g, ''); + const marker = 'full article here'; + const markerIndex = raw.toLowerCase().indexOf(marker); + if (markerIndex < 0) + return raw; + + const newlineAfterMarker = raw.indexOf('\n', markerIndex); + if (newlineAfterMarker < 0) + return ''; + + return raw.slice(newlineAfterMarker + 1); +} + +function stripUrlsAndQuotesForSummary(content) { + const taggable = extractTaggableContent(content).replace(/\r/g, ''); + const withoutQuotes = taggable + .split('\n') + .map(line => String(line || '').trim()) + .filter(line => line && !line.startsWith('>')) + .join('\n'); + + return withoutQuotes + .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/gi, '$1') + .replace(/<?https?:\/\/\S+>?/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function extractContentLines(content) { + const normalizedContent = stripUrlsAndQuotesForSummary(content); + const sentenceCandidates = normalizedContent + .split(/(?<=[.!?])\s+/g) + .map(normalizeTagText) + .filter(sentence => sentence && /[a-z0-9]/i.test(sentence)); + + const substantiveSentences = sentenceCandidates.filter(sentence => { + const wordCount = (sentence.match(/[a-z0-9']+/gi) || []).length; + return wordCount >= 4 && sentence.length >= 20; + }); + + const selected = substantiveSentences.length >= 2 + ? substantiveSentences + : sentenceCandidates; + + return { + line1: selected[0] || '', + line2: selected[1] || '' + }; +} + +function collectProfileTagNames(profiles) { + if (!Array.isArray(profiles)) + return []; + + const names = []; + const seen = new Set(); + + for (const profile of profiles) { + const username = normalizeTagText(profile?.username); + if (!username) + continue; + + const key = username.toLowerCase(); + if (seen.has(key)) + continue; + + seen.add(key); + names.push(username); + } + + return names; +} + +function buildArticleTags(title, authors = [], contributors = [], articleId = null) { + const normalizedTitle = normalizeTagText(title) || 'Untitled'; + const normalizedArticleId = normalizeTagText(articleId); + + const tags = []; + const seen = new Set(); + const baseTags = [ + normalizedTitle, + normalizedArticleId + ]; + const nameTags = [ + ...collectProfileTagNames(authors), + ...collectProfileTagNames(contributors) + ]; + + for (const rawTag of [...baseTags, ...nameTags]) { + const tag = normalizeTagText(rawTag); + if (!tag) + continue; + + const key = tag.toLowerCase(); + if (seen.has(key)) + continue; + + seen.add(key); + tags.push(tag); + } + + return tags; +} + +function normalizeArticleTags(article) { + return buildArticleTags( + article?.title, + article?.authors, + article?.contributors, + article?.id + ); +} + +function buildDraftArticlePayload(articleInput) { + const authorObjects = normalizeProfileObjects(articleInput?.authors); + + if (authorObjects.length === 0) { + authorObjects.push({ + username: String(articleInput?.author_username || 'Anonymous').trim() || 'Anonymous', + image: String(articleInput?.author_image || '') + }); + } + + const authorUsernameSet = new Set(authorObjects.map(author => author.username.toLowerCase())); + const contributorObjects = normalizeProfileObjects(articleInput?.contributors) + .filter(contributor => !authorUsernameSet.has(contributor.username.toLowerCase())); + + const articleId = String(articleInput?.id || crypto.randomUUID()); + const { line1, line2 } = extractContentLines(articleInput?.content); + const tags = buildArticleTags(articleInput?.title, authorObjects, contributorObjects, articleId); + const forumPostUrl = normalizeHttpUrl(articleInput?.forum_post_url); + + const article = { + id: articleId, + title: String(articleInput?.title || '').trim(), + content: String(articleInput?.content || ''), + article_time: articleInput?.article_time ? new Date(articleInput.article_time).toISOString() : new Date().toISOString(), + published: false, + authors: authorObjects, + contributors: contributorObjects, + line1, + line2, + tags, + forum_post_url: forumPostUrl, + source_message_id: String(articleInput?.source_message_id || ''), + source_channel_id: String(articleInput?.source_channel_id || ''), + source_guild_id: String(articleInput?.source_guild_id || ''), + linked_message_ids: normalizeUniqueStrings(articleInput?.linked_message_ids) + }; + + if (!article.title) + throw new Error('Article title is required'); + + if (!article.content) + throw new Error('Article content is required'); + + return article; +} + +function toIndexEntry(article, filename) { + const primaryAuthor = normalizeTagText(article?.authors?.[0]?.username) || 'Anonymous'; + const derivedLines = extractContentLines(article?.content); + const line1 = normalizeTagText(article?.line1) || derivedLines.line1; + const line2 = normalizeTagText(article?.line2) || derivedLines.line2; + const forumPostUrl = normalizeHttpUrl(article?.forum_post_url); + + return { + filename, + timestamp: article.article_time, + title: String(article.title || ''), + author: primaryAuthor, + line1, + line2, + forum_post_url: forumPostUrl, + tags: normalizeArticleTags(article) + }; +} + +async function resolveArticleFilePath(config, articleId) { + return `${config.articleDirectory}/${articleId}.json`; +} + +async function createDraftArticleInternal(articleInput) { + try { + const config = requireConfig(); + const draft = buildDraftArticlePayload(articleInput); + const filename = toArticleFilename(config, draft); + + const commitHeadline = `Create article draft ${draft.id}`; + + const commitOnce = async () => { + const expectedHead = await getBranchHeadOid(config); + if (!expectedHead) + throw new Error('Unable to resolve branch head for draft creation.'); + + return commitFilesSingleApiCall( + config, + [{ path: filename, content: JSON.stringify(draft, null, 2) }], + commitHeadline, + expectedHead + ); + }; + + try { + await commitOnce(); + } catch (error) { + const message = getErrorMessage(error); + if (!isHeadConflictMessage(message)) + throw error; + + await commitOnce(); + } + + return { created: true, article: draft, filename }; + } catch (error) { + const message = getErrorMessage(error); + console.error('Error creating article draft on GitHub:', message); + return { created: false, article: null, filename: null, error: message }; + } +} + +async function setArticleForumPostUrlInternal(articleId, forumPostUrl) { + try { + const config = requireConfig(); + const normalizedId = String(articleId || '').trim(); + const normalizedForumPostUrl = normalizeHttpUrl(forumPostUrl); + + if (!normalizedId) + return { updated: false, article: null, error: 'Missing article id.' }; + + if (!normalizedForumPostUrl) + return { updated: false, article: null, error: 'Missing or invalid forum post url.' }; + + let finalArticle = null; + + const commitHeadline = `Set forum post url for article ${normalizedId}`; + + const runUpdate = async () => { + const articlePath = await resolveArticleFilePath(config, normalizedId); + + const currentArticleFile = await getRepoFile(config, articlePath); + if (!currentArticleFile.exists) + throw new Error(`Draft article ${normalizedId} was not found.`); + + const currentArticle = parseArticle(currentArticleFile.content); + if (!currentArticle) + throw new Error(`Draft article ${normalizedId} is invalid JSON.`); + + const updatedArticle = { + ...currentArticle, + id: normalizedId, + forum_post_url: normalizedForumPostUrl + }; + + const additions = [ + { path: articlePath, content: JSON.stringify(updatedArticle, null, 2) } + ]; + + const indexFile = await getRepoFile(config, config.indexPath); + const index = parseIndex(indexFile.content); + + if (!index.articles || typeof index.articles !== 'object' || Array.isArray(index.articles)) + index.articles = {}; + + if (index.articles[normalizedId]) { + index.articles[normalizedId] = toIndexEntry(updatedArticle, articlePath); + index.updated_at = new Date().toISOString(); + additions.push({ path: config.indexPath, content: JSON.stringify(index, null, 2) }); + } + + const expectedHead = await getBranchHeadOid(config); + if (!expectedHead) + throw new Error('Unable to resolve branch head for forum url update.'); + + await commitFilesSingleApiCall( + config, + { additions }, + commitHeadline, + expectedHead + ); + + finalArticle = updatedArticle; + }; + + try { + await runUpdate(); + } catch (error) { + const message = getErrorMessage(error); + if (!isHeadConflictMessage(message)) + throw error; + + await runUpdate(); + } + + return { updated: true, article: finalArticle, error: null }; + } catch (error) { + const message = getErrorMessage(error); + console.error('Error saving article forum post url on GitHub:', message); + return { updated: false, article: null, error: message }; + } +} +async function publishArticleByIdInternal(articleId, options = {}) { + try { + const config = requireConfig(); + const normalizedId = String(articleId || '').trim(); + + if (!normalizedId) + return { published: false, alreadyPublished: false, article: null, error: 'Missing article id.' }; + + let finalArticle = null; + let finalAlreadyPublished = false; + + const commitHeadline = `Publish article ${normalizedId}`; + + const runPublish = async () => { + const articlePath = await resolveArticleFilePath(config, normalizedId); + + const currentArticleFile = await getRepoFile(config, articlePath); + if (!currentArticleFile.exists) + throw new Error(`Draft article ${normalizedId} was not found.`); + + const currentArticle = parseArticle(currentArticleFile.content); + if (!currentArticle) + throw new Error(`Draft article ${normalizedId} is invalid JSON.`); + + const updatedArticle = { + ...currentArticle, + id: normalizedId, + published: true, + article_time: currentArticle.article_time || new Date().toISOString() + }; + updatedArticle.forum_post_url = normalizeHttpUrl(options?.forum_post_url) || normalizeHttpUrl(currentArticle?.forum_post_url); + const updatedLines = extractContentLines(updatedArticle.content); + updatedArticle.line1 = updatedLines.line1; + updatedArticle.line2 = updatedLines.line2; + updatedArticle.tags = normalizeArticleTags(updatedArticle); + + const indexFile = await getRepoFile(config, config.indexPath); + const index = parseIndex(indexFile.content); + + if (!index.articles || typeof index.articles !== 'object' || Array.isArray(index.articles)) + index.articles = {}; + + const alreadyPublished = Boolean(index.articles[normalizedId]) || Boolean(currentArticle.published); + index.articles[normalizedId] = toIndexEntry(updatedArticle, articlePath); + index.updated_at = new Date().toISOString(); + + finalArticle = updatedArticle; + finalAlreadyPublished = alreadyPublished; + + const expectedHead = await getBranchHeadOid(config); + if (!expectedHead) + throw new Error('Unable to resolve branch head for publish.'); + + return commitFilesSingleApiCall( + config, + { + additions: [ + { path: articlePath, content: JSON.stringify(updatedArticle, null, 2) }, + { path: config.indexPath, content: JSON.stringify(index, null, 2) } + ] + }, + commitHeadline, + expectedHead + ); + }; + + try { + await runPublish(); + } catch (error) { + const message = getErrorMessage(error); + if (!isHeadConflictMessage(message)) + throw error; + + await runPublish(); + } + + return { published: true, alreadyPublished: finalAlreadyPublished, article: finalArticle, error: null }; + } catch (error) { + const message = getErrorMessage(error); + console.error('Error publishing article to GitHub:', message); + return { published: false, alreadyPublished: false, article: null, error: message }; + } +} + +async function deleteArticleByIdInternal(articleId) { + try { + const config = requireConfig(); + const normalizedId = String(articleId || '').trim(); + + if (!normalizedId) { + return { + deleted: false, + articleId: null, + notFound: false, + error: 'Missing article id.' + }; + } + + let deletedFile = false; + let removedFromIndex = false; + + const commitHeadline = `Delete article ${normalizedId}`; + + const buildDeletePayload = async () => { + const indexFile = await getRepoFile(config, config.indexPath); + const index = parseIndex(indexFile.content); + const filename = await resolveArticleFilePath(config, normalizedId); + + const articleFile = await getRepoFile(config, filename); + const fileExists = Boolean(articleFile?.exists); + const hadIndexEntry = Boolean(index.articles?.[normalizedId]); + + if (!fileExists && !hadIndexEntry) + return { notFound: true, additions: [], deletions: [] }; + + if (hadIndexEntry) + delete index.articles[normalizedId]; + + if (indexFile.exists || hadIndexEntry) + index.updated_at = new Date().toISOString(); + + const additions = []; + if (indexFile.exists || hadIndexEntry) { + additions.push({ + path: config.indexPath, + content: JSON.stringify(index, null, 2) + }); + } + + const deletions = []; + if (fileExists && filename) + deletions.push({ path: filename }); + + deletedFile = fileExists; + removedFromIndex = hadIndexEntry; + + return { + notFound: false, + additions, + deletions + }; + }; + + const commitDelete = async () => { + const payload = await buildDeletePayload(); + if (payload.notFound) + return { notFound: true }; + + const expectedHead = await getBranchHeadOid(config); + if (!expectedHead) + throw new Error('Unable to resolve branch head for delete.'); + + await commitFilesSingleApiCall( + config, + { additions: payload.additions, deletions: payload.deletions }, + commitHeadline, + expectedHead + ); + + return { notFound: false }; + }; + + let deleteResult; + try { + deleteResult = await commitDelete(); + } catch (error) { + const message = getErrorMessage(error); + if (!isHeadConflictMessage(message)) + throw error; + + deleteResult = await commitDelete(); + } + + if (deleteResult?.notFound) { + return { + deleted: false, + articleId: normalizedId, + notFound: true, + error: null + }; + } + + return { + deleted: true, + articleId: normalizedId, + deletedFile, + removedFromIndex, + error: null + }; + } catch (error) { + const message = getErrorMessage(error); + console.error('Error deleting article from GitHub:', message); + + return { + deleted: false, + articleId: String(articleId || '').trim() || null, + notFound: false, + error: message + }; + } +} + +async function loadIndex(config, ref = null) { + const indexFile = await getRepoFile(config, config.indexPath, ref); + const index = parseIndex(indexFile.content); + + return { + index, + sha: indexFile.sha + }; +} + +async function getArticleById(articleId) { + const config = requireConfig(); + const normalizedId = String(articleId || '').trim(); + if (!normalizedId) + return null; + const filename = await resolveArticleFilePath(config, normalizedId); + + const file = await getRepoFile(config, filename); + if (!file.exists || !file.content) + return null; + + return parseArticle(file.content); +} + +async function getLatestArticles(limit = 10) { + const config = requireConfig(); + const { index } = await loadIndex(config); + + const entries = Object.entries(index.articles) + .map(([id, data]) => ({ + id, + filename: data.filename, + timestamp: data.timestamp + })) + .filter(entry => entry.filename && entry.timestamp) + .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) + .slice(0, limit); + + const articles = []; + + for (const entry of entries) { + const file = await getRepoFile(config, entry.filename); + if (!file.exists || !file.content) + continue; + + const parsed = parseArticle(file.content); + if (parsed?.published === true) + articles.push(parsed); + } + + return articles; +} + +function createDraftArticle(articleInput) { + return enqueueArticleWrite(() => createDraftArticleInternal(articleInput)); +} + +function publishArticleById(articleId, options = {}) { + return enqueueArticleWrite(() => publishArticleByIdInternal(articleId, options)); +} + +function setArticleForumPostUrl(articleId, forumPostUrl) { + return enqueueArticleWrite(() => setArticleForumPostUrlInternal(articleId, forumPostUrl)); +} + +function deleteArticleById(articleId) { + return enqueueArticleWrite(() => deleteArticleByIdInternal(articleId)); +} + +module.exports = { + createDraftArticle, + publishArticleById, + setArticleForumPostUrl, + deleteArticleById, + getArticleById, + getLatestArticles +}; + + + + diff --git a/utils/discordutils.js b/utils/discordutils.js index 6530ac6..7e86e04 100644 --- a/utils/discordutils.js +++ b/utils/discordutils.js @@ -2,16 +2,30 @@ const { Message } = require('discord.js'); const { getAsDurationMs } = require('./utils'); -const editDataRoles = process.env.EDIT_DATA_ROLES; +const editDataRoleIds = new Set(((process.env.EDIT_DATA_ROLES || '').match(/\d+/g) || [])); const modRole = process.env.MOD_ROLE; +function hasModRole(member) { + if (!member) + return false; + + return member.roles?.cache?.some(role => role.name === modRole) || false; +} + +function canEditData(member) { + if (!member) + return false; + + return hasModRole(member) || member.roles?.cache?.some(role => editDataRoleIds.has(role.id)) || false; +} + /** * Checks if the author of this messsage has permission to edit data, * and sends an error reply if the user does not. * @param {Message} message */ async function checkDataEdit(message) { - const hasPermission = message.member?.roles?.cache?.some(role => editDataRoles.includes(role.id)); + const hasPermission = canEditData(message.member); if (!hasPermission) { // No permission to proceed @@ -27,7 +41,7 @@ async function checkDataEdit(message) { * @param {Message} message */ async function checkMod(message) { - const hasPermission = message.member?.roles?.cache?.some(role => role.name === modRole); + const hasPermission = hasModRole(message.member); if (!hasPermission) { // No permission to proceed @@ -119,4 +133,4 @@ class Reason { } } -module.exports = { checkDataEdit, checkMod, extractIds, extractReason, extractDuration, extractReasonWithoutDuration, Reason } \ No newline at end of file +module.exports = { checkDataEdit, checkMod, canEditData, extractIds, extractReason, extractDuration, extractReasonWithoutDuration, Reason } \ No newline at end of file