├── .babelrc ├── .github └── dependabot.yml ├── .gitignore ├── README.md ├── apps ├── slack-bot │ ├── Dockerfile │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── prisma │ │ └── schema.prisma │ ├── scripts │ │ └── regenerate-og.js │ ├── src │ │ ├── app.js │ │ ├── commands │ │ │ ├── commands.js │ │ │ ├── help.js │ │ │ ├── setaudio.js │ │ │ ├── setcss.js │ │ │ ├── setdomain.js │ │ │ ├── setusername.js │ │ │ ├── togglestreaks.js │ │ │ └── webring.js │ │ ├── events │ │ │ ├── create.js │ │ │ ├── deleted.js │ │ │ ├── forget.js │ │ │ ├── joined.js │ │ │ ├── mention.js │ │ │ ├── noFile.js │ │ │ ├── reactionAdded.js │ │ │ ├── reactionRemoved.js │ │ │ ├── updated.js │ │ │ └── userChanged.js │ │ ├── lib │ │ │ ├── channelKeywords.js │ │ │ ├── clubEmojis.js │ │ │ ├── emojiKeywords.js │ │ │ ├── emojis.js │ │ │ ├── files.js │ │ │ ├── prisma.js │ │ │ ├── profiles.js │ │ │ ├── reactions.js │ │ │ ├── s3.js │ │ │ ├── seasons.js │ │ │ ├── shipEasterEgg.js │ │ │ ├── slack.js │ │ │ ├── streaks.js │ │ │ ├── transcript.js │ │ │ ├── transcript.yml │ │ │ ├── updates.js │ │ │ ├── users.js │ │ │ ├── utils.js │ │ │ └── webring.js │ │ ├── metrics.js │ │ └── routes │ │ │ ├── mux.js │ │ │ └── streakResetter.js │ └── yarn.lock └── web │ ├── Dockerfile │ ├── README.md │ ├── components │ ├── analytics.js │ ├── audio-player.js │ ├── banner.js │ ├── close.js │ ├── club-icon.js │ ├── clubs-edit-popup.js │ ├── clubs-member-popup.js │ ├── clubs-popup.js │ ├── content.js │ ├── emoji.js │ ├── epoch.js │ ├── example-posts.js │ ├── feed.js │ ├── flag.js │ ├── footer.js │ ├── login-popup.js │ ├── mention.js │ ├── message.js │ ├── nav.js │ ├── nprogress.js │ ├── optional.js │ ├── post-editor.js │ ├── post.js │ ├── posts.js │ ├── profile.js │ ├── reaction.js │ ├── summer-of-making.js │ └── video.js │ ├── lib │ ├── dates.js │ ├── email.js │ ├── emoji.js │ ├── images.js │ ├── prisma.js │ ├── s3.js │ ├── use-form.js │ ├── use-has-mounted.js │ ├── use-prefers-motion.js │ └── util.js │ ├── mdx-components.js │ ├── metrics.js │ ├── middleware.js │ ├── next.config.js │ ├── package.json │ ├── pages │ ├── 404.js │ ├── [username] │ │ ├── index.js │ │ └── mentions.js │ ├── _app.js │ ├── about.mdx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].js │ │ ├── clubs │ │ │ ├── [slug] │ │ │ │ ├── avatar.js │ │ │ │ └── index.js │ │ │ └── index.js │ │ ├── css.js │ │ ├── metric.js │ │ ├── posts.js │ │ ├── presigned-s3.js │ │ ├── profiles │ │ │ └── [username].js │ │ ├── r │ │ │ └── [emoji].js │ │ ├── rss │ │ │ └── [username].js │ │ ├── streaks.js │ │ ├── usernames.js │ │ ├── users │ │ │ ├── [username] │ │ │ │ ├── avatar.js │ │ │ │ ├── index.js │ │ │ │ └── mentions.js │ │ │ └── index.js │ │ └── web │ │ │ ├── clubs │ │ │ ├── [id] │ │ │ │ ├── add-member.js │ │ │ │ ├── edit.js │ │ │ │ └── remove-member.js │ │ │ ├── my.js │ │ │ └── new.js │ │ │ ├── post │ │ │ ├── delete.js │ │ │ └── new.js │ │ │ ├── profile │ │ │ └── edit.js │ │ │ ├── reactions │ │ │ ├── click.js │ │ │ └── new.js │ │ │ └── streaks.js │ ├── clubs │ │ └── [slug].js │ ├── index.js │ ├── r │ │ └── [emoji].js │ └── streaks.js │ ├── prettier.config.js │ ├── prisma │ ├── migrations │ │ ├── 20230202102627_setup │ │ │ └── migration.sql │ │ ├── 20230202110720_new_users │ │ │ └── migration.sql │ │ ├── 20230202113702_i_ds │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma │ ├── public │ ├── app.css │ ├── clubs.css │ ├── emoji-picker.css │ ├── fonts.css │ ├── heatmap.css │ ├── inputs.css │ ├── mentions.css │ ├── nav.css │ ├── overlay.css │ ├── posts.css │ ├── profiles.css │ ├── robots.txt │ ├── scrapbookwidget.js │ └── themes │ │ ├── default.css │ │ └── gamelab.css │ ├── scripts │ └── v2-db-migrate.js │ ├── vercel.json │ └── yarn.lock ├── compose.yml ├── package.json ├── scripts └── codemods │ └── comment-console-logs.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [ 4 | "superjson-next" 5 | ] 6 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' # See documentation for possible values 9 | directory: '/' # Location of package manifests 10 | schedule: 11 | interval: 'weekly' 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | .vercel 3 | node_modules 4 | .DS_Store 5 | *.env 6 | package-lock.json 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scrapbook 2 | 3 | Scrapbook helps you **share the things you're working on every day!** As a [Hack Clubber](https://hackclub.com/), you are always learning and building things. Scrapbook allows you to share updates on the things you're doing with the rest of the Hack Club community, and keeps you motivated by recording each day you contribute, tallying that up onto a streak shown on your profile. 4 | 5 | ## How to Set Up the Project 6 | 7 | 1. **Clone the repository:** 8 | ```sh 9 | git clone https://github.com/hackclub/scrapbook.git 10 | cd scrapbook 11 | ``` 12 | 13 | 2. **Install dependencies:** 14 | ```shell 15 | yarn deps-install 16 | ``` 17 | 3. **Request the .env file:** 18 | Send a message mentioning @creds in Hack Club's Slack asking for the .env file. 19 | 20 | 4. **Start the development server:** 21 | ```shell 22 | yarn dev 23 | ``` 24 | 25 | 5. View your server: Open your browser and navigate to http://localhost:3000/. 26 | 27 | ## Build Commands 28 | ### Build the web application: 29 | ```sh 30 | yarn build:web 31 | ``` 32 | 33 | ### Check code formatting: 34 | ```sh 35 | yarn checkFormat 36 | ``` 37 | 38 | ### Format code for Slack bot 39 | ```sh 40 | yarn prettier:slack-bot 41 | ``` 42 | 43 | ### Format code for web app 44 | ```sh 45 | yarn prettier:web 46 | ``` 47 | 48 | 49 | Run the codemod for commenting the lines 50 | 51 | ```shell 52 | npx jscodeshift \ 53 | -t scripts/codemods/comment-console-logs.js \ 54 | apps/web apps/slack-bot \ 55 | --extensions=js,jsx \ 56 | --ignore-pattern '**/node_modules/**' 57 | ``` 58 | 59 | -------------------------------------------------------------------------------- /apps/slack-bot/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:23-alpine3.20 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json . 6 | COPY yarn.lock . 7 | 8 | # USER node 9 | 10 | # RUN npm install -g yarn 11 | RUN yarn install 12 | 13 | COPY . . 14 | 15 | # generate prisma client 16 | RUN npx prisma generate 17 | 18 | EXPOSE 3001 19 | CMD ["yarn", "start"] -------------------------------------------------------------------------------- /apps/slack-bot/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2022 Hack Club 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /apps/slack-bot/README.md: -------------------------------------------------------------------------------- 1 | # Scrappy 2 | 3 | Scrappy is the Slack bot that powers [scrapbook.hackclub.com](https://scrapbook.hackclub.com). Scrappy generates your Scrapbook and Scrapbook posts via Slack messages. For more information about how to sign up for Scrapbook, check out the [about page](https://scrapbook.hackclub.com/about). 4 | 5 | [Click here to view the Scrapbook repository](https://github.com/hackclub/scrapbook), which hosts the Scrapbook web code. 6 | 7 | ## Commands 8 | 9 | Scrappy provides some helpful commands in Slack. These commands are also documented in our Slack if you send the message `/scrappy` in any channel. 10 | 11 | - `/scrappy-togglestreaks`: toggles your streak count on/off in your status 12 | - `/scrappy-togglestreaks all`: opts out of streaks completely 13 | - `/scrappy-open`: opens your scrapbook (or another user's if you specify a username) 14 | - `/scrappy-setcss`: adds a custom CSS file to your scrapbook profile. Check out this cool example! 15 | - `/scrappy-setdomain`: links a custom domain to your scrapbook profile, e.g. [https://zachlatta.com](https://zachlatta.com) 16 | - `/scrappy-setusername`: change your profile username 17 | - `/scrappy-setaudio`: links an audio file to your Scrapbook. [See an example here](https://scrapbook.hackclub.com/matthew)! 18 | - `/scrappy-webring`: adds or removes someone to your webring 19 | - _Remove_ a post: delete the Slack message and Scrappy will automatically update for you 20 | - _Edit_ a post: edit the Slack message and it will automatically update for you 21 | - _Post_ a message to the `#scrapbook` channel or add an existing Slack message to Scrapbook by reacting to it with the `:scrappy:` emoji (Note: If it isn't working, make sure Scrappy is added to the channel by mentioning `@scrappy`) 22 | 23 | ## Contributing 24 | 25 | Contributions are encouraged and welcome! There are two GitHub repositories that contain code for Scrapbook: the [Scrapbook website](https://github.com/hackclub/scrapbook#contributing) and [Scrappy (the Slack bot)](https://github.com/hackclub/scrappy#contributing). Each repository has a section on contributing guidelines and how to run each project locally. 26 | 27 | Development chatter happens in the [#scrapbook-dev](https://app.slack.com/client/T0266FRGM/C035D6S6TFW) channel in the [Hack Club Slack](https://hackclub.com/slack/). 28 | 29 | ## Running locally 30 | 31 | In order to run Scrappy locally, you'll need to [join the Hack Club Slack](https://hackclub.com/slack). From there, ask @sampoder to be added to the `scrappy (dev)` app on Slack. 32 | 33 | 1. Clone this repository 34 | - `git clone https://github.com/hackclub/scrappy.git && cd scrappy` 35 | 1. Install [ngrok](https://dashboard.ngrok.com/get-started/setup) (if you haven't already) 36 | - Recommended installation is via [Homebrew](https://brew.sh/) 37 | - `brew install ngrok` 38 | 1. Install dependencies 39 | - `yarn` 40 | 1. Create `.env` file at root of project 41 | - `touch .env` 42 | - Send a message mentioning `@creds` in [Hack Club's Slack](https://hackclub.com/slack/) asking for the `.env` file contents 43 | 1. Link your `.env` with your Prisma schema 44 | - `npx prisma generate` 45 | 1. Start server 46 | - `yarn dev` 47 | 1. Forward your local server to ngrok 48 | - `ngrok http 3000` 49 | - Your ngrok URL will be printed out after running this command, which you will need for the next step 50 | 1. Update the [Slack settings](https://api.slack.com/apps/A015DCRTT43/event-subscriptions?) 51 | - Click the toggle to enable events 52 | - Update the URL request URL to be `/api/slack/message`. An example would look like `https://ea61-73-68-194-110.ngrok.io/api/slack/message` 53 | - Save changes and reinstall the Slack app 54 | - This will regenerate Scrappy's [oauth](https://api.slack.com/apps/A015DCRTT43/oauth?) tokens, so make sure to update these in the `.env` file 55 | -------------------------------------------------------------------------------- /apps/slack-bot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hackclub/scrappy-bot", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "dev": "nodemon src/app.js", 9 | "start": "node src/app.js", 10 | "format": "prettier --write .", 11 | "checkFormat": "prettier --check ." 12 | }, 13 | "dependencies": { 14 | "@aws-sdk/client-s3": "^3.712.0", 15 | "@aws-sdk/lib-storage": "^3.703.0", 16 | "@mux/mux-node": "^9.0.1", 17 | "@prisma/client": "5.19.1", 18 | "@slack/bolt": "^4.1.1", 19 | "airtable": "^0.12.2", 20 | "airtable-plus": "^1.0.4", 21 | "bottleneck": "^2.19.5", 22 | "cheerio": "^1.0.0", 23 | "date-season": "^0.0.2", 24 | "dotenv": "^16.4.5", 25 | "eslint": "^9.16.0", 26 | "express": "^4.21.2", 27 | "form-data": "^4.0.0", 28 | "heic-convert": "^2.1.0", 29 | "js-yaml": "^4.1.0", 30 | "lodash": "^4.17.19", 31 | "node-emoji": "^2.2.0", 32 | "node-fetch": "2.6.7", 33 | "node-statsd": "^0.1.1", 34 | "uuid": "^11.0.3" 35 | }, 36 | "devDependencies": { 37 | "nodemon": "^3.1.7", 38 | "prettier": "3.4.2", 39 | "prisma": "5.19.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/slack-bot/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This needs to be synced with hackclub/scrapbook 2 | 3 | generator client { 4 | provider = "prisma-client-js" 5 | } 6 | 7 | datasource db { 8 | provider = "postgresql" 9 | url = env("PG_DATABASE_URL") 10 | } 11 | 12 | model Session { 13 | id String @id @default(cuid()) 14 | sessionToken String @unique 15 | userId String 16 | expires DateTime 17 | user Accounts @relation(fields: [userId], references: [id], onDelete: Cascade) 18 | } 19 | 20 | model VerificationToken { 21 | identifier String 22 | token String @unique 23 | expires DateTime 24 | 25 | @@unique([identifier, token]) 26 | } 27 | 28 | model Accounts { 29 | id String @unique @default(cuid()) 30 | slackID String? @unique(map: "Accounts.slackID_unique") 31 | email String? @unique(map: "Accounts.email_unique") 32 | emailVerified DateTime? 33 | username String @unique(map: "Accounts.username_unique") 34 | streakCount Int? 35 | maxStreaks Int? 36 | displayStreak Boolean? 37 | streaksToggledOff Boolean? 38 | customDomain String? 39 | cssURL String? 40 | website String? 41 | github String? 42 | image String? 43 | fullSlackMember Boolean? 44 | avatar String? 45 | webring String[] 46 | newMember Boolean @default(false) 47 | timezoneOffset Int? 48 | timezone String? 49 | pronouns String? 50 | customAudioURL String? 51 | lastUsernameUpdatedTime DateTime? 52 | webhookURL String? 53 | ClubMember ClubMember[] 54 | sessions Session[] 55 | updates Updates[] @relation("updates") 56 | slackUpdates Updates[] @relation("slackUpdates") 57 | } 58 | 59 | model Updates { 60 | id String @id @default(cuid()) 61 | accountsSlackID String? 62 | postTime DateTime? @default(now()) 63 | text String? 64 | attachments String[] 65 | muxAssetIDs String[] 66 | muxPlaybackIDs String[] 67 | muxAssetStatuses String? 68 | messageTimestamp Float? 69 | backupAssetID String? 70 | backupPlaybackID String? 71 | isLargeVideo Boolean? 72 | channel String? 73 | accountsID String? 74 | ClubUpdate ClubUpdate? 75 | emojiReactions EmojiReactions[] 76 | Accounts Accounts? @relation("updates", fields: [accountsID], references: [id]) 77 | SlackAccounts Accounts? @relation("slackUpdates", fields: [accountsSlackID], references: [slackID]) 78 | } 79 | 80 | model EmojiType { 81 | name String @unique(map: "EmojiType.name_unique") 82 | emojiSource String? 83 | emojiReactions EmojiReactions[] 84 | } 85 | 86 | model EmojiReactions { 87 | id String @id @default(cuid()) 88 | updateId String? 89 | emojiTypeName String? 90 | usersReacted String[] 91 | updatedAt DateTime @default(now()) @map("created_at") 92 | EmojiType EmojiType? @relation(fields: [emojiTypeName], references: [name]) 93 | update Updates? @relation(fields: [updateId], references: [id]) 94 | } 95 | 96 | model ClubMember { 97 | id String @id @default(cuid()) 98 | accountId String 99 | clubId String 100 | admin Boolean @default(false) 101 | account Accounts @relation(fields: [accountId], references: [id], onDelete: Cascade) 102 | club Club @relation(fields: [clubId], references: [id], onDelete: Cascade) 103 | } 104 | 105 | model ClubUpdate { 106 | id String @id @default(cuid()) 107 | updateId String @unique 108 | clubId String 109 | club Club @relation(fields: [clubId], references: [id], onDelete: Cascade) 110 | update Updates @relation(fields: [updateId], references: [id], onDelete: Cascade) 111 | } 112 | 113 | model Club { 114 | id String @id @default(cuid()) 115 | slug String @unique 116 | name String 117 | logo String 118 | customDomain String? 119 | cssURL String? 120 | website String? 121 | location String? 122 | github String? 123 | description String? 124 | banner String @default("https://wallpapercave.com/wp/wp10026724.jpg") 125 | members ClubMember[] 126 | updates ClubUpdate[] 127 | } 128 | -------------------------------------------------------------------------------- /apps/slack-bot/src/commands/commands.js: -------------------------------------------------------------------------------- 1 | 2 | let commands = { 3 | scrappy: "scrappy", // command | reaction 4 | scrappyRetryReaction: "scrappy-retry", 5 | scrappyParrotReaction: "scrappyparrot", 6 | scrappyHelp: "scrappy-help", 7 | scrappySetAudio: "scrappy-setaudio", 8 | scrappySetCSS: "scrappy-setcss", 9 | scrappySetDomain: "scrappy-setdomain", 10 | scrappyDisplayStreaks: "scrappy-displaystreaks", 11 | scrappySetUsername: "scrappy-setusername", 12 | scrappyToggleStreaks: "scrappy-togglestreaks", 13 | scrappyWebring: "scrappy-webring" 14 | }; 15 | 16 | if (process.env.NODE_ENV === "staging") { 17 | for (let key of Object.keys(commands)) { 18 | commands[key] = "test-" + commands[key]; 19 | } 20 | } 21 | 22 | export { commands }; 23 | -------------------------------------------------------------------------------- /apps/slack-bot/src/commands/help.js: -------------------------------------------------------------------------------- 1 | import { t } from "../lib/transcript.js"; 2 | 3 | export default async ({ respond }) => { 4 | await respond(t("messages.help")); 5 | }; 6 | -------------------------------------------------------------------------------- /apps/slack-bot/src/commands/setaudio.js: -------------------------------------------------------------------------------- 1 | import { t } from "../lib/transcript.js"; 2 | import { getUserRecord } from "../lib/users.js"; 3 | import prisma from "../lib/prisma.js"; 4 | 5 | export default async ({ command, respond }) => { 6 | const { text, user_id } = command; 7 | let url = text.split(" ")[0]; 8 | url = url?.substring(1, url.length - 1); 9 | let userRecord = await getUserRecord(user_id); 10 | if (!url) { 11 | if (userRecord.customAudioURL != null) { 12 | await respond( 13 | t("messages.audio.removed", { previous: userRecord.customAudioURL }) 14 | ); 15 | await prisma.accounts.update({ 16 | // update the account with the new audioless 17 | where: { slackID: userRecord.slackID }, 18 | data: { customAudioURL: null }, 19 | }); 20 | } else { 21 | await respond(t("messages.audio.noargs")); 22 | } 23 | } else { 24 | if (!url.includes("http")) { 25 | url = "https://" + url; 26 | } 27 | await prisma.accounts.update({ 28 | // update the account with the new audio 29 | where: { slackID: userRecord.slackID }, 30 | data: { customAudioURL: url }, 31 | }); 32 | await respond( 33 | t("messages.audio.set", { 34 | url: `https://scrapbook.hackclub.com/${userRecord.username}`, 35 | }) 36 | ); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /apps/slack-bot/src/commands/setcss.js: -------------------------------------------------------------------------------- 1 | import prisma from "../lib/prisma.js"; 2 | import { t } from "../lib/transcript.js"; 3 | import { getUserRecord } from "../lib/users.js"; 4 | 5 | export default async ({ command, respond }) => { 6 | const args = command.text.split(" "); 7 | let url = args[0]; 8 | url = url?.substring(0, url.length); 9 | if (!url) { 10 | const userRecord = await getUserRecord(command.user_id); 11 | if (userRecord.cssURL != null) { 12 | await prisma.accounts.update({ 13 | where: { slackID: userRecord.slackID }, 14 | data: { cssURL: null }, 15 | }); 16 | await respond(t("messages.css.removed")); 17 | } else { 18 | await respond(t("messages.css.noargs")); 19 | } 20 | } else { 21 | const user = await getUserRecord(command.user_id); 22 | if (url === "delete" || url === "remove") { 23 | await prisma.accounts.update({ 24 | where: { slackID: user.slackID }, 25 | data: { cssURL: "" }, 26 | }); 27 | await respond(t("messages.css.removed")); 28 | } else { 29 | await prisma.accounts.update({ 30 | where: { slackID: user.slackID }, 31 | data: { cssURL: url }, 32 | }); 33 | await respond(t("messages.css.set", { url })); 34 | } 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /apps/slack-bot/src/commands/setdomain.js: -------------------------------------------------------------------------------- 1 | import prisma from "../lib/prisma.js"; 2 | import { t } from "../lib/transcript.js"; 3 | import { getUserRecord } from "../lib/users.js"; 4 | import fetch from "node-fetch"; 5 | 6 | const TEAM_ID = "team_gUyibHqOWrQfv3PDfEUpB45J"; 7 | 8 | export default async ({ command, respond }) => { 9 | const arg = command.text.split(" ")[0]; 10 | if (!arg) { 11 | await respond(t("messages.domain.noargs")); 12 | } else { 13 | const user = await getUserRecord(command.user_id); 14 | if (user.customDomain != null) { 15 | await fetch( 16 | `https://api.vercel.com/v1/projects/QmWRnAGRMjviMn7f2EkW5QEieMv2TAGjUz8RS698KZm5q8/alias?domain=${user.customDomain}&teamId=${TEAM_ID}`, 17 | { 18 | method: "DELETE", 19 | headers: { 20 | Authorization: `Bearer ${process.env.VC_SCRAPBOOK_TOKEN}`, 21 | }, 22 | } 23 | ).then((res) => res.json()); 24 | } 25 | const vercelFetch = await fetch( 26 | `https://api.vercel.com/v9/projects/QmWRnAGRMjviMn7f2EkW5QEieMv2TAGjUz8RS698KZm5q8/domains?teamId=${TEAM_ID}`, 27 | { 28 | method: "POST", 29 | headers: { 30 | "Content-Type": "application/json", 31 | Authorization: `Bearer ${process.env.VC_SCRAPBOOK_TOKEN}`, 32 | }, 33 | body: JSON.stringify({ 34 | name: arg, 35 | }), 36 | } 37 | ) 38 | .then((r) => r.json()) 39 | .catch((err) => { 40 | // console.log(`Error while setting custom domain ${arg}: ${err}`); 41 | }); 42 | if (vercelFetch.error) { 43 | await respond( 44 | t("messages.domain.domainerror", { 45 | text: arg, 46 | error: JSON.stringify(vercelFetch.error), 47 | }) 48 | ); 49 | } else if (!vercelFetch.verified) { 50 | /* 51 | // domain is owned by another Vercel account, but we can ask the owner to verify 52 | console.log(vercelFetch.verification); 53 | */ 54 | 55 | if (!vercelFetch.verification) { 56 | await respond( 57 | t("messages.domain.domainerror", { 58 | text: arg, 59 | error: "No verification records were provided by the Vercel API", 60 | }) 61 | ); 62 | } 63 | const record = vercelFetch.verification[0]; 64 | const recordText = `type: \`${record.type}\` 65 | domain: \`${record.domain}\` 66 | value: \`${record.value}\``; 67 | await respond( 68 | t("messages.domain.domainverify", { 69 | text: recordText, 70 | domain: arg, 71 | }) 72 | ); 73 | } else { 74 | await prisma.accounts.update({ 75 | where: { slackID: user.slackID }, 76 | data: { customDomain: arg }, 77 | }); 78 | await respond(t("messages.domain.domainset", { text: arg })); 79 | } 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /apps/slack-bot/src/commands/setusername.js: -------------------------------------------------------------------------------- 1 | import prisma from "../lib/prisma.js"; 2 | import { t } from "../lib/transcript.js"; 3 | import { getUserRecord } from "../lib/users.js"; 4 | 5 | export default async ({ command, respond }) => { 6 | const { text, user_id } = command; 7 | let username = text.split(" ")[0]?.replace(" ", "_"); 8 | const userRecord = await getUserRecord(user_id); 9 | const exists = await prisma.accounts.findMany({ where: { username } }); 10 | if ( 11 | userRecord.lastUsernameUpdatedTime > new Date(Date.now() - 86400 * 1000) 12 | ) { 13 | await respond(t("messages.username.time")); 14 | } else if (!username) { 15 | await respond(t("messages.username.noargs")); 16 | } else if (username.length < 2) { 17 | await respond(t("messages.username.short")); 18 | } else if (exists.length > 0) { 19 | await respond(t("messages.username.exists")); 20 | } else { 21 | await prisma.accounts.update({ 22 | // update the account with the new username 23 | where: { slackID: userRecord.slackID }, 24 | data: { 25 | username: username, 26 | lastUsernameUpdatedTime: new Date(Date.now()), 27 | }, 28 | }); 29 | await respond( 30 | t("messages.username.set", { 31 | url: `https://scrapbook.hackclub.com/${username}`, 32 | }) 33 | ); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /apps/slack-bot/src/commands/togglestreaks.js: -------------------------------------------------------------------------------- 1 | import prisma from "../lib/prisma.js"; 2 | import { t } from "../lib/transcript.js"; 3 | import { getUserRecord } from "../lib/users.js"; 4 | import { displayStreaks } from "../lib/streaks.js"; 5 | 6 | export default async ({ command, respond }) => { 7 | // /scrappy-togglestreaks: toggle status 8 | // /scrappy-togglestreaks all: opt out of streaks completely 9 | const { user_id, text } = command; 10 | const args = text?.split(" "); 11 | const allArg = args[args[0] === "togglestreaks" ? 1 : 0]; 12 | const toggleAllStreaks = allArg && allArg === "all"; 13 | const record = await getUserRecord(user_id); 14 | const display = record.displayStreak; 15 | const streaksToggledOff = record.streaksToggledOff; 16 | if (toggleAllStreaks) { 17 | await Promise.all([ 18 | prisma.accounts.update({ 19 | where: { slackID: record.slackID }, 20 | data: { 21 | displayStreak: streaksToggledOff ? true : false, 22 | streaksToggledOff: !streaksToggledOff, 23 | }, 24 | }), 25 | respond( 26 | streaksToggledOff 27 | ? t("messages.streak.toggle.all.optin") 28 | : t("messages.streak.toggle.all.optout") 29 | ), 30 | ]); 31 | } else { 32 | await Promise.all([ 33 | prisma.accounts.update({ 34 | where: { slackID: record.slackID }, 35 | data: { 36 | displayStreak: !display, 37 | }, 38 | }), 39 | respond( 40 | display 41 | ? t("messages.streak.toggle.status.invisible") 42 | : t("messages.streak.toggle.status.visible") 43 | ), 44 | ]); 45 | } 46 | await displayStreaks(user_id, record.streakCount); 47 | }; 48 | -------------------------------------------------------------------------------- /apps/slack-bot/src/commands/webring.js: -------------------------------------------------------------------------------- 1 | import prisma from "../lib/prisma.js"; 2 | import { t } from "../lib/transcript.js"; 3 | import { getUserRecord } from "../lib/users.js"; 4 | 5 | export default async ({ command, respond }) => { 6 | const { user_id, text } = command; 7 | const args = text.split(" "); 8 | const webringUser = args[args[0] === "webring" ? 1 : 0] 9 | ?.split("@")[1] 10 | ?.split("|")[0]; 11 | if (!webringUser) { 12 | return await respond(t("messages.webring.noargs")); 13 | } 14 | if (webringUser && !text.includes("<@")) { 15 | return await respond(t("messages.open.invaliduser")); 16 | } 17 | if (user_id === webringUser) { 18 | return await respond(t("messages.webring.yourself")); 19 | } 20 | const userRecord = await getUserRecord(user_id); 21 | const webringUserRecord = await getUserRecord(webringUser); 22 | const scrapbookLink = `https://scrapbook.hackclub.com/${userRecord.username}`; 23 | let currentWebring = userRecord.webring; 24 | if (!currentWebring) { 25 | currentWebring = [webringUserRecord.slackID]; 26 | } else if (!currentWebring.includes(webringUserRecord.slackID)) { 27 | if (currentWebring.length >= 8) 28 | return await respond(t("messages.webring.toolong")); 29 | currentWebring.push(webringUserRecord.slackID); 30 | await respond(t(`messages.webring.add`, { webringUser, scrapbookLink })); 31 | } else { 32 | currentWebring = currentWebring.filter( 33 | (rec) => rec != webringUserRecord.slackID 34 | ); 35 | await respond(t(`messages.webring.remove`, { webringUser, scrapbookLink })); 36 | } 37 | await prisma.accounts.update({ 38 | where: { slackID: userRecord.slackID }, 39 | data: { webring: currentWebring }, 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /apps/slack-bot/src/events/create.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is triggered when a new post shows up in the #scrapbook channel 3 | 4 | - posts without attachments should be rejected with an ephemeral message 5 | - posts with attachments should be added to the scrapbook & replied to with a threaded message 6 | */ 7 | 8 | import { createUpdate } from "../lib/updates.js"; 9 | import deleted from "./deleted.js"; 10 | import metrics from "../metrics.js"; 11 | 12 | export default async ({ event }) => { 13 | // delete the scrapbook update if the message was deleted on slack client 14 | try { 15 | if (event.subtype === "message_deleted") return await deleted({ event }); 16 | } catch (error) { 17 | metrics.increment("errors.message_deleted", 1); 18 | } 19 | 20 | if (event.thread_ts || event.channel != process.env.CHANNEL) return; 21 | const { files = [], channel, ts, user, text, thread_ts } = event; 22 | if (!thread_ts) await createUpdate(files, channel, ts, user, text); 23 | }; 24 | -------------------------------------------------------------------------------- /apps/slack-bot/src/events/deleted.js: -------------------------------------------------------------------------------- 1 | import { displayStreaks } from "../lib/streaks.js"; 2 | import { postEphemeral, react } from "../lib/slack.js"; 3 | import { getUserRecord } from "../lib/users.js"; 4 | import { deleteUpdate, updateExistsTS } from "../lib/updates.js"; 5 | import { shouldUpdateStreak } from "../lib/streaks.js"; 6 | import { app } from "../app.js"; 7 | import prisma from "../lib/prisma.js"; 8 | import metrics from "../metrics.js"; 9 | 10 | const deleteThreadedMessages = async (ts, channel, user) => { 11 | try { 12 | let result = await app.client.conversations.replies({ channel, ts }); 13 | await Promise.all( 14 | result.messages.map(async (msg) => { 15 | if (msg.ts != msg.thread_ts) { 16 | let deleteM = await app.client.chat.delete({ 17 | token: process.env.SLACK_USER_TOKEN, 18 | channel, 19 | ts: msg.ts, 20 | }); 21 | return deleteM; 22 | } else { 23 | return null; 24 | } // top-level comment 25 | }) 26 | ); 27 | const userRecord = await getUserRecord(user); 28 | const shouldUpdate = await shouldUpdateStreak(user, false); 29 | if (shouldUpdate) { 30 | const updatedStreakCount = userRecord.streakCount - 1; 31 | if (updatedStreakCount >= 0) { 32 | await prisma.accounts.update({ 33 | where: { slackID: userRecord.slackID }, 34 | data: { streakCount: updatedStreakCount }, 35 | }); 36 | displayStreaks(user, updatedStreakCount); 37 | } 38 | } 39 | await postEphemeral( 40 | channel, 41 | `Your scrapbook update has been deleted :boom:`, 42 | user 43 | ); 44 | } catch (e) { 45 | // console.log(e); 46 | } 47 | }; 48 | 49 | export default async ({ event }) => { 50 | try { 51 | const { channel, message, previous_message, deleted_ts } = event; 52 | const ts = deleted_ts || previous_message?.thread_ts; 53 | const hasScrap = await updateExistsTS(ts); 54 | if (ts && hasScrap) { 55 | await Promise.all([ 56 | await react("remove", channel, ts, "beachball"), 57 | await react("add", channel, ts, "boom"), 58 | ]); 59 | await Promise.all([ 60 | react("add", channel, ts, "beachball"), 61 | deleteUpdate(ts), 62 | deleteThreadedMessages(ts, channel, previous_message.user), 63 | ]); 64 | } 65 | // metrics.increment("success.delete_msg", 1); 66 | } catch (e) { 67 | metrics.increment("errors.delete_msg", 1); 68 | // console.log(e); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /apps/slack-bot/src/events/forget.js: -------------------------------------------------------------------------------- 1 | import { postEphemeral, react } from "../lib/slack.js"; 2 | import { forgetUser } from "../lib/users.js"; 3 | import { t } from "../lib/transcript.js"; 4 | 5 | export default async ({ event }) => { 6 | const { user, channel, ts } = event; 7 | if (channel != process.env.CHANNEL) return; 8 | await Promise.all([react("add", channel, ts, "beachball"), forgetUser(user)]); 9 | await Promise.all([ 10 | react("remove", channel, ts, "beachball"), 11 | react("add", channel, ts, "confusedparrot"), 12 | postEphemeral(channel, t("messages.forget"), user), 13 | ]); 14 | return { ok: true }; 15 | }; 16 | -------------------------------------------------------------------------------- /apps/slack-bot/src/events/joined.js: -------------------------------------------------------------------------------- 1 | // This posts an introductory message to the #scrapbook channel when someone shows up 2 | 3 | import { timeout } from "../lib/utils.js"; 4 | import { postEphemeral } from "../lib/slack.js"; 5 | import { t } from "../lib/transcript.js"; 6 | 7 | export default async ({ event }) => { 8 | const { user, channel } = event; 9 | if (event.channel != process.env.CHANNEL) return; 10 | await timeout(1000); 11 | postEphemeral(channel, t("messages.join.scrapbook", { user }), user); 12 | }; 13 | -------------------------------------------------------------------------------- /apps/slack-bot/src/events/mention.js: -------------------------------------------------------------------------------- 1 | import { reply } from "../lib/slack.js"; 2 | import { t } from "../lib/transcript.js"; 3 | 4 | const wordList = [ 5 | "fuck", 6 | "dumb", 7 | "suck", 8 | "stupid", 9 | "crap", 10 | "crappy", 11 | "trash", 12 | "trashy", 13 | ]; 14 | 15 | const messageContainsWord = (msg) => 16 | wordList.some((word) => msg.includes(word)); 17 | 18 | export default async ({ message }) => { 19 | const { channel, ts, user, text, thread_ts } = message; 20 | const containsWord = await messageContainsWord(text); 21 | if (containsWord) { 22 | reply(channel, thread_ts || ts, t("messages.mentionKeyword", { user })); 23 | } else { 24 | reply(channel, thread_ts || ts, t("messages.mention", { user })); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /apps/slack-bot/src/events/noFile.js: -------------------------------------------------------------------------------- 1 | import { postEphemeral } from "../lib/slack.js"; 2 | import { t } from "../lib/transcript.js"; 3 | import { app } from "../app.js"; 4 | 5 | export default async ({ event }) => { 6 | const { channel, ts, user, text } = event; 7 | await Promise.all([ 8 | app.client.chat.delete({ 9 | token: process.env.SLACK_USER_TOKEN, 10 | channel, 11 | ts, 12 | }), 13 | postEphemeral(channel, t("messages.delete", { text }), user), 14 | ]); 15 | }; 16 | 17 | export const noFileCheck = async ({ message, next }) => { 18 | if ( 19 | !message.subtype && 20 | !message.thread_ts && 21 | message.channel == process.env.CHANNEL 22 | ) 23 | await next(); 24 | }; 25 | -------------------------------------------------------------------------------- /apps/slack-bot/src/events/reactionRemoved.js: -------------------------------------------------------------------------------- 1 | import { getReactionRecord } from "../lib/reactions.js"; 2 | import { getUserRecord } from "../lib/users.js"; 3 | import Bottleneck from "bottleneck"; 4 | import prisma from "../lib/prisma.js"; 5 | import clubEmojis from "../lib/clubEmojis.js"; 6 | import { getEmojiRecord } from "../lib/emojis.js"; 7 | 8 | const limiter = new Bottleneck({ 9 | maxConcurrent: 1, 10 | }); 11 | 12 | export default async ({ event }) => { 13 | const { item, user, reaction } = event; 14 | const ts = item.ts; 15 | limiter.schedule(async () => { 16 | try { 17 | const update = ( 18 | await prisma.updates.findMany({ 19 | where: { 20 | messageTimestamp: parseFloat(ts), 21 | }, 22 | }) 23 | )[0]; 24 | if (!update) return; 25 | const reactionRecord = await getReactionRecord(reaction, update.id); 26 | if (typeof reactionRecord == "undefined") return; 27 | const userRecord = await getUserRecord(user); 28 | let usersReacted = reactionRecord.usersReacted; 29 | const updatedUsersReacted = usersReacted.filter( 30 | (userReacted) => userReacted != userRecord.id 31 | ); 32 | if (updatedUsersReacted.length === 0) { 33 | await prisma.emojiReactions.deleteMany({ 34 | where: { 35 | id: reactionRecord.id, 36 | }, 37 | }); 38 | const emojiRecord = await getEmojiRecord(reaction.emojiTypeName); 39 | if (Object.keys(clubEmojis).includes(emojiRecord.name)) { 40 | /* 41 | console.log({ 42 | updateId: update.id, 43 | club: { 44 | slug: clubEmojis[emojiRecord.name] 45 | } 46 | }) 47 | */ 48 | 49 | await prisma.clubUpdate.deleteMany({ 50 | where: { 51 | updateId: update.id, 52 | club: { 53 | slug: clubEmojis[emojiRecord.name] 54 | } 55 | }, 56 | }); 57 | } 58 | } else { 59 | await prisma.emojiReactions.update({ 60 | where: { id: reactionRecord.id }, 61 | data: { usersReacted: updatedUsersReacted }, 62 | }); 63 | } 64 | } catch { 65 | return; 66 | } 67 | 68 | }); 69 | }; 70 | -------------------------------------------------------------------------------- /apps/slack-bot/src/events/updated.js: -------------------------------------------------------------------------------- 1 | // This function is called when a poster updates their previous post 2 | 3 | import { formatText } from "../lib/utils.js"; 4 | import { postEphemeral } from "../lib/slack.js"; 5 | import { getUserRecord } from "../lib/users.js"; 6 | import prisma from "../lib/prisma.js"; 7 | import metrics from "../metrics.js"; 8 | 9 | export default async ({ event }) => { 10 | try { 11 | const updateRecord = await prisma.updates.findFirst({ 12 | where: { 13 | messageTimestamp: parseFloat(event.previous_message.ts), 14 | }, 15 | }); 16 | if (updateRecord?.id) { 17 | const newMessage = await formatText(event.message.text); 18 | await prisma.updates.update({ 19 | where: { id: updateRecord.id }, 20 | data: { text: newMessage }, 21 | }); 22 | await postEphemeral( 23 | event.channel, 24 | `Your post has been edited! You should see it update on the website in a few seconds.`, 25 | event.message.user 26 | ); 27 | await getUserRecord(event.message.user); 28 | } 29 | // metrics.increment("success.update_post", 1); 30 | } catch (e) { 31 | metrics.increment("errors.update_post", 1); 32 | // console.log(e); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /apps/slack-bot/src/events/userChanged.js: -------------------------------------------------------------------------------- 1 | import { setStatus } from "../lib/profiles.js"; 2 | import { getUserRecord } from "../lib/users.js"; 3 | import { app } from "../app.js"; 4 | import prisma from "../lib/prisma.js"; 5 | import metrics from "../metrics.js"; 6 | 7 | export default async ({ event }) => { 8 | try { 9 | const { user } = event; 10 | const statusEmoji = user.profile.status_emoji; 11 | if (statusEmoji?.startsWith("som-") && 12 | // the character that follows "som-" MUST be a numbe 13 | !Number.isNaN(parseInt(statusEmoji?.slice("som-".length)[0])) 14 | ) { 15 | const statusEmojiCount = statusEmoji.split("-")[1].split(":")[0]; 16 | const { streakCount } = await getUserRecord(user.id); 17 | if ( 18 | (streakCount != statusEmojiCount && streakCount <= 7) || 19 | ("7+" != statusEmojiCount && streakCount >= 8) 20 | ) { 21 | setStatus( 22 | user.id, 23 | `I tried to cheat Scrappy because I’m a clown`, 24 | ":clown_face:" 25 | ); 26 | } 27 | } 28 | // While we're here, check if any of the user's profile fields have been changed & update them 29 | const info = await app.client.users.info({ 30 | user: user.id, 31 | }); 32 | // return if there is no user with this slackID 33 | if (!user.profile.fields) return; 34 | 35 | // if a user does not have the fields property on them 36 | // then they probably don't have timezone information available as well 37 | if (info.user.profile.fields === null) return; 38 | 39 | // return if we got an unsuccessful response from Slack 40 | if (!info.ok) return; 41 | await prisma.accounts.update({ 42 | where: { slackID: user.id }, 43 | data: { 44 | timezoneOffset: info.user?.tz_offset, 45 | timezone: info.user?.tz.replace(`\\`, ""), 46 | avatar: user.profile.image_192, 47 | email: user.profile.fields.email, 48 | website: user.profile.fields["Xf5LNGS86L"]?.value || undefined, 49 | github: user.profile.fields["Xf0DMHFDQA"]?.value || undefined, 50 | }, 51 | }); 52 | } 53 | catch (e) { 54 | // console.log(e); 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/channelKeywords.js: -------------------------------------------------------------------------------- 1 | // Orpheus thanks you for leaving the actual channel name in a comment :) 2 | 3 | export default { 4 | C0M8PUPU6: "ship", // #ship 5 | C6C026NHJ: "hardware", // #hardware 6 | CJ1UVDF7E: "art", // #art 7 | CBX54ACPJ: "camera", // #photography 8 | C02EWM09ACE: "camera", // #surroundings 9 | CCW6Q86UF: "appleinc", // #apple 10 | C90686D0T: "rainbow-flag", // #lgbtq 11 | CN523HLKW: "bank-hackclub", // #bank 12 | C0131FX5K98: "js", // #javascript 13 | C0166QHR0HG: "swift", // #swift 14 | C012LPZUAPR: "gopher", // #go 15 | C14D3AQTT: "package", // #packages 16 | CD543U2UD: "lachlan", // #lachlanfans 17 | CDDMDRJUA: "hacktoberfest", // #hacktoberfest 18 | C019RMWTECD: "ussr", // #the-democratic-peoples-republic-of-sam 19 | CDLBHGUQN: "cat", // #cats 20 | CDJV1CXC2: "dog", // #dogs 21 | C01NQTDFUR5: "scrappy", // #scrappy-dev 22 | C02TWKX227J: "wordle", // #wordle 23 | C0P5NE354: "robot_face", // #bot-spam 24 | C01GF9987SL: "aoc", // #adventofcode 25 | C02UN35M7LG: "sprig-dino", // #sprig 26 | C02EWM09ACE: "undeniablytrue", // #surroundings 27 | C045S4393CY: "10daysinpublic", // #10-days-in-public 28 | C0168BR5PDE: "winter-hardware-wonderland", // #hardware-party 29 | CDJMS683D: "1234", // #counttoamillion 30 | CGVCSNLAJ: "first", // #frc 31 | C064PGB86JE: "quests", // #quests 32 | C06CHS2D05Q: "leaders-summit" //#the-summit 33 | }; 34 | -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/clubEmojis.js: -------------------------------------------------------------------------------- 1 | // Add your club! "emoji": "scrapbook_slug" 2 | 3 | 4 | export default { 5 | "lioncityhacks999999": "nlcs-singapore", 6 | "quest": "quests" 7 | } -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/emojis.js: -------------------------------------------------------------------------------- 1 | import prisma from "../lib/prisma.js"; 2 | import * as emoji from "node-emoji"; 3 | import { app } from "../app.js"; 4 | 5 | export const emojiExists = async (emoji, updateId) => 6 | prisma.emojiReactions 7 | .findMany({ 8 | where: { 9 | updateId: updateId, 10 | emojiTypeName: emoji, 11 | }, 12 | }) 13 | .then((r) => r.length > 0) 14 | .catch((err) => console.log("Cannot check if emoji exists", err)); 15 | 16 | export const getEmojiRecord = async (reaction) => { 17 | reaction = reaction.split("::")[0]; // Removes skin tone modifiers. e.g :+1::skin-tone-5:. 18 | const emojiRecord = await prisma.emojiType.findMany({ 19 | where: { 20 | name: reaction, 21 | }, 22 | }); 23 | if (emojiRecord.length > 0) return emojiRecord[0]; 24 | else { 25 | let emojiSource; 26 | let unicodeEmoji = emoji.find(reaction); 27 | if (!unicodeEmoji) { 28 | const emojiList = await app.client.emoji.list(); 29 | emojiSource = emojiList.emoji[reaction]; 30 | } else { 31 | emojiSource = unicodeEmoji.emoji; 32 | } 33 | return await prisma.emojiType.create({ 34 | data: { 35 | name: reaction, 36 | emojiSource: emojiSource, 37 | }, 38 | }); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/files.js: -------------------------------------------------------------------------------- 1 | import S3 from "./s3.js"; 2 | import { Upload } from "@aws-sdk/lib-storage"; 3 | import Mux from "@mux/mux-node"; 4 | import { app } from "../app.js"; 5 | import { postEphemeral } from "./slack.js"; 6 | import { t } from "./transcript.js"; 7 | import { timeout } from "./utils.js"; 8 | import { v4 as uuidv4 } from "uuid"; 9 | import fetch from "node-fetch"; 10 | import FormData from "form-data"; 11 | import convert from "heic-convert"; 12 | import stream from "node:stream"; 13 | 14 | const mux = new Mux({ 15 | tokenId: process.env.MUX_TOKEN_ID, 16 | tokenSecret: process.env.MUX_TOKEN_SECRET 17 | }); 18 | 19 | const isFileType = (types, fileName) => 20 | types.some((el) => fileName.toLowerCase().endsWith(el)); 21 | 22 | export const getPublicFileUrl = async (urlPrivate, channel, user) => { 23 | let fileName = urlPrivate.split("/").pop(); 24 | const fileId = urlPrivate.split("-")[2].split("/")[0]; 25 | const isImage = isFileType(["jpg", "jpeg", "png", "gif", "webp", "heic"], fileName); 26 | const isAudio = isFileType(["mp3", "wav", "aiff", "m4a"], fileName); 27 | const isVideo = isFileType(["mp4", "mov", "webm"], fileName); 28 | 29 | if (!(isImage || isAudio | isVideo)) return null; 30 | const file = await fetch(urlPrivate, { 31 | headers: { 32 | Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`, 33 | }, 34 | }); 35 | let blob = await file.blob(); 36 | let mediaStream = blob.stream(); 37 | if (blob.type === "image/heic") { 38 | const blobArrayBuffer = Buffer.from(await blob.arrayBuffer()); 39 | // convert the image buffer into a jpeg image 40 | const outBuffer = await convert({ 41 | buffer: blobArrayBuffer, 42 | format: "JPEG" 43 | }); 44 | 45 | // create a readable stream for upload 46 | mediaStream = stream.Readable.from(outBuffer); 47 | 48 | fileName = `./${uuidv4()}.jpeg`; 49 | } 50 | if (blob.size === 19) { 51 | const publicFile = app.client.files.sharedPublicURL({ 52 | token: process.env.SLACK_USER_TOKEN, 53 | file: fileId, 54 | }); 55 | const pubSecret = publicFile.file.permalink_public.split("-").pop(); 56 | const directUrl = `https://files.slack.com/files-pri/T0266FRGM-${fileId}/${fileName}?pub_secret=${pubSecret}`; 57 | if (isVideo) { 58 | postEphemeral(channel, t("messages.errors.bigvideo"), user); 59 | await timeout(30000); 60 | const asset = await mux.video.assets.create({ 61 | input: directUrl, 62 | playback_policy: "public", 63 | }); 64 | return { 65 | url: "https://i.imgur.com/UkXMexG.mp4", 66 | muxId: asset.id, 67 | muxPlaybackId: asset.playback_ids[0].id, 68 | }; 69 | } else { 70 | await postEphemeral(channel, t("messages.errors.imagefail")); 71 | return { url: directUrl }; 72 | } 73 | } 74 | if (isVideo) { 75 | let form = new FormData(); 76 | form.append("file", mediaStream, { 77 | filename: fileName, 78 | knownLength: blob.size, 79 | }); 80 | const uploadedUrl = await fetch("https://bucky.hackclub.com", { 81 | method: "POST", 82 | body: form, 83 | }).then((r) => r.text()); 84 | const asset = await mux.video.assets.create({ 85 | input: uploadedUrl, 86 | playback_policy: "public", 87 | }); 88 | return { 89 | url: uploadedUrl, 90 | muxId: asset.id, 91 | muxPlaybackId: asset.playback_ids[0].id, 92 | }; 93 | } 94 | 95 | const uploads = new Upload({ 96 | client: S3, 97 | params: { 98 | Bucket: "scrapbook-into-the-redwoods", 99 | Key: `${uuidv4()}-${fileName}`, 100 | Body: mediaStream, 101 | } 102 | }); 103 | const uploadedImage = await uploads.done(); 104 | 105 | return { 106 | url: uploadedImage.Location, 107 | muxId: null, 108 | muxPlaybackId: null, 109 | }; 110 | }; -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/prisma.js: -------------------------------------------------------------------------------- 1 | import Prisma from "@prisma/client"; 2 | import metrics from "../metrics.js"; 3 | 4 | let prisma = new Prisma.PrismaClient().$extends({ 5 | // extend prisma client 6 | // to send query metrics such as latency & failures 7 | query: { 8 | async $allOperations({ operation, model, args, query }) { 9 | const metricKey = `${operation}_${model}`; 10 | try { 11 | const start = performance.now(); 12 | const queryResult = await query(args); 13 | const time = performance.now() - start; 14 | 15 | metrics.timing(metricKey, time); 16 | 17 | return queryResult; 18 | } catch (err) { 19 | metrics.increment(`errors.${metricKey}`, 1); 20 | } 21 | return; 22 | } 23 | } 24 | }); 25 | 26 | export default prisma; 27 | -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/profiles.js: -------------------------------------------------------------------------------- 1 | import { app } from "../app.js"; 2 | import { t } from "./transcript.js"; 3 | import { getUserRecord } from "./users.js"; 4 | import prisma from "./prisma.js"; 5 | import metrics from "../metrics.js"; 6 | 7 | export const setStatus = async (user, statusText, statusEmoji) => { 8 | try{ 9 | // don't set status for @zrl or @msw as they're slack owners 10 | if(user == "U0C7B14Q3" || user == "U0266FRGP") return false 11 | 12 | // get user info 13 | const userInfo = await app.client.users.info({ 14 | token: process.env.SLACK_USER_TOKEN, 15 | user, 16 | }); 17 | 18 | const setProfile = app.client.users.profile.set({ 19 | token: userInfo.user.is_admin ? process.env.SLACK_ADMIN_TOKEN : process.env.SLACK_USER_TOKEN, 20 | user, 21 | profile: { 22 | status_text: statusText, 23 | status_emoji: statusEmoji, 24 | status_expiration: 0, 25 | }, 26 | }); 27 | 28 | metrics.increment("success.set_status", 1); 29 | } 30 | catch(e){ 31 | app.client.chat.postMessage({ 32 | channel: "U0266FRGP", 33 | text: t("messages.errors.zach"), 34 | }); 35 | app.client.chat.postMessage({ 36 | channel: "USNPNJXNX", 37 | text: t("messages.errors.zach"), 38 | }); 39 | metrics.increment("errors.set_status", 1); 40 | } 41 | }; 42 | 43 | export const setAudio = async (user, url) => { 44 | const userRecord = await getUserRecord(user); 45 | await prisma.accounts.update({ 46 | where: { 47 | slackID: userRecord.slackID, 48 | }, 49 | data: { 50 | customAudioURL: url, 51 | }, 52 | }); 53 | }; 54 | -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/reactions.js: -------------------------------------------------------------------------------- 1 | import { react } from "./slack.js"; 2 | import prisma from "./prisma.js"; 3 | import emojiKeywords from "./emojiKeywords.js"; 4 | 5 | export const getReactionRecord = async (emoji, updateId) => 6 | await prisma.emojiReactions.findFirst({ 7 | where: { 8 | emojiTypeName: emoji, 9 | updateId: updateId, 10 | }, 11 | }); 12 | 13 | export const reactBasedOnKeywords = (channel, message, ts) => { 14 | Object.keys(emojiKeywords).forEach(async (keyword) => { 15 | try { 16 | if ( 17 | message?.toLowerCase().search(new RegExp("\\b" + keyword + "\\b", "gi")) !== -1 18 | ) { 19 | await react("add", channel, ts, emojiKeywords[keyword]); 20 | } 21 | } 22 | catch (e) { 23 | // console.log(message) 24 | // console.log(e) 25 | } 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/s3.js: -------------------------------------------------------------------------------- 1 | import { S3Client } from "@aws-sdk/client-s3"; 2 | 3 | 4 | const s3 = new S3Client({ 5 | region: "us-east-1", 6 | credentials: { 7 | accessKeyId: process.env.S3_ACCESS_KEY_ID, 8 | secretAccessKey: process.env.S3_SECRET_ACCESS_KEY 9 | } 10 | }) 11 | 12 | export default s3; 13 | -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/seasons.js: -------------------------------------------------------------------------------- 1 | import createSeasonResolver from "date-season"; 2 | 3 | let seasonNorth = createSeasonResolver(); 4 | 5 | export const SEASON_EMOJI = 6 | seasonNorth(new Date()) == "Spring" 7 | ? "spring-of-making" 8 | : seasonNorth(new Date()) == "Summer" 9 | ? "summer-of-making" 10 | : seasonNorth(new Date()) == "Winter" 11 | ? "wom" 12 | : "aom"; 13 | -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/shipEasterEgg.js: -------------------------------------------------------------------------------- 1 | import lodash from 'lodash'; 2 | const { random } = lodash; 3 | import { postEphemeral } from "./slack.js" 4 | 5 | export default async ({ event }) => { 6 | const { item, user, reaction, item_user } = event; 7 | const { channel, ts } = item; 8 | const get = (arr) => random(0, arr.length - 1); 9 | const randomize = () => [ 10 | get(platforms), 11 | get(verbs), 12 | get(subjects), 13 | get(stacks), 14 | get(slander), 15 | ]; 16 | const indices = randomize(); 17 | const makeText = () => 18 | `${slander[indices[4]]} ${platforms[indices[0]]} that ${ 19 | verbs[indices[1]] 20 | } ${subjects[indices[2]]} using ${stacks[indices[3]]}`; 21 | const text = makeText(); 22 | await postEphemeral(channel, text, user); 23 | return text; 24 | }; 25 | 26 | const slander = [ 27 | `you want an idea!!! well i'll give you an idea:`, 28 | `yeah, that's right, i'm more creative than you...`, 29 | "idea!!! yummy idea!!!", 30 | "ugh, another idea (how am i gonna make my millions??):", 31 | "welll..... you could build a", 32 | "you could (hmm... maybe you could) build a", 33 | "get off your bumm and make a", 34 | "chop chop time to make a", 35 | `don't be lazy young one!! time to build a`, 36 | "i have an idea:", 37 | "i have one more idea than you:", 38 | ]; 39 | 40 | const platforms = [ 41 | "Slack bot", 42 | "SMS bot", 43 | "iPhone app", 44 | "Arduino board", 45 | "Website", 46 | "Robot", 47 | "Fake robot", 48 | "ML model", 49 | "Ancient software", 50 | "Smartwatch", 51 | "Smart camera", 52 | "Microcomputer", 53 | "Windows program", 54 | "Mac app", 55 | ]; 56 | 57 | const verbs = [ 58 | "remixes", 59 | "generates", 60 | "psychoanalyzes", 61 | "transcribes", 62 | "scans", 63 | "hand-draws", 64 | "deep-fries", 65 | "stuns", 66 | "eats", 67 | "builds", 68 | "examines", 69 | "inspects", 70 | "researches", 71 | "solves", 72 | "explains", 73 | "investigates", 74 | "cleans", 75 | "measures", 76 | "cooks", 77 | ]; 78 | 79 | const subjects = [ 80 | "Hack Club community members", 81 | "iPhone apps", 82 | "lamps", 83 | "floorplans", 84 | "iPad Pro keyboards", 85 | "chicken fingers", 86 | "McDonalds packaging", 87 | "chicken nuggets", 88 | "board games", 89 | "fruit", 90 | "fish", 91 | "pencils", 92 | "books", 93 | "headphones", 94 | "soccer balls", 95 | "insect repellent", 96 | "hazmat suits", 97 | "paint brushes", 98 | "video calls", 99 | "calendars", 100 | "cups", 101 | "speakers", 102 | "rulers", 103 | "erasers", 104 | "tv remotes", 105 | "bikes", 106 | "newspapers", 107 | "pens", 108 | "light bulbs", 109 | "LED lights", 110 | "apples", 111 | "oranges", 112 | "atlases", 113 | "power plugs", 114 | "computers", 115 | "ancient scrolls", 116 | "statues", 117 | "hammers", 118 | "superheros", 119 | "teddy bears", 120 | "alpacas", 121 | "cables", 122 | "stuffed toys", 123 | ]; 124 | 125 | const stacks = [ 126 | "AI", 127 | "ML", 128 | "blockchain", 129 | "React.js", 130 | "a CLI", 131 | "Redwood.js", 132 | "Markdown", 133 | "Rust", 134 | "Go", 135 | "JavaScript", 136 | "Twilio", 137 | "the GitHub API", 138 | "Ruby on Rails", 139 | "serverless", 140 | "Python", 141 | "Next.js", 142 | "COBOL", 143 | "Flask", 144 | "Django", 145 | "Tensorflow", 146 | "C++", 147 | "Swift", 148 | "Java", 149 | "Binary", 150 | ]; 151 | -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/slack.js: -------------------------------------------------------------------------------- 1 | import { app } from "../app.js"; 2 | import metrics from "../metrics.js" 3 | import prisma from "../lib/prisma.js"; 4 | import { getEmojiRecord, emojiExists } from "./emojis.js"; 5 | 6 | // ex. react('add', 'C248d81234', '12384391.12231', 'beachball') 7 | export const react = async (addOrRemove, channel, ts, reaction) => { 8 | try { 9 | await app.client.reactions[addOrRemove]({ 10 | channel: channel, 11 | name: reaction, 12 | timestamp: ts, 13 | }); 14 | 15 | const update = await prisma.updates.findFirst({ 16 | where: { 17 | messageTimestamp: parseFloat(ts), 18 | }, 19 | }); 20 | 21 | const emojiRecord = await getEmojiRecord(reaction); 22 | if (update && emojiRecord) { 23 | if (addOrRemove === 'add') { 24 | const exists = await emojiExists(reaction, update.id); 25 | if (!exists) { 26 | await prisma.emojiReactions.create({ 27 | data: { 28 | updateId: update.id, 29 | emojiTypeName: emojiRecord.name, 30 | }, 31 | }); 32 | } 33 | } else if (addOrRemove === 'remove') { 34 | await prisma.emojiReactions.deleteMany({ 35 | where: { 36 | updateId: update.id, 37 | emojiTypeName: emojiRecord.name, 38 | }, 39 | }); 40 | } 41 | } 42 | // metrics.increment(`success.react.${addOrRemove}`, 1); 43 | } catch (error) { 44 | metrics.increment(`errors.react.${addOrRemove}`, 1); 45 | } 46 | }; 47 | 48 | // replies to a message in a thread 49 | // ex. reply('C34234d934', '31482975923.12331', 'this is a threaded reply!') 50 | export const reply = async (channel, parentTs, text, unfurl) => { 51 | try { 52 | await app.client.chat.postMessage({ 53 | channel: channel, 54 | thread_ts: parentTs, 55 | text: text, 56 | parse: "mrkdwn", 57 | unfurl_links: unfurl, 58 | unfurl_media: false, 59 | }); 60 | 61 | // metrics.increment("success.reply", 1); 62 | } catch (err) { 63 | metrics.increment("errors.reply", 1); 64 | } 65 | } 66 | 67 | export const getMessage = async (ts, channel) => { 68 | try { 69 | const history = await app.client.conversations.history({ 70 | channel, 71 | latest: ts, 72 | limit: 1, 73 | inclusive: true, 74 | }); 75 | // metrics.increment("success.get_message", 1); 76 | return history.messages[0] || null; 77 | } catch (e) { 78 | metrics.increment("errors.get_message", 1); 79 | return null; 80 | } 81 | }; 82 | 83 | export const postEphemeral = async (channel, text, user, threadTs) => { 84 | try { 85 | await app.client.chat.postEphemeral({ 86 | attachments: [], 87 | channel: channel, 88 | text: text, 89 | user: user, 90 | thread_ts: threadTs, 91 | }); 92 | // metrics.increment("success.post_ephemeral", 1); 93 | } catch (e) { 94 | metrics.increment("errors.post_ephemeral", 1); 95 | // console.log(e); 96 | } 97 | }; 98 | -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/transcript.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import yaml from "js-yaml"; 3 | import path from "path"; 4 | import { sample } from "./utils.js"; 5 | 6 | // ex. t('greeting', { userID: 'UX12U391' }) 7 | 8 | export const t = (search, vars) => { 9 | const searchArr = search.split("."); 10 | const transcriptObj = yaml.load( 11 | fs.readFileSync(path.join(process.cwd(), "src/lib/transcript.yml"), "utf-8") 12 | ); 13 | return evalTranscript(recurseTranscript(searchArr, transcriptObj), vars); 14 | }; 15 | 16 | const recurseTranscript = (searchArr, transcriptObj, topRequest) => { 17 | topRequest = topRequest || searchArr.join("."); 18 | const searchCursor = searchArr.shift(); 19 | const targetObj = transcriptObj[searchCursor]; 20 | if (!targetObj) { 21 | return topRequest; 22 | } 23 | if (searchArr.length > 0) { 24 | return recurseTranscript(searchArr, targetObj, topRequest); 25 | } else { 26 | if (Array.isArray(targetObj)) { 27 | return sample(targetObj); 28 | } else { 29 | return targetObj; 30 | } 31 | } 32 | }; 33 | 34 | const evalTranscript = (target, vars = {}) => { 35 | const context = { 36 | ...vars, 37 | t, 38 | }; 39 | return function () { 40 | return eval("`" + target + "`"); 41 | }.call(context); 42 | }; 43 | -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/users.js: -------------------------------------------------------------------------------- 1 | import prisma from "./prisma.js"; 2 | import { app } from "../app.js"; 3 | import { sample } from "./utils.js"; 4 | 5 | export const getUserRecord = async (userId) => { 6 | let user 7 | try { 8 | user = await app.client.users.profile.get({ token: process.env.SLACK_USER_TOKEN, user: userId }); 9 | } 10 | catch (e) { 11 | // console.log(userId) 12 | // console.error(e) 13 | return 14 | } 15 | if (user.profile === undefined) return; 16 | let record = await prisma.accounts.findUnique({ 17 | where: { 18 | slackID: userId, 19 | }, 20 | }); 21 | if (record === null) { 22 | let profile = await app.client.users.info({ user: userId }); 23 | let username = 24 | user.profile.display_name !== "" 25 | ? user.profile.display_name.replace(/\s/g, "") 26 | : user.profile.real_name.replace(/\s/g, ""); 27 | let tzOffset = profile.user.tz_offset; 28 | let tz = profile.user.tz.replace(`\\`, ""); 29 | let checkIfExists = await prisma.accounts.findFirst({ 30 | where: { username: username }, 31 | }); 32 | record = await prisma.accounts.create({ 33 | data: { 34 | slackID: userId, 35 | username: `${username}${checkIfExists != null ? `-${userId}` : ""}`, 36 | streakCount: 0, 37 | email: user.profile.email, 38 | website: user.profile.fields["Xf5LNGS86L"]?.value || null, 39 | github: user.profile.fields["Xf0DMHFDQA"]?.value || null, 40 | newMember: true, 41 | avatar: user.profile.image_192, 42 | timezoneOffset: tzOffset, 43 | timezone: tz, 44 | }, 45 | }); 46 | if (!user.profile.is_custom_image) { 47 | const animalImages = [ 48 | "https://i.imgur.com/njP1JWx.jpg", 49 | "https://i.imgur.com/NdOZWDB.jpg", 50 | "https://i.imgur.com/l8dV3DJ.jpg", 51 | "https://i.imgur.com/Ej6Ovlq.jpg", 52 | "https://i.imgur.com/VG29lvI.jpg", 53 | "https://i.imgur.com/tDusvvD.jpg", 54 | "https://i.imgur.com/63H1hQM.jpg", 55 | "https://i.imgur.com/xGtLTa3.png", 56 | ]; 57 | const animalImage = sample(animalImages); 58 | await prisma.accounts.update({ 59 | where: { slackID: userId }, 60 | data: { avatar: animalImage }, 61 | }); 62 | } 63 | } else { 64 | // update the user email if they don't have one on their account 65 | if (!record.email) { 66 | await prisma.accounts.update({ 67 | where: { 68 | slackID: userId 69 | }, 70 | data: { 71 | email: user.profile.email 72 | } 73 | }) 74 | } 75 | } 76 | 77 | return { ...record, slack: user }; 78 | }; 79 | 80 | export const forgetUser = async (user) => { 81 | await Promise.all([ 82 | await prisma.updates.deleteMany({ 83 | // delete their updates... 84 | where: { 85 | slackID: user, 86 | }, 87 | }), 88 | await prisma.accounts.deleteMany({ 89 | // delete their account 90 | where: { 91 | accountsSlackID: user, 92 | }, 93 | }), 94 | ]); 95 | }; 96 | 97 | export const canDisplayStreaks = async (userId) => { 98 | let record = await getUserRecord(userId); 99 | return record.displayStreak; 100 | }; 101 | -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/utils.js: -------------------------------------------------------------------------------- 1 | import * as emoji from "node-emoji"; 2 | import { getUserRecord } from "./users.js"; 3 | import { app } from "../app.js"; 4 | import { v4 as uuidv4 } from "uuid"; 5 | import { Upload } from "@aws-sdk/lib-storage" 6 | import S3 from "./s3.js"; 7 | 8 | const replaceEmoji = (str) => emoji.emojify(str.replace(/::(.*):/, ":")); 9 | 10 | export const timeout = (ms) => { 11 | return new Promise((resolve) => { 12 | setTimeout(() => { 13 | resolve(); 14 | }, ms); 15 | }); 16 | }; 17 | 18 | export const sample = (arr) => arr[Math.floor(Math.random() * arr.length)]; 19 | 20 | export const getNow = (tz) => { 21 | const date = new Date().toLocaleString("en-US", { timeZone: tz ?? undefined }); 22 | return new Date(date).toISOString(); 23 | }; 24 | 25 | export const getDayFromISOString = (ISOString) => { 26 | const date = new Date(ISOString); 27 | try { 28 | date.setHours(date.getHours() - 4); 29 | ISOString = date.toISOString(); 30 | } catch {} 31 | try { 32 | const month = ISOString.split("-")[1]; 33 | const day = ISOString.split("-")[2].split("T")[0]; 34 | return `${month}-${day}`; 35 | } catch {} 36 | }; 37 | 38 | export const formatText = async (text) => { 39 | text = replaceEmoji(text).replace("&", "&"); 40 | let users = text.replaceAll("><@", "> <@").match(/<@U\S+>/g) || []; 41 | await Promise.all( 42 | users.map(async (u) => { 43 | const uID = u.substring(2, u.length - 1); 44 | const userRecord = await getUserRecord(uID); 45 | if (!userRecord) { 46 | app.client.users.profile 47 | .get({ user: u.substring(2, u.length - 1) }) 48 | .then(({ profile }) => profile.display_name || profile.real_name) 49 | .then((displayName) => (text = text.replace(u, `@${displayName}`))); 50 | } else text = text.replace(u, ` @${userRecord.username} `); 51 | }) 52 | ); 53 | let channels = text.match(/<#[^|>]+\|\S+>/g) || []; 54 | channels.forEach(async (channel) => { 55 | const channelName = channel.split("|")[1].replace(">", ""); 56 | text = text.replace(channel, `#${channelName}`); 57 | }); 58 | return text; 59 | }; 60 | 61 | export const getUrlFromString = (str) => { 62 | const urlRegex = 63 | /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi; 64 | let url = str.match(urlRegex)[0]; 65 | if (url.includes("|")) url = url.split("|")[0]; 66 | if (url.startsWith("<")) url = url.substring(1, url.length - 1); 67 | return url; 68 | }; 69 | 70 | // returns the urls that are in the text 71 | export function getUrls(text) { 72 | /** 73 | * source: https://github.com/huckbit/extract-urls/blob/dc958a658ebf9d86f4546092d5a3183e9a99eb95/index.js#L5 74 | * 75 | * matches http,https,www and urls like raylib.com including scrapbook.hackclub.com 76 | */ 77 | const matcher = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()'@:%_\+.~#?!&//=]*)/gi; 78 | return text.match(matcher); 79 | } 80 | 81 | export function extractOgUrl(htmlDoc) { 82 | const result = RegExp("\"og:image\"").exec(htmlDoc); 83 | 84 | if (!result) return; 85 | 86 | let index = result.index; 87 | for(;;) { 88 | if (htmlDoc[index] === "/" && htmlDoc[index+1] === ">") break; 89 | if (htmlDoc[index] === ">") break; 90 | index++; 91 | } 92 | 93 | const ogExtract = htmlDoc.slice(result.index, index); 94 | const ogUrlString = ogExtract.split("content=")[1].trim(); 95 | return ogUrlString.slice(1, -1); 96 | } 97 | 98 | export async function getPageContent(page) { 99 | const response = await fetch(page); 100 | const content = await response.text(); 101 | return content; 102 | } 103 | 104 | async function uploadImageToS3(filename, blob) { 105 | let formData = new FormData(); 106 | formData.append("file", blob, { 107 | filename, 108 | knownLength: blob.size 109 | }); 110 | 111 | const uploads = new Upload({ 112 | client: S3, 113 | params: { 114 | Bucket: "scrapbook-into-the-redwoods", 115 | Key: `${uuidv4()}-${filename}`, 116 | Body: blob 117 | } 118 | }); 119 | 120 | const uploadedImage = await uploads.done(); 121 | return uploadedImage.Location; 122 | } 123 | 124 | export async function getAndUploadOgImage(url) { 125 | const file = await fetch(url); 126 | let blob = await file.blob(); 127 | const form = new FormData(); 128 | form.append("file", blob, `${uuidv4()}.${blob.type.split("/")[1]}`); 129 | 130 | const imageUrl = await uploadImageToS3(`${uuidv4()}.${blob.type.split("/")[1]}`, blob); 131 | 132 | return imageUrl; 133 | } 134 | -------------------------------------------------------------------------------- /apps/slack-bot/src/lib/webring.js: -------------------------------------------------------------------------------- 1 | import { getUserRecord } from "./users.js"; 2 | import { sample } from "./utils.js"; 3 | import prisma from "./prisma.js"; 4 | 5 | export const getRandomWebringPost = async (user) => { 6 | const userRecord = await getUserRecord(user); 7 | const webring = userRecord.webring; 8 | if (!webring || !webring.length) return { notfound: true }; 9 | const randomUserRecord = sample(webring); 10 | const latestUpdate = await prisma.updates.findMany({ 11 | orderBy: { 12 | postTime: "desc", 13 | }, 14 | where: { 15 | accountsSlackID: randomUserRecord, 16 | }, 17 | }); 18 | if (latestUpdate.length === 0) { 19 | return { 20 | post: null, 21 | scrapbookUrl: `https://scrapbook.hackclub.com/${randomUserRecord.username}`, 22 | nonexistence: true, 23 | }; 24 | } else { 25 | const messageTs = 26 | latestUpdate[0].messageTimestamp.toString().replace(".", "") + "00"; 27 | const channel = latestUpdate[0].channel; 28 | return { 29 | post: `https://hackclub.slack.com/archives/${channel}/p${messageTs}`, 30 | scrapbookUrl: `https://scrapbook.hackclub.com/${randomUserRecord.username}`, 31 | }; 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /apps/slack-bot/src/metrics.js: -------------------------------------------------------------------------------- 1 | import { StatsD } from "node-statsd"; 2 | import { config } from "dotenv"; 3 | 4 | config(); 5 | const environment = process.env.NODE_ENV; 6 | const graphite = process.env.GRAPHITE_HOST; 7 | 8 | if (graphite === null) { 9 | throw new Error("Graphite host not configured!"); 10 | } 11 | 12 | const options = { 13 | host: graphite, 14 | port: 8125, 15 | prefix: `${environment}.scrappy.` 16 | }; 17 | 18 | const metrics = new StatsD(options); 19 | 20 | export default metrics; 21 | -------------------------------------------------------------------------------- /apps/slack-bot/src/routes/mux.js: -------------------------------------------------------------------------------- 1 | import { reply } from "../lib/slack.js"; 2 | import { t } from "../lib/transcript.js"; 3 | import prisma from "../lib/prisma.js"; 4 | 5 | // Only runs when a user uploads a large video, to notify them when Mux processes the video 6 | 7 | export const mux = { 8 | path: "/api/mux", 9 | method: ["POST"], 10 | handler: async (req, res) => { 11 | if (req.body.type === "video.asset.ready") { 12 | const assetId = req.body.object.id; 13 | const videoUpdate = ( 14 | await prisma.updates.findMany({ 15 | where: { 16 | muxAssetIDs: { 17 | has: assetId, 18 | }, 19 | }, 20 | }) 21 | )[0]; 22 | const largeVideo = videoUpdate?.isLargeVideo || false; 23 | if (largeVideo) { 24 | const ts = videoUpdate.messageTimestamp; 25 | const user = videoUpdate.accountsSlackID; 26 | reply(process.env.CHANNEL, ts, t("messages.assetReady", { user })); 27 | } 28 | } 29 | res.writeHead(200); 30 | res.end(); 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /apps/web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:23-alpine3.20 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json . 6 | COPY yarn.lock . 7 | 8 | RUN yarn install 9 | 10 | COPY . . 11 | 12 | ARG GRAPHITE_HOST 13 | ARG PG_DATABASE_URL 14 | 15 | RUN yarn build 16 | 17 | EXPOSE 3000 18 | CMD ["yarn", "start"] 19 | -------------------------------------------------------------------------------- /apps/web/components/analytics.js: -------------------------------------------------------------------------------- 1 | import Script from 'next/script' 2 | 3 | const Analytics = () => ( 4 |