24 Commits

Author SHA1 Message Date
teklu 10c7d337a5 fix: solve linter error
Node.js CI / CI (pull_request) Successful in 28s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 51s
2026-02-23 22:40:53 +09:00
teklu 60fec0f2d8 fix: solve linter error
Node.js CI / CI (pull_request) Failing after 24s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 53s
2026-02-21 23:36:20 +09:00
teklu 0a3c000add fix: fix lint erorr
Node.js CI / CI (pull_request) Failing after 23s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 50s
2026-02-17 01:13:41 +09:00
teklu 0394d03361 feat: add owner-only message context menu to forward messages to DMs 2026-02-16 19:48:27 +09:00
hikari a9c7ebf74d docs: update feedback section to use support forum
Node.js CI / CI (push) Successful in 26s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m30s
2026-01-26 12:37:32 -08:00
naomi 0b19d91444 feat: add alpha and omega reminder
Node.js CI / CI (push) Successful in 27s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m29s
2026-01-08 17:02:02 -08:00
naomi 74bccd903d chore: no more ts reminders
Node.js CI / CI (push) Successful in 28s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 51s
2026-01-08 08:48:05 -08:00
naomi 402acffb5c fix: properly handle duplicated channels
Node.js CI / CI (push) Successful in 27s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m31s
2025-12-30 11:59:57 -08:00
naomi a8a58faf3c feat: automated upload of .gitea/workflows/ci.yml
Node.js CI / CI (push) Successful in 27s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 57s
2025-12-22 19:43:12 +01:00
naomi 579dfe96f5 feat: automated upload of .gitea/workflows/ci.yml
Node.js CI / CI (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
2025-12-22 19:36:42 +01:00
naomi 47dd385f05 feat: automated upload of .gitea/workflows/ci.yml
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
Node.js CI / Lint and Test (push) Failing after 3s
2025-12-22 19:26:24 +01:00
naomi 2851693a70 feat: automated upload of .npmrc
Node.js CI / Lint and Test (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
2025-12-22 19:17:12 +01:00
naomi 6ee8189edd feat: automated upload of .gitea/workflows/security.yml
Node.js CI / Lint and Test (push) Successful in 27s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
2025-12-18 03:09:33 +01:00
naomi aaa710eba4 fix: log the curl results, pipx for semgrep (#1)
Node.js CI / Lint and Test (push) Successful in 26s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 50s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #1
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2025-12-18 02:28:21 +01:00
naomi e6112f57cb feat: add tags for new forum channels
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 24s
Node.js CI / Lint and Test (push) Successful in 26s
2025-12-17 17:18:17 -08:00
naomi 72b7571b92 fix: fcc sprint notifications 2025-12-17 16:27:27 -08:00
naomi 6e35d49a34 chore: add action so I can test it from a branch
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 23s
Node.js CI / Lint and Test (push) Successful in 26s
2025-12-17 23:09:06 +01:00
naomi 9c53c22130 feat: add reminders for fcc sprints
Node.js CI / Lint and Test (push) Successful in 25s
2025-12-16 18:04:07 -08:00
naomi 9d9d0809d7 feat: update form logic for new platform
Node.js CI / Lint and Test (push) Successful in 26s
2025-12-10 14:52:36 -08:00
naomi 53274ec38c feat: auto-tag the new community fora
Node.js CI / Lint and Test (push) Successful in 1m29s
2025-12-09 12:24:55 -08:00
naomi 9ada4b9cbe feat: prep for mentorship to be self-assignable
Node.js CI / Lint and Test (push) Successful in 28s
2025-12-03 15:27:41 -08:00
naomi 34d71c73aa feat: remove bullying because science did her resume
Node.js CI / Lint and Test (push) Successful in 50s
And I am very proud of her!
2025-11-12 10:05:50 -08:00
naomi 532461202a feat: bully science for failing to complete her resume
Node.js CI / Lint and Test (push) Successful in 50s
2025-11-11 09:32:54 -08:00
naomi 50e46368ed feat: prepare to resume mentorship programme
Node.js CI / Lint and Test (push) Successful in 52s
2025-10-30 17:50:03 -07:00
18 changed files with 636 additions and 125 deletions
+14 -5
View File
@@ -8,22 +8,31 @@ on:
- main - main
jobs: jobs:
lint: ci:
name: Lint and Test name: CI
runs-on: ubuntu-latest
steps: steps:
- name: Checkout Source Files - name: Checkout Source Files
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Use Node.js v22 - name: Use Node.js v24
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 24
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
with: with:
version: 9 version: 10
- name: Ensure Dependencies are Pinned
uses: naomi-lgbt/dependency-pin-check@main
with:
language: javascript
dev-dependencies: true
peer-dependencies: true
optional-dependencies: true
- name: Install Dependencies - name: Install Dependencies
run: pnpm install run: pnpm install
+177
View File
@@ -0,0 +1,177 @@
name: Security Scan and Upload
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
schedule:
- cron: '0 0 * * 1'
workflow_dispatch:
jobs:
security-audit:
name: Security & DefectDojo Upload
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@v4
# --- AUTO-SETUP PROJECT ---
- name: Ensure DefectDojo Product Exists
env:
DD_URL: ${{ secrets.DD_URL }}
DD_TOKEN: ${{ secrets.DD_TOKEN }}
PRODUCT_NAME: ${{ github.repository }}
PRODUCT_TYPE_ID: 1
run: |
sudo apt-get install jq -y > /dev/null
echo "Checking connection to $DD_URL..."
# Check if product exists - capture HTTP code to debug connection issues
RESPONSE=$(curl --write-out "%{http_code}" --silent --output /tmp/response.json \
-H "Authorization: Token $DD_TOKEN" \
"$DD_URL/api/v2/products/?name=$PRODUCT_NAME")
# If response is not 200, print error
if [ "$RESPONSE" != "200" ]; then
echo "::error::Failed to query DefectDojo. HTTP Code: $RESPONSE"
cat /tmp/response.json
exit 1
fi
COUNT=$(cat /tmp/response.json | jq -r '.count')
if [ "$COUNT" = "0" ]; then
echo "Creating product '$PRODUCT_NAME'..."
curl -s -X POST "$DD_URL/api/v2/products/" \
-H "Authorization: Token $DD_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "'"$PRODUCT_NAME"'", "description": "Auto-created by Gitea Actions", "prod_type": '$PRODUCT_TYPE_ID' }'
else
echo "Product '$PRODUCT_NAME' already exists."
fi
# --- 1. TRIVY (Dependencies & Misconfig) ---
- name: Install Trivy
run: |
sudo apt-get install wget apt-transport-https gnupg lsb-release -y
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install trivy -y
- name: Run Trivy (FS Scan)
run: |
trivy fs . --scanners vuln,misconfig --format json --output trivy-results.json --exit-code 0
- name: Upload Trivy to DefectDojo
env:
DD_URL: ${{ secrets.DD_URL }}
DD_TOKEN: ${{ secrets.DD_TOKEN }}
run: |
echo "Uploading Trivy results..."
# Generate today's date in YYYY-MM-DD format
TODAY=$(date +%Y-%m-%d)
HTTP_CODE=$(curl --write-out "%{http_code}" --output response.txt --silent -X POST "$DD_URL/api/v2/import-scan/" \
-H "Authorization: Token $DD_TOKEN" \
-F "active=true" \
-F "verified=true" \
-F "scan_type=Trivy Scan" \
-F "engagement_name=CI/CD Pipeline" \
-F "product_name=${{ github.repository }}" \
-F "scan_date=$TODAY" \
-F "auto_create_context=true" \
-F "file=@trivy-results.json")
if [[ "$HTTP_CODE" != "200" && "$HTTP_CODE" != "201" ]]; then
echo "::error::Upload Failed with HTTP $HTTP_CODE"
echo "--- SERVER RESPONSE ---"
cat response.txt
echo "-----------------------"
exit 1
else
echo "Upload Success!"
fi
# --- 2. GITLEAKS (Secrets) ---
- name: Install Gitleaks
run: |
wget -qO gitleaks.tar.gz https://github.com/gitleaks/gitleaks/releases/download/v8.18.0/gitleaks_8.18.0_linux_x64.tar.gz
tar -xzf gitleaks.tar.gz
sudo mv gitleaks /usr/local/bin/ && chmod +x /usr/local/bin/gitleaks
- name: Run Gitleaks
run: gitleaks detect --source . -v --report-path gitleaks-results.json --report-format json --no-git || true
- name: Upload Gitleaks to DefectDojo
env:
DD_URL: ${{ secrets.DD_URL }}
DD_TOKEN: ${{ secrets.DD_TOKEN }}
run: |
echo "Uploading Gitleaks results..."
TODAY=$(date +%Y-%m-%d)
HTTP_CODE=$(curl --write-out "%{http_code}" --output response.txt --silent -X POST "$DD_URL/api/v2/import-scan/" \
-H "Authorization: Token $DD_TOKEN" \
-F "active=true" \
-F "verified=true" \
-F "scan_type=Gitleaks Scan" \
-F "engagement_name=CI/CD Pipeline" \
-F "product_name=${{ github.repository }}" \
-F "scan_date=$TODAY" \
-F "auto_create_context=true" \
-F "file=@gitleaks-results.json")
if [[ "$HTTP_CODE" != "200" && "$HTTP_CODE" != "201" ]]; then
echo "::error::Upload Failed with HTTP $HTTP_CODE"
echo "--- SERVER RESPONSE ---"
cat response.txt
echo "-----------------------"
exit 1
else
echo "Upload Success!"
fi
# --- 3. SEMGREP (SAST) ---
- name: Install Semgrep (via pipx)
run: |
sudo apt-get install pipx -y
pipx install semgrep
# Add pipx binary path to GITHUB_PATH so next steps can see 'semgrep'
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Run Semgrep
run: semgrep scan --config=p/security-audit --config=p/owasp-top-ten --json --output semgrep-results.json . || true
- name: Upload Semgrep to DefectDojo
env:
DD_URL: ${{ secrets.DD_URL }}
DD_TOKEN: ${{ secrets.DD_TOKEN }}
run: |
echo "Uploading Semgrep results..."
TODAY=$(date +%Y-%m-%d)
HTTP_CODE=$(curl --write-out "%{http_code}" --output response.txt --silent -X POST "$DD_URL/api/v2/import-scan/" \
-H "Authorization: Token $DD_TOKEN" \
-F "active=true" \
-F "verified=true" \
-F "scan_type=Semgrep JSON Report" \
-F "engagement_name=CI/CD Pipeline" \
-F "product_name=${{ github.repository }}" \
-F "scan_date=$TODAY" \
-F "auto_create_context=true" \
-F "file=@semgrep-results.json")
if [[ "$HTTP_CODE" != "200" && "$HTTP_CODE" != "201" ]]; then
echo "::error::Upload Failed with HTTP $HTTP_CODE"
echo "--- SERVER RESPONSE ---"
cat response.txt
echo "-----------------------"
exit 1
else
echo "Upload Success!"
fi
+25
View File
@@ -0,0 +1,25 @@
# Package Manager Configuration
# Force pnpm usage - breaks npm/yarn intentionally
node-linker=pnpm
# Security: Disable all lifecycle scripts
ignore-scripts=true
enable-pre-post-scripts=false
# Security: Require packages to be 10+ days old before installation
minimum-release-age=14400
# Security: Verify package integrity hashes
verify-store-integrity=true
# Security: Enforce strict trust policies
trust-policy=strict
# Security: Strict peer dependency resolution
strict-peer-dependencies=true
# Performance: Use symlinks for node_modules
symlink=true
# Lockfile: Ensure lockfile is not modified during install
frozen-lockfile=false
+1 -1
View File
@@ -18,7 +18,7 @@ This page is currently deployed. [View the live website.]
## Feedback and Bugs ## Feedback and Bugs
If you have feedback or a bug report, please feel free to open a GitHub issue! If you have feedback or a bug report, please [log a ticket on our forum](https://support.nhcarrigan.com).
## Contributing ## Contributing
+95
View File
@@ -0,0 +1,95 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Teklu Abayneh
*/
import {
ContextMenuCommandBuilder,
ApplicationCommandType,
type MessageContextMenuCommandInteraction,
DiscordAPIError,
ButtonBuilder,
EmbedBuilder,
ActionRowBuilder,
ButtonStyle,
type Message,
} from "discord.js";
import { ids } from "../config/ids.js";
const buildForwardedEmbed = (message: Message): EmbedBuilder => {
const forwardedEmbed = new EmbedBuilder().
setColor(0x58_65_F2).
setTitle(`Message from ${String(message.author.tag)}!`).
setDescription(
`${(message.attachments.size > 0
? `**Attachments:** ${String(message.attachments.size)}
file(s)\n\n`
: "\n")
+ (message.embeds.length > 0
? `**Embeds:** ${String(message.embeds.length)}\n\n`
: "")}
\n${message.content}\n\n`,
);
return forwardedEmbed;
};
const buildViewButtonFunction = (message: Message): ButtonBuilder => {
const viewButton = new ButtonBuilder().
setLabel("View Message").
setURL(message.url).
setStyle(ButtonStyle.Link);
return viewButton;
};
const data = new ContextMenuCommandBuilder().setName("Forward to Naomi").
setType(ApplicationCommandType.Message);
const execute = async(interaction: MessageContextMenuCommandInteraction):
Promise<void> => {
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 = buildForwardedEmbed(message);
const viewButton = buildViewButtonFunction(message);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
viewButton,
);
await naomi.send({
components: [ row ],
embeds: [ forwardedEmbed ],
files: message.attachments.map((att) => {
return att.url;
}),
});
await interaction.editReply({
content: "✅ Forwarded to your DMs!",
});
} catch (error) {
let replyText = "❌ Failed to forward message.";
if (error instanceof DiscordAPIError && error.code === 50_007) {
replyText = `${replyText} (Naomi's DMs might be closed)`;
}
await interaction.editReply(replyText);
}
};
export const forwardOwnerDM = {
data,
execute,
};
+26 -1
View File
@@ -6,15 +6,26 @@
export const ids = { export const ids = {
channels: { channels: {
formSubmissions: "1410435042898874471", accessibilityForum: "1451006622838030509",
billingQuestions: "1451010972771811480",
bugReports: "1447723804330823763",
communityFeedback: "1447726591189975210",
featureRequests: "1447726510369931295",
formSubmissions: "1448445144071147520",
gaming: "1385797656307175468", gaming: "1385797656307175468",
general: "1385797320389431336", general: "1385797320389431336",
legalNotices: "1451009920479793254",
marketingProposals: "1451012386327756902",
menteeChat: "1400589073613062204", menteeChat: "1400589073613062204",
mentorshipGoalForum: "1400629118110011526", mentorshipGoalForum: "1400629118110011526",
mentorshipProjectForum: "1400616702265266186", mentorshipProjectForum: "1400616702265266186",
naomiDiscussionForum: "1408154690121633917", naomiDiscussionForum: "1408154690121633917",
news: "1407804798677418198", news: "1407804798677418198",
partnershipRequests: "1451009066355654829",
policyIdeation: "1417294974046965842",
pressInquiries: "1451011543482368163",
resumeReviewForum: "1407807555266154496", resumeReviewForum: "1407807555266154496",
technicalSupport: "1451014823440678972",
}, },
guilds: { guilds: {
nhcarrigan: "1354624415861833870", nhcarrigan: "1354624415861833870",
@@ -41,10 +52,24 @@ export const ids = {
member: "1407807699718111292", member: "1407807699718111292",
naomi: "1407807752549699727", naomi: "1407807752549699727",
}, },
unanswered: {
accessibilityForum: "1451008476779122869",
billingQuestions: "1451011252301205524",
bugReports: "1447726213446500555",
communityFeedback: "1447726807595094036",
featureRequests: "1447726899919978677",
legalNotices: "1451010521687130313",
marketingProposals: "1451012664858906720",
partnershipRequests: "1451009530459586650",
policyIdeation: "1447755602318196828",
pressInquiries: "1451012067841544223",
technicalSupport: "1451015143646298132",
},
}, },
users: { users: {
amari: "1406431359345496255", amari: "1406431359345496255",
naomi: "465650873650118659", naomi: "465650873650118659",
nhcarrigan: "1382837581649150104", nhcarrigan: "1382837581649150104",
teklu: "1381735115163570198",
}, },
}; };
+34 -12
View File
@@ -3,25 +3,47 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import type { ProgressReminder } from "../interfaces/reminder.js";
interface Channel { const nhcarriganMentorshipChannels: Array<ProgressReminder> = [
channelId: string; {
roleId: string; channelId: "1400630073010163792",
name: string; createThread: true,
createThread: boolean; name: "accountability",
} roleId: "1400588705273745550",
},
const nhcarriganMentorshipChannels: Array<Channel> = [
]; ];
const freeCodeCampSprintChannels: Array<Channel> = [ const freeCodeCampSprintChannels: Array<ProgressReminder> = [
{
channelId: "1424801426160488520",
createThread: true,
name: "JS Objects/Arrays/Loops",
roleId: "1425506225453273224",
},
{
channelId: "1424801426160488520",
createThread: true,
name: "Python Basics",
roleId: "1450187499912695838",
},
{
channelId: "1424801426160488520",
createThread: true,
name: "Miscellaneous Sprints",
roleId: "1450672361291513916",
},
{
channelId: "1442575112384811158",
createThread: false,
name: "Alpha and Omega",
roleId: "1458984977101230196",
},
]; ];
/** /**
* The channels to post progress reminders in. * The channels to post progress reminders in.
*/ */
export const progressReminders: Array<Channel> = [ export const progressReminders: Array<ProgressReminder> = [
...nhcarriganMentorshipChannels, ...nhcarriganMentorshipChannels,
...freeCodeCampSprintChannels, ...freeCodeCampSprintChannels,
]; ];
+77 -24
View File
@@ -5,23 +5,24 @@
*/ */
import { DiscordAnalytics } from "@nhcarrigan/discord-analytics"; import { DiscordAnalytics } from "@nhcarrigan/discord-analytics";
import { Client, import {
Client,
GatewayIntentBits, GatewayIntentBits,
Events, Events,
Partials, Partials,
MessageFlags } from "discord.js"; MessageFlags,
} 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";
import { checkRetroAchievements } from "./modules/checkAchievements.js"; import { checkRetroAchievements } from "./modules/checkAchievements.js";
import { getForumTagId } from "./modules/getForumTagId.js";
import { logMenteeJoin } from "./modules/logMenteeJoin.js"; import { logMenteeJoin } from "./modules/logMenteeJoin.js";
import { logMenteeLeave } from "./modules/logMenteeLeave.js"; import { logMenteeLeave } from "./modules/logMenteeLeave.js";
import { import { postFreeCodeCampNews, postHackerNews } from "./modules/postNews.js";
postFreeCodeCampNews,
postHackerNews,
} from "./modules/postNews.js";
import { postProgressReminders } from "./modules/postProgressReminders.js"; import { postProgressReminders } from "./modules/postProgressReminders.js";
import { processMentorshipRole } from "./modules/processMentorshipRole.js"; import { processMentorshipRole } from "./modules/processMentorshipRole.js";
import { processUserGuildTag } from "./modules/processUserGuildTag.js"; import { processUserGuildTag } from "./modules/processUserGuildTag.js";
@@ -30,8 +31,10 @@ import { instantiateServer } from "./server/serve.js";
import { logger } from "./utils/logger.js"; import { logger } from "./utils/logger.js";
import type { Amari } from "./interfaces/amari.js"; import type { Amari } from "./interfaces/amari.js";
if (process.env.GH_CLIENT_ID === undefined if (
|| process.env.GH_PRIVATE_KEY === undefined) { process.env.GH_CLIENT_ID === undefined
|| process.env.GH_PRIVATE_KEY === undefined
) {
throw new Error("Cannot initialise GitHub!"); throw new Error("Cannot initialise GitHub!");
} }
@@ -39,19 +42,25 @@ 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("debug", `Authenticated to GitHub as ${data?.name ?? "unknown"}`); await logger.log(
"debug",
`Authenticated to GitHub as ${data?.name ?? "unknown"}`,
);
const amari: Amari = { const amari: Amari = {
discord: new Client({ intents: [ discord: new Client({
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,
@@ -63,8 +72,10 @@ const amari: Amari = {
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("debug", void logger.log(
`Authenticated to Discord as ${amari.discord.user?.username ?? "unknown"}`); "debug",
`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() => {
@@ -78,12 +89,18 @@ amari.discord.once(Events.ClientReady, () => {
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); },
setInterval(() => { 10 * 60 * 1000,
);
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) => {
@@ -96,24 +113,60 @@ amari.discord.on(Events.MessageCreate, (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.isMessageContextMenuCommand()
&& interaction.commandName === "Forward to Naomi"
) {
void forwardOwnerDM.execute(interaction);
return;
}
if (interaction.isButton() && interaction.customId === "resolve") { if (interaction.isButton() && interaction.customId === "resolve") {
if (interaction.user.id !== ids.users.naomi) { if (interaction.user.id !== ids.users.naomi) {
return void interaction.reply( void interaction.reply({
{ content: "Who are you????", content: "Who are you????",
flags: [ MessageFlags.Ephemeral ] }, flags: [ MessageFlags.Ephemeral ],
); });
return;
} }
return void interaction.message.delete();
void interaction.message.delete();
return;
} }
if (interaction.isAutocomplete()) { if (interaction.isAutocomplete()) {
return void interaction; void interaction;
return;
} }
return void interaction.reply({ void interaction.reply({
content: "What?", content: "What?",
flags: [ MessageFlags.Ephemeral ], flags: [ MessageFlags.Ephemeral ],
}); });
}); });
amari.discord.on(Events.ThreadCreate, (thread) => {
if (thread.parent?.isThreadOnly() !== true) {
return;
}
const { bugReports, communityFeedback, featureRequests, policyIdeation }
= ids.channels;
if (
![ bugReports,
communityFeedback,
featureRequests,
policyIdeation ].includes(
thread.parent.id,
)
) {
return;
}
const tagId = getForumTagId(thread.parent.id);
if (tagId === null) {
return;
}
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);
}); });
+3 -4
View File
@@ -4,9 +4,8 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable @typescript-eslint/naming-convention -- Baserow uses snake case */
export interface FormSubmission { export interface FormSubmission {
table_id: number; [key: string]: unknown;
items: Array<{ id: number }>; id: number;
manualSort: number;
} }
+12
View File
@@ -0,0 +1,12 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export interface ProgressReminder {
channelId: string;
roleId: string;
name: string;
createThread: boolean;
}
+42
View File
@@ -0,0 +1,42 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { ids } from "../config/ids.js";
/**
* Grabs the "unanswered" tag ID for a given forum channel ID.
* @param id - The ID of the forum channel.
* @returns The ID of the "unanswered" tag, or null if not found.
*/
// eslint-disable-next-line complexity -- This is less complex than trying to narrow the type...
export const getForumTagId = (id: string): string | null => {
switch (id) {
case ids.channels.bugReports:
return ids.tags.unanswered.bugReports;
case ids.channels.communityFeedback:
return ids.tags.unanswered.communityFeedback;
case ids.channels.featureRequests:
return ids.tags.unanswered.featureRequests;
case ids.channels.policyIdeation:
return ids.tags.unanswered.policyIdeation;
case ids.channels.accessibilityForum:
return ids.tags.unanswered.accessibilityForum;
case ids.channels.marketingProposals:
return ids.tags.unanswered.marketingProposals;
case ids.channels.technicalSupport:
return ids.tags.unanswered.technicalSupport;
case ids.channels.billingQuestions:
return ids.tags.unanswered.billingQuestions;
case ids.channels.pressInquiries:
return ids.tags.unanswered.pressInquiries;
case ids.channels.partnershipRequests:
return ids.tags.unanswered.partnershipRequests;
case ids.channels.legalNotices:
return ids.tags.unanswered.legalNotices;
default:
return null;
}
};
-8
View File
@@ -40,13 +40,5 @@ export const logMenteeJoin = async(
); );
return; return;
} }
await channel.send({
content: `Hey <@${member.id}>~!
Welcome to our community! It looks like you may have applied for our mentorship programme!
If that is correct, you should ping Naomi to grant your role and begin onboarding! <a:love:1364089736557494353>`,
});
await logger.metric("processed_mentee_join", 1, { user: member.id }); await logger.metric("processed_mentee_join", 1, { user: member.id });
}; };
+7
View File
@@ -123,6 +123,13 @@ const postHackerNews = async(amari: Amari): Promise<void> => {
} }
await Promise.all( await Promise.all(
latestPosts.map(async(post) => { latestPosts.map(async(post) => {
if (
hasNaughtyWords(post.title)
|| hasNaughtyWords(post.contentSnippet)
|| hasNaughtyWords(post.content)
) {
return;
}
const sent = await channel.send(post.link); const sent = await channel.send(post.link);
if (channel.type === ChannelType.GuildAnnouncement) { if (channel.type === ChannelType.GuildAnnouncement) {
await sent.crosspost(); await sent.crosspost();
+38 -29
View File
@@ -3,70 +3,79 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { ChannelType } from "discord.js"; import { ChannelType, type TextChannel } from "discord.js";
import { progressReminders } from "../config/progressReminders.js"; import { progressReminders } from "../config/progressReminders.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
import type { Amari } from "../interfaces/amari.js"; import type { Amari } from "../interfaces/amari.js";
import type { ProgressReminder } from "../interfaces/reminder.js";
/** /**
* Posts a daily progress check-in reminder in configured channels. * Posts a daily progress check-in reminder in configured channels.
* @param amari - Amari's instance. * @param amari - Amari's instance.
*/ */
// eslint-disable-next-line max-lines-per-function -- shut up // eslint-disable-next-line max-lines-per-function -- shut up
export const postProgressReminders = async( export const postProgressReminders = async(amari: Amari): Promise<void> => {
amari: Amari,
): Promise<void> => {
try { try {
const mapped = await Promise.all( const mapped = await Promise.all(
progressReminders.map(async(channel) => { progressReminders.map(async(reminder) => {
const fetched = await amari.discord.channels. const fetched = await amari.discord.channels.
fetch(channel.channelId). fetch(reminder.channelId).
catch(() => { catch(() => {
void logger.log("warn", `Failed to fetch channel ${channel.name}.`); void logger.log(
"warn",
`Failed to fetch channel ${reminder.name}.`,
);
return null; return null;
}); });
if (!fetched) { if (!fetched) {
await logger.log("warn", `Channel ${channel.name} not found.`); await logger.log("warn", `Channel ${reminder.name} not found.`);
return null; return { channel: null, reminder: reminder };
} }
if (fetched.type !== ChannelType.GuildText) { if (fetched.type !== ChannelType.GuildText) {
await logger.log( await logger.log(
"warn", "warn",
`Channel ${channel.name} is not a text channel.`, `Channel ${reminder.name} is not a text channel.`,
); );
return null; return { channel: null, reminder: reminder };
} }
return fetched; return { channel: fetched, reminder: reminder };
}), }),
); );
const filtered = mapped.filter((channel) => { const filtered: Array<{
channel: TextChannel;
reminder: ProgressReminder;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Filter is dumb.
}> = mapped.filter(({ channel }) => {
return channel !== null; return channel !== null;
}); }) as Array<{
channel: TextChannel;
reminder: ProgressReminder;
}>;
await Promise.all( await Promise.all(
filtered.map(async(channel) => { filtered.map(async(reminder) => {
const isThread = progressReminders.find((c) => { const isThread
return c.channelId === channel.id; = progressReminders.find((c) => {
return c.channelId === reminder.channel.id;
})?.createThread ?? false; })?.createThread ?? false;
const sent = await channel. const sent = await reminder.channel.
send({ allowedMentions: { send({
allowedMentions: {
parse: [ "roles" ], parse: [ "roles" ],
}, },
content: content: `Good morning <@&${reminder.reminder.roleId}> It is time for your daily progress update. Please share the following in ${
`Good morning <@&${ isThread
progressReminders.find((c) => {
return c.channelId === channel.id;
})?.roleId ?? channel.guildId
}> It is time for your daily progress update. Please share the following in ${isThread
? "the thread attached to this message" ? "the thread attached to this message"
: "this channel"}: : "this channel"
}:
1️⃣ What did you accomplish yesterday? 1️⃣ What did you accomplish yesterday?
2️⃣ What are you working on today? 2️⃣ What are you working on today?
3️⃣ Are there any blockers or issues you need help with?` }). 3️⃣ Are there any blockers or issues you need help with?`,
}).
catch(() => { catch(() => {
void logger.log( void logger.log(
"warn", "warn",
`Failed to send progress reminder in channel ${channel.name}.`, `Failed to send progress reminder in channel ${reminder.channel.name}.`,
); );
return null; return null;
}); });
@@ -79,7 +88,7 @@ export const postProgressReminders = async(
catch(() => { catch(() => {
void logger.log( void logger.log(
"warn", "warn",
`Failed to start thread in channel ${channel.name}.`, `Failed to start thread in channel ${reminder.channel.name}.`,
); );
return null; return null;
}); });
+22 -13
View File
@@ -5,7 +5,6 @@
*/ */
import { MessageFlags } from "discord.js"; import { MessageFlags } from "discord.js";
import { formIds } from "../config/forms.js";
import { ids } from "../config/ids.js"; import { ids } from "../config/ids.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
import type { Amari } from "../interfaces/amari.js"; import type { Amari } from "../interfaces/amari.js";
@@ -21,18 +20,26 @@ import type { FastifyRequest, FastifyReply } from "fastify";
// eslint-disable-next-line max-lines-per-function -- only long because of analytics. // eslint-disable-next-line max-lines-per-function -- only long because of analytics.
export const processFormSubmission = async( export const processFormSubmission = async(
amari: Amari, amari: Amari,
request: FastifyRequest<{
// eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify standard. // eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify standard.
request: FastifyRequest<{ Body: FormSubmission }>, Body: Array<FormSubmission>;
// eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify standard.
Querystring: { form?: string };
}>,
response: FastifyReply, response: FastifyReply,
): Promise<void> => { ): Promise<void> => {
const { secret } = request.headers; const { authorization: secret } = request.headers;
if (secret !== process.env.BASEROW_SECRET if (
|| process.env.BASEROW_SECRET === undefined) { secret !== process.env.BASEROW_SECRET
|| secret === undefined
) {
await response.status(403).send({ message: "Invalid Secret Provided." }); await response.status(403).send({ message: "Invalid Secret Provided." });
return; return;
} }
const { form } = request.query;
await response.status(204).send(); await response.status(204).send();
const channel = amari.discord.channels.cache.get(ids.channels.formSubmissions) const channel
= amari.discord.channels.cache.get(ids.channels.formSubmissions)
?? await amari.discord.channels.fetch(ids.channels.formSubmissions); ?? await amari.discord.channels.fetch(ids.channels.formSubmissions);
if (channel?.isSendable() !== true) { if (channel?.isSendable() !== true) {
await logger.log( await logger.log(
@@ -41,15 +48,15 @@ export const processFormSubmission = async(
); );
return; return;
} }
const { table_id: table, items } = request.body; const submissionIds = request.body.map((item) => {
const rowIds = items.map((item) => { return `${item.id.toString()} (${item.manualSort.toString()})`;
return item.id; });
}).join(", ");
const tableName = formIds[table];
await channel.send({ await channel.send({
components: [ components: [
{ {
content: `${tableName ?? "Unknown Form"} Submission Received!\n\nRow ID(s): ${rowIds}`, content: `${
form ?? "Unknown Form"
} Submission Received!\n\nRow ID(s): ${submissionIds.join(", ")}`,
type: 10, type: 10,
}, },
{ {
@@ -68,5 +75,7 @@ export const processFormSubmission = async(
], ],
flags: [ MessageFlags.IsComponentsV2 ], flags: [ MessageFlags.IsComponentsV2 ],
}); });
await logger.metric("processed_form_submission", 1, { table: String(table) }); await logger.metric("processed_form_submission", 1, {
table: String(request.body),
});
}; };
+3 -3
View File
@@ -46,11 +46,11 @@ export const processMentorshipRole = async(
Welcome to our mentorship programme! We are excited to have you here and help you grow and reach success. Welcome to our mentorship programme! We are excited to have you here and help you grow and reach success.
To get started, please make sure you have accepted the GitHub invite for your dedicated repository under the [NHCarrigan Mentorship organsation](<https://github.com/nhcarrigan-mentorship>). To get started, please ping Naomi with your GitHub username and your first and last name. She will invite you to your dedicated repository in the [NHCarrigan Mentorship organsation](<https://github.com/nhcarrigan-mentorship>) where you can work on your flagship project.
Once you have done this, your next step is to read our [wiki](<https://nhcarrigan.notion.site/mentorship-wiki>). Then, create your post in <#1400629118110011526> as outlined in the wiki. Once you have done this, your next step is to read our [wiki](<https://docs.nhcarrigan.com/mentorship/00-faq/>). Then, create your goal-setting post in <#1400629118110011526> and your project-planning post in <#1400616702265266186> as outlined in the wiki.
Naomi will follow up with you from there! Best of luck on your journey~! <a:love:1364089736557494353>`, If at any time you need some guidance, support, or review, please ping Naomi! She's always happy to help you succeed in your mentorship! Best of luck on your journey~! <a:love:1364089736557494353>`,
}); });
await logger.metric("processed_mentorship_role", 1, { await logger.metric("processed_mentorship_role", 1, {
user: updatedMember.id, user: updatedMember.id,
+32
View File
@@ -0,0 +1,32 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Teklu Abayneh
*/
import { REST, Routes } from "discord.js";
import { forwardOwnerDM } from "../commands/forwardToOwner.js";
import { logger } from "../utils/logger.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);
const requestCommand = async(): Promise<void> => {
try {
await rest.put(Routes.applicationCommands(clientId), { body: commands });
} catch (error) {
if (error instanceof Error) {
await logger.error("operation", error);
}
}
};
void requestCommand();
+5 -2
View File
@@ -87,7 +87,9 @@ export const instantiateServer = (amari: Amari): void => {
server. server.
// eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify standard. // eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify standard.
post<{ Body: FormSubmission }>("/form", async(request, response) => { post<{ Body: Array<FormSubmission>; Querystring: { form: string } }>(
"/form",
async(request, response) => {
try { try {
await processFormSubmission(amari, request, response); await processFormSubmission(amari, request, response);
} catch (error) { } catch (error) {
@@ -96,7 +98,8 @@ export const instantiateServer = (amari: Amari): void => {
} }
await logger.error("/form route", error); await logger.error("/form route", error);
} }
}); },
);
server.listen({ port: 7044 }, (error) => { server.listen({ port: 7044 }, (error) => {
if (error) { if (error) {