feat: add owner-only message context menu to forward messages to DMs

This commit is contained in:
2026-02-16 19:48:27 +09:00
parent a9c7ebf74d
commit 0394d03361
4 changed files with 215 additions and 97 deletions
+72
View File
@@ -0,0 +1,72 @@
import {
ContextMenuCommandBuilder,
ApplicationCommandType,
type MessageContextMenuCommandInteraction,
DiscordAPIError,
ButtonBuilder,
EmbedBuilder,
ActionRowBuilder,
ButtonStyle,
} from "discord.js";
import { ids } from "../config/ids.js";
export const forwardOwnerDM = {
data: new ContextMenuCommandBuilder()
.setName("Forward to Naomi")
.setType(ApplicationCommandType.Message),
async execute(interaction: MessageContextMenuCommandInteraction) {
await interaction.deferReply({ ephemeral: true });
if (interaction.user.id !== ids.users.naomi) {
await interaction.editReply("❌ Only Naomi can use this command.");
return;
}
const message = interaction.targetMessage;
if (message.author.id === ids.users.naomi) {
await interaction.editReply("No need to forward your own message to yourself 😄");
return;
}
try {
const naomi = await interaction.client.users.fetch(ids.users.naomi);
const forwardedEmbed = new EmbedBuilder()
.setColor(0x5865F2)
.setTitle(`Message from ${message.author.tag}!`)
.setDescription(
(message.attachments.size > 0 ? `**Attachments:** ${message.attachments.size} file(s)\n\n` : "\n") +
(message.embeds.length > 0 ? `**Embeds:** ${message.embeds.length}\n\n` : "") + "\n" +
(message.content || "*[No text content]*") + "\n\n");
const viewButton = new ButtonBuilder()
.setLabel("View Message")
.setURL(message.url)
.setStyle(ButtonStyle.Link);
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(viewButton);
await naomi.send({
embeds: [forwardedEmbed],
files: message.attachments.map((att) => att.url),
components: [row],
});
await interaction.editReply({
content: "✅ Forwarded to your DMs!",
});
} catch (error) {
console.error("Failed to forward:", error);
let replyText = "❌ Failed to forward message.";
if (error instanceof DiscordAPIError && error.code === 50007) {
replyText += " (Naomi's DMs might be closed)";
}
await interaction.editReply(replyText);
}
},
};
+1
View File
@@ -70,5 +70,6 @@ export const ids = {
amari: "1406431359345496255", amari: "1406431359345496255",
naomi: "465650873650118659", naomi: "465650873650118659",
nhcarrigan: "1382837581649150104", nhcarrigan: "1382837581649150104",
teklu:"1381735115163570198",
}, },
}; };
+109 -96
View File
@@ -6,14 +6,15 @@
import { DiscordAnalytics } from "@nhcarrigan/discord-analytics"; import { DiscordAnalytics } from "@nhcarrigan/discord-analytics";
import { import {
Client, Client,
GatewayIntentBits, GatewayIntentBits,
Events, Events,
Partials, Partials,
MessageFlags, MessageFlags,
} from "discord.js"; } from "discord.js";
import { scheduleJob } from "node-schedule"; import { scheduleJob } from "node-schedule";
import { App } from "octokit"; import { App } from "octokit";
import { forwardOwnerDM } from "./commands/forwardToOwner.js";
import { ids } from "./config/ids.js"; import { ids } from "./config/ids.js";
import { handleMessageCreate } from "./events/handleMessageCreate.js"; import { handleMessageCreate } from "./events/handleMessageCreate.js";
import { cacheData } from "./modules/cacheData.js"; import { cacheData } from "./modules/cacheData.js";
@@ -31,136 +32,148 @@ import { logger } from "./utils/logger.js";
import type { Amari } from "./interfaces/amari.js"; import type { Amari } from "./interfaces/amari.js";
if ( if (
process.env.GH_CLIENT_ID === undefined process.env.GH_CLIENT_ID === undefined
|| process.env.GH_PRIVATE_KEY === undefined || process.env.GH_PRIVATE_KEY === undefined
) { ) {
throw new Error("Cannot initialise GitHub!"); throw new Error("Cannot initialise GitHub!");
} }
const githubApp = new App({ const githubApp = new App({
appId: process.env.GH_CLIENT_ID, appId: process.env.GH_CLIENT_ID,
privateKey: process.env.GH_PRIVATE_KEY.replaceAll("\\n", "\n"), privateKey: process.env.GH_PRIVATE_KEY.replaceAll("\\n", "\n"),
}); });
const octokit = await githubApp.getInstallationOctokit(83_119_105); const octokit = await githubApp.getInstallationOctokit(83_119_105);
const { data } = await octokit.rest.apps.getAuthenticated(); const { data } = await octokit.rest.apps.getAuthenticated();
await logger.log( await logger.log(
"debug", "debug",
`Authenticated to GitHub as ${data?.name ?? "unknown"}`, `Authenticated to GitHub as ${data?.name ?? "unknown"}`,
); );
const amari: Amari = { const amari: Amari = {
discord: new Client({ discord: new Client({
intents: [ intents: [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent, GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMembers,
GatewayIntentBits.DirectMessages, GatewayIntentBits.DirectMessages,
], ],
partials: [ Partials.Channel ], partials: [Partials.Channel],
}), }),
github: octokit, github: octokit,
lastRssItems: { lastRssItems: {
freeCodeCamp: null, freeCodeCamp: null,
hackerNews: null, hackerNews: null,
}, },
recentlyActiveChannels: new Set<string>(), recentlyActiveChannels: new Set<string>(),
}; };
const analytics = new DiscordAnalytics(amari.discord, logger); const analytics = new DiscordAnalytics(amari.discord, logger);
amari.discord.once(Events.ClientReady, () => { amari.discord.once(Events.ClientReady, () => {
void logger.log( void logger.log(
"debug", "debug",
`Authenticated to Discord as ${amari.discord.user?.username ?? "unknown"}`, `Authenticated to Discord as ${amari.discord.user?.username ?? "unknown"}`,
); );
void cacheData(amari); void cacheData(amari);
analytics.startCron(); analytics.startCron();
scheduleJob("post news", "0 * * * *", async() => { scheduleJob("post news", "0 * * * *", async () => {
await postFreeCodeCampNews(amari); await postFreeCodeCampNews(amari);
await postHackerNews(amari); await postHackerNews(amari);
}); });
scheduleJob("check guild tags", "0 0 * * *", async() => { scheduleJob("check guild tags", "0 0 * * *", async () => {
await logger.log("debug", "Auditing guild tags."); await logger.log("debug", "Auditing guild tags.");
await cacheData(amari); await cacheData(amari);
}); });
scheduleJob("post progress reminders", "0 9 * * 1-5", async() => { scheduleJob("post progress reminders", "0 9 * * 1-5", async () => {
await postProgressReminders(amari); await postProgressReminders(amari);
}); });
setInterval(() => { setInterval(() => {
amari.recentlyActiveChannels = new Set<string>(); amari.recentlyActiveChannels = new Set<string>();
}, 10 * 60 * 1000); }, 10 * 60 * 1000);
setInterval(() => { setInterval(() => {
void checkRetroAchievements(amari); void checkRetroAchievements(amari);
}, 10 * 60 * 1000); }, 10 * 60 * 1000);
}); });
amari.discord.on(Events.MessageCreate, (message) => { amari.discord.on(Events.MessageCreate, (message) => {
if (!message.inGuild()) { if (!message.inGuild()) {
void respondToDm(amari, message); void respondToDm(amari, message);
return; return;
} }
void handleMessageCreate(amari, message); void handleMessageCreate(amari, message);
}); });
amari.discord.on(Events.InteractionCreate, (interaction) => { amari.discord.on(Events.InteractionCreate, (interaction) => {
void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction }); void analytics.logGatewayEvent(Events.InteractionCreate, { ...interaction });
if (interaction.isButton() && interaction.customId === "resolve") {
if (interaction.user.id !== ids.users.naomi) { if (interaction.isMessageContextMenuCommand() && interaction.commandName === "Forward to Naomi") {
return void interaction.reply({ void forwardOwnerDM.execute(interaction);
content: "Who are you????", return
flags: [ MessageFlags.Ephemeral ],
});
} }
return void interaction.message.delete();
} if (interaction.isButton() && interaction.customId === "resolve") {
if (interaction.isAutocomplete()) { if (interaction.user.id !== ids.users.naomi) {
return void interaction; return void interaction.reply({
} content: "Who are you????",
return void interaction.reply({ flags: [MessageFlags.Ephemeral],
content: "What?", });
flags: [ MessageFlags.Ephemeral ], }
});
return void interaction.message.delete();
}
if (interaction.isAutocomplete()) {
return void interaction;
}
return void interaction.reply({
content: "What?",
flags: [MessageFlags.Ephemeral],
});
}); });
amari.discord.on(Events.ThreadCreate, (thread) => { amari.discord.on(Events.ThreadCreate, (thread) => {
if (thread.parent?.isThreadOnly() !== true) { if (thread.parent?.isThreadOnly() !== true) {
return; return;
} }
const { bugReports, communityFeedback, featureRequests, policyIdeation } const { bugReports, communityFeedback, featureRequests, policyIdeation }
= ids.channels; = ids.channels;
if ( if (
![ bugReports, ![bugReports,
communityFeedback, communityFeedback,
featureRequests, featureRequests,
policyIdeation ].includes( policyIdeation].includes(
thread.parent.id, thread.parent.id,
) )
) { ) {
return; return;
} }
const tagId = getForumTagId(thread.parent.id); const tagId = getForumTagId(thread.parent.id);
if (tagId === null) { if (tagId === null) {
return; return;
} }
void thread.setAppliedTags([ tagId ]); void thread.setAppliedTags([tagId]);
}); });
amari.discord.on(Events.UserUpdate, (_oldUser, updatedUser) => { amari.discord.on(Events.UserUpdate, (_oldUser, updatedUser) => {
void processUserGuildTag(amari, updatedUser); void processUserGuildTag(amari, updatedUser);
}); });
amari.discord.on(Events.GuildMemberUpdate, (oldMember, updatedMember) => { amari.discord.on(Events.GuildMemberUpdate, (oldMember, updatedMember) => {
void processMentorshipRole(amari, oldMember, updatedMember); void processMentorshipRole(amari, oldMember, updatedMember);
}); });
amari.discord.on(Events.GuildMemberAdd, (member) => { amari.discord.on(Events.GuildMemberAdd, (member) => {
void logMenteeJoin(amari, member); void logMenteeJoin(amari, member);
}); });
amari.discord.on(Events.GuildMemberRemove, (member) => { amari.discord.on(Events.GuildMemberRemove, (member) => {
void logMenteeLeave(amari, member); void logMenteeLeave(amari, member);
}); });
await amari.discord.login(process.env.BOT_TOKEN); await amari.discord.login(process.env.BOT_TOKEN);
amari.discord.on(Events.MessageCreate, (message) => {
const channelType = message.channel.type;
console.log(`📩 [${channelType}] ${message.author.tag}: ${message.content}`);
});
instantiateServer(amari); instantiateServer(amari);
+32
View File
@@ -0,0 +1,32 @@
import { REST, Routes } from "discord.js";
import { forwardOwnerDM } from "../commands/forwardToOwner.js";
const commands = [
forwardOwnerDM.data.toJSON(),
];
const token = process.env.BOT_TOKEN;
const clientId = process.env.GH_CLIENT_ID;
if (token === undefined) {
throw new Error("BOT_TOKEN is missing from environment variables!");
}
if (clientId === undefined) {
throw new Error("CLIENT_ID is missing from environment variables!");
}
const rest = new REST({ version: "10" }).setToken(token);
(async() => {
try {
console.log("Registering GLOBAL context menu command... wait till appear everywhere.");
await rest.put(
Routes.applicationCommands(clientId),
{ body: commands },
);
console.log("Global registration sent! then check right-click on a message → Apps.");
} catch (error) {
console.error("Registration failed:", error);
}
})();