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: | <${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(/?/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