├── .eslintrc.yml ├── .github └── dependabot.yml ├── .gitignore ├── 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 /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | extends: eslint:recommended 5 | overrides: [] 6 | parserOptions: 7 | ecmaVersion: latest 8 | sourceType: module 9 | rules: {} 10 | -------------------------------------------------------------------------------- /.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 | ignore: 13 | - dependency-name: "node-fetch" 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | node_modules 3 | .DS_Store 4 | *.env 5 | dist 6 | build 7 | production.env 8 | staging.env -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.1", 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/regenerate-og.js: -------------------------------------------------------------------------------- 1 | import { Upload } from "@aws-sdk/lib-storage"; 2 | import { PrismaClient } from "@prisma/client"; 3 | import { v4 as uuidv4 } from "uuid"; 4 | import S3 from "../src/lib/s3.js"; 5 | 6 | export const getUrlFromString = (str) => { 7 | const urlRegex = 8 | /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi; 9 | let url = str.match(urlRegex)[0]; 10 | if (url.includes("|")) url = url.split("|")[0]; 11 | if (url.startsWith("<")) url = url.substring(1, url.length - 1); 12 | return url; 13 | }; 14 | 15 | // returns the urls that are in the text 16 | export function getUrls(text) { 17 | /** 18 | * source: https://github.com/huckbit/extract-urls/blob/dc958a658ebf9d86f4546092d5a3183e9a99eb95/index.js#L5 19 | * 20 | * matches http,https,www and urls like raylib.com including scrapbook.hackclub.com 21 | */ 22 | const matcher = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()'@:%_\+.~#?!&//=]*)/gi; 23 | return text.match(matcher); 24 | } 25 | 26 | export function extractOgUrl(htmlDoc) { 27 | const result = RegExp("\"og:image\"").exec(htmlDoc); 28 | 29 | if (!result) return; 30 | 31 | let index = result.index; 32 | for (; ;) { 33 | if (htmlDoc[index] === "/" && htmlDoc[index + 1] === ">") break; 34 | if (htmlDoc[index] === ">") break; 35 | index++; 36 | } 37 | 38 | const ogExtract = htmlDoc.slice(result.index, index); 39 | const ogUrlString = ogExtract.split("content=")[1].trim(); 40 | return ogUrlString.slice(1, -1); 41 | } 42 | 43 | export async function getPageContent(page) { 44 | const response = await fetch(page); 45 | const content = await response.text(); 46 | return content; 47 | } 48 | 49 | async function uploadImageToS3(filename, blob) { 50 | let formData = new FormData(); 51 | formData.append("file", blob, { 52 | filename, 53 | knownLength: blob.size 54 | }); 55 | 56 | const uploads = new Upload({ 57 | client: S3, 58 | params: { 59 | Bucket: "scrapbook-into-the-redwoods", 60 | Key: `${uuidv4()}-${filename}`, 61 | Body: blob 62 | } 63 | }); 64 | 65 | const uploadedImage = await uploads.done(); 66 | return uploadedImage.Location; 67 | } 68 | 69 | export async function getAndUploadOgImage(url) { 70 | try { 71 | const file = await fetch(url); 72 | let blob = await file.blob(); 73 | const form = new FormData(); 74 | form.append("file", blob, `${uuidv4()}.${blob.type.split("/")[1]}`); 75 | 76 | const imageUrl = await uploadImageToS3(`${uuidv4()}.${blob.type.split("/")[1]}`, blob); 77 | 78 | return imageUrl; 79 | } catch (error) { 80 | throw error; 81 | } 82 | } 83 | 84 | async function processPosts() { 85 | const prismaClient = new PrismaClient(); 86 | let processed = 0; 87 | 88 | const startDate = new Date("2023-12-22"); 89 | const postsWithPotentiallyOGImages = await prismaClient.updates.findMany({ 90 | where: { 91 | postTime: { 92 | gt: startDate 93 | } 94 | }, 95 | }); 96 | 97 | while (processed <= postsWithPotentiallyOGImages.length) { 98 | console.log("Processing posts", processed, "to", processed + 100); 99 | await regenerateOGImages(postsWithPotentiallyOGImages.slice(processed, processed + 100)); 100 | processed += 100; 101 | } 102 | } 103 | 104 | async function regenerateOGImages(posts) { 105 | return new Promise((resolve, reject) => { 106 | // this is the date when fallbacks to OG images was originally introduced 107 | const prismaClient = new PrismaClient(); 108 | Promise.all(posts.map(async post => { 109 | console.log("Working on post", post.id); 110 | // check if the post has an image that is hosted on `imgutil.s3.us-east-2.amazonaws.com` and it's actually an image 111 | const imageWasOnBucky = image => image.includes('imgutil.s3.us-east-2.amazonaws.com') && ["jpg", "jpeg", "png", "gif", "webp", "heic"].some(ext => image.toLowerCase().endsWith(ext)) 112 | const attachmentsOnBucky = post.attachments.filter(attachment => imageWasOnBucky(attachment)); 113 | const attachmentesNotOnBucky = post.attachments.filter(attachment => !imageWasOnBucky(attachment)); 114 | 115 | if (post.attachments.length > 0 && attachmentsOnBucky.length === 0) return; 116 | 117 | const urls = getUrls(post.text); 118 | if (!urls || urls.length === 0) return; 119 | 120 | const regeneratedOGs = await Promise.all(urls.map(async url => { 121 | try { 122 | 123 | const pageContent = await getPageContent(url); 124 | const ogUrls = extractOgUrl(pageContent); 125 | 126 | if (ogUrls.length === 0) return null; 127 | 128 | let imageUrl = await getAndUploadOgImage(ogUrls); 129 | return imageUrl; 130 | } catch (error) { 131 | console.log("Failed to update OG image", url, error); 132 | return null; 133 | } 134 | })); 135 | 136 | const updatedAttachments = [...attachmentesNotOnBucky, ...regeneratedOGs.filter(a => a !== null)]; 137 | 138 | // update the attachments 139 | await prismaClient.updates.update({ 140 | where: { 141 | id: post.id 142 | }, 143 | data: { 144 | attachments: updatedAttachments 145 | } 146 | }); 147 | 148 | console.log("Updated post attachments", post.id); 149 | })).then(() => { 150 | resolve(); 151 | }); 152 | console.log("Done!"); 153 | }); 154 | } 155 | 156 | processPosts(); -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import bolt from "@slack/bolt"; 2 | const { App, subtype, ExpressReceiver } = bolt; 3 | import bodyParser from "body-parser"; 4 | import fetch from "node-fetch"; 5 | import { t } from "./lib/transcript.js"; 6 | import { mux } from "./routes/mux.js"; 7 | import streakResetter from "./routes/streakResetter.js"; 8 | import help from "./commands/help.js"; 9 | import setAudio from "./commands/setaudio.js"; 10 | import setCSS from "./commands/setcss.js"; 11 | import setDomain from "./commands/setdomain.js"; 12 | import setUsername from "./commands/setusername.js"; 13 | import toggleStreaks from "./commands/togglestreaks.js"; 14 | import webring from "./commands/webring.js"; 15 | import joined from "./events/joined.js"; 16 | import userChanged from "./events/userChanged.js"; 17 | import create from "./events/create.js"; 18 | import deleted from "./events/deleted.js"; 19 | import mention from "./events/mention.js"; 20 | import updated from "./events/updated.js"; 21 | import forget from "./events/forget.js"; 22 | import noFile, { noFileCheck } from "./events/noFile.js"; 23 | import reactionAdded from "./events/reactionAdded.js"; 24 | import reactionRemoved from "./events/reactionRemoved.js"; 25 | import { commands } from "./commands/commands.js"; 26 | import metrics from "./metrics.js"; 27 | 28 | const receiver = new ExpressReceiver({ 29 | signingSecret: process.env.SLACK_SIGNING_SECRET, 30 | }); 31 | receiver.router.use(bodyParser.urlencoded({ extended: true })); 32 | receiver.router.use(bodyParser.json()); 33 | 34 | export const app = new App({ 35 | token: process.env.SLACK_BOT_TOKEN, 36 | signingSecret: process.env.SLACK_SIGNING_SECRET, 37 | receiver, 38 | }); 39 | 40 | export const execute = (actionToExecute) => { 41 | return async (slackObject, ...props) => { 42 | if (slackObject.ack) { 43 | await slackObject.ack(); 44 | } 45 | 46 | let isCommandOrMessage = slackObject.payload.command || slackObject.payload.message; 47 | const payload = slackObject.payload; 48 | let metricKey; 49 | if (payload.type === "message" && payload.subtype) { 50 | metricKey = payload.subtype; 51 | } else { 52 | metricKey = payload.type; 53 | } 54 | 55 | try { 56 | const metricMsg = `success.${metricKey}`; 57 | const startTime = new Date().getTime(); 58 | actionToExecute(slackObject, ...props) 59 | .then(() => { 60 | const time = (new Date().getTime()) - startTime; 61 | if (isCommandOrMessage) metrics.timing(metricKey, time); 62 | }); 63 | if (isCommandOrMessage) metrics.increment(metricMsg, 1); 64 | } catch (e) { 65 | const metricMsg = `errors.${metricKey}`; 66 | if (isCommandOrMessage) metrics.increment(metricMsg, 1); 67 | console.log(e); 68 | await app.client.chat.postMessage({ 69 | channel: "C04ULNY90BC", 70 | text: t("error", { e }), 71 | parse: "mrkdwn", 72 | unfurl_links: false, 73 | unfurl_media: false, 74 | }); 75 | } 76 | }; 77 | }; 78 | 79 | app.command(`/${commands.scrappy}`, execute(help)); 80 | 81 | app.command(`/${commands.scrappyHelp}`, execute(help)); 82 | 83 | app.command(`/${commands.scrappySetAudio}`, execute(setAudio)); 84 | 85 | app.command(`/${commands.scrappySetCSS}`, execute(setCSS)); 86 | 87 | app.command(`/${commands.scrappySetDomain}`, execute(setDomain)); 88 | 89 | app.command(`/${commands.scrappyDisplayStreaks}`, execute(toggleStreaks)); 90 | 91 | app.command(`/${commands.scrappySetUsername}`, execute(setUsername)); 92 | 93 | app.command(`/${commands.scrappyToggleStreaks}`, execute(toggleStreaks)); 94 | 95 | app.command(`/${commands.scrappyWebring}`, execute(webring)); 96 | 97 | app.event("reaction_added", execute(reactionAdded)); 98 | 99 | app.event("reaction_removed", execute(reactionRemoved)); 100 | 101 | app.event("member_joined_channel", execute(joined)); 102 | 103 | app.event("user_change", execute(userChanged)); 104 | 105 | app.event("message", execute(create)); 106 | 107 | const messageChanged = (slackObject, ...props) => { 108 | if (slackObject.event.message.subtype == "tombstone") { 109 | execute(deleted)(slackObject, ...props); 110 | } else { 111 | return execute(updated)(slackObject, ...props); 112 | } 113 | }; 114 | 115 | app.message(subtype("message_changed"), messageChanged); 116 | 117 | app.message("forget scrapbook", execute(forget)); 118 | 119 | app.message("<@U015D6A36AG>", execute(mention)); 120 | 121 | try { 122 | receiver.router.post("/api/mux", mux.handler); 123 | } catch (e) { 124 | console.log(e); 125 | } 126 | 127 | try { 128 | receiver.router.get("/api/streakResetter", streakResetter); 129 | } catch (e) { 130 | console.log(e); 131 | } 132 | 133 | (async () => { 134 | await app.start(process.env.PORT || 3000); 135 | let latestCommitMsg = "misc..."; 136 | await fetch("https://api.github.com/repos/hackclub/scrappy/commits/main") 137 | .then((r) => r.json()) 138 | .then((d) => (latestCommitMsg = d.commit?.message || "")); 139 | app.client.chat.postMessage({ 140 | channel: "C0P5NE354", 141 | text: t("startup.message", { latestCommitMsg }), 142 | parse: "mrkdwn", 143 | unfurl_links: false, 144 | unfurl_media: false, 145 | }); 146 | console.log("⚡️ Scrappy is running !"); 147 | })(); -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | // domain is owned by another Vercel account, but we can ask the owner to verify 51 | console.log(vercelFetch.verification); 52 | if (!vercelFetch.verification) { 53 | await respond( 54 | t("messages.domain.domainerror", { 55 | text: arg, 56 | error: "No verification records were provided by the Vercel API", 57 | }) 58 | ); 59 | } 60 | const record = vercelFetch.verification[0]; 61 | const recordText = `type: \`${record.type}\` 62 | domain: \`${record.domain}\` 63 | value: \`${record.value}\``; 64 | await respond( 65 | t("messages.domain.domainverify", { 66 | text: recordText, 67 | domain: arg, 68 | }) 69 | ); 70 | } else { 71 | await prisma.accounts.update({ 72 | where: { slackID: user.slackID }, 73 | data: { customDomain: arg }, 74 | }); 75 | await respond(t("messages.domain.domainset", { text: arg })); 76 | } 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/events/reactionAdded.js: -------------------------------------------------------------------------------- 1 | import { createUpdate, updateExists, updateExistsTS } from "../lib/updates.js"; 2 | import { getEmojiRecord, emojiExists } from "../lib/emojis.js"; 3 | import { getReactionRecord, reactBasedOnKeywords } from "../lib/reactions.js"; 4 | import { react, getMessage, postEphemeral } from "../lib/slack.js"; 5 | import { t } from "../lib/transcript.js"; 6 | import { getUserRecord } from "../lib/users.js"; 7 | import shipEasterEgg from "../lib/shipEasterEgg.js"; 8 | import { SEASON_EMOJI } from "../lib/seasons.js"; 9 | import prisma from "../lib/prisma.js"; 10 | import Bottleneck from "bottleneck"; 11 | const limiter = new Bottleneck({ maxConcurrent: 1 }); 12 | import channelKeywords from "../lib/channelKeywords.js"; 13 | import clubEmojis from "../lib/clubEmojis.js"; 14 | import { commands } from "../commands/commands.js"; 15 | import metrics from "../metrics.js"; 16 | 17 | export default async ({ event }) => { 18 | const { item, user, reaction, item_user } = event; 19 | const { channel, ts } = item; 20 | if (channel == "C0M8PUPU6" && ts == "1679405777.423309" && reaction == "boom") { 21 | return await shipEasterEgg({ event }); 22 | } 23 | if (reaction !== SEASON_EMOJI && user === "U015D6A36AG") return; 24 | if ( 25 | (await updateExistsTS(ts)) && 26 | (reaction === commands.scrappy || reaction === commands.scrappyParrotReaction) && 27 | channel !== process.env.CHANNEL 28 | ) 29 | return; 30 | const message = await getMessage(ts, channel); 31 | if ((await updateExistsTS(ts)) && reaction === commands.scrappyRetryReaction) { 32 | if (channelKeywords[channel]) 33 | await react("add", channel, ts, channelKeywords[channel]); 34 | await reactBasedOnKeywords(channel, message.text, ts); 35 | await react("remove", channel, ts, "beachball"); 36 | await react("add", channel, ts, SEASON_EMOJI); 37 | return; 38 | } 39 | // If someone reacted with a Scrappy emoji in a non-#scrapbook channel, then maybe upload it. 40 | if ( 41 | (reaction === commands.scrappy || reaction === commands.scrappyParrotReaction) && 42 | channel !== process.env.CHANNEL 43 | ) { 44 | if (item_user != user) { 45 | // If the reacter didn't post the original message, then show them a friendly message 46 | postEphemeral( 47 | channel, 48 | t("messages.errors.anywhere.op", { reaction }), 49 | user 50 | ); 51 | } else if (message) { 52 | 53 | let hasNoMediaFiles = !message.files || message.files.length == 0; 54 | const files = hasNoMediaFiles ? [] : message.files; 55 | 56 | // log reaction used to create the update 57 | metrics.increment(`referrer.reaction.${reaction}`, 1); 58 | 59 | const update = await createUpdate(files, channel, ts, user, message.text); 60 | 61 | message.reactions.forEach(async reaction => { 62 | if ( 63 | reaction.name === "scrappy" || 64 | reaction.name === "scrappy-retry" || 65 | reaction.name ===commands.scrappyParrotReaction 66 | ) return; 67 | await prisma.emojiReactions.create({ 68 | data: { 69 | updateId: update.id, 70 | emojiTypeName: reaction.name 71 | } 72 | }); 73 | }); 74 | } 75 | return; 76 | } 77 | if ( 78 | reaction === commands.scrappyRetryReaction && 79 | channel == process.env.CHANNEL && 80 | message && !message.thread_ts 81 | ) { 82 | if (!message.files || message.files.length == 0) { 83 | postEphemeral(channel, t("messages.errors.anywhere.files"), user); 84 | return; 85 | } 86 | 87 | metrics.increment(`referrer.reaction.${reaction}`, 1); 88 | await createUpdate(message.files, channel, ts, item_user, message.text); 89 | } 90 | limiter.schedule(async () => { 91 | const emojiRecord = await getEmojiRecord(reaction); 92 | const update = await prisma.updates.findFirst({ 93 | where: { 94 | messageTimestamp: parseFloat(ts), 95 | }, 96 | }); 97 | if (!update) return; 98 | const postExists = await updateExists(update.id); 99 | const reactionExists = await emojiExists(reaction, update.id); 100 | if (Object.keys(clubEmojis).includes(emojiRecord.name)) { 101 | await prisma.clubUpdate.create({ 102 | data: { 103 | update: { 104 | connect: { 105 | id: update.id 106 | }, 107 | }, 108 | club: { 109 | connect: { 110 | slug: clubEmojis[emojiRecord.name] 111 | }, 112 | } 113 | }, 114 | }); 115 | } 116 | if (!reactionExists) { 117 | // Post hasn't been reacted to yet at all, or it has been reacted to, but not with this emoji 118 | await prisma.emojiReactions.create({ 119 | data: { 120 | updateId: update.id, 121 | emojiTypeName: emojiRecord.name, 122 | }, 123 | }); 124 | } else if (postExists && reactionExists) { 125 | const userRecord = await getUserRecord(user).catch((err) => 126 | console.log("Cannot get user record", err) 127 | ); 128 | // Post has been reacted to with this emoji 129 | const reactionRecord = await getReactionRecord(reaction, update.id).catch( 130 | (err) => console.log("Cannot get reaction record", err) 131 | ); 132 | let usersReacted = reactionRecord.usersReacted; 133 | if (userRecord.id) { 134 | await usersReacted.push(userRecord.id); 135 | } 136 | await prisma.emojiReactions.update({ 137 | where: { id: reactionRecord.id }, 138 | data: { usersReacted: usersReacted }, 139 | }); 140 | } 141 | }); 142 | }; -------------------------------------------------------------------------------- /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 | console.log({ 41 | updateId: update.id, 42 | club: { 43 | slug: clubEmojis[emojiRecord.name] 44 | } 45 | }) 46 | await prisma.clubUpdate.deleteMany({ 47 | where: { 48 | updateId: update.id, 49 | club: { 50 | slug: clubEmojis[emojiRecord.name] 51 | } 52 | }, 53 | }); 54 | } 55 | } else { 56 | await prisma.emojiReactions.update({ 57 | where: { id: reactionRecord.id }, 58 | data: { usersReacted: updatedUsersReacted }, 59 | }); 60 | } 61 | } catch { 62 | return; 63 | } 64 | 65 | }); 66 | }; 67 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | // return if we got an unsuccessful response from Slack 35 | if (!info.ok) return; 36 | await prisma.accounts.update({ 37 | where: { slackID: user.id }, 38 | data: { 39 | timezoneOffset: info.user?.tz_offset, 40 | timezone: info.user?.tz.replace(`\\`, ""), 41 | avatar: user.profile.image_192, 42 | email: user.profile.fields.email, 43 | website: user.profile.fields["Xf5LNGS86L"]?.value || undefined, 44 | github: user.profile.fields["Xf0DMHFDQA"]?.value || undefined, 45 | }, 46 | }); 47 | } 48 | catch (e) { 49 | console.log(e); 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/lib/clubEmojis.js: -------------------------------------------------------------------------------- 1 | // Add your club! "emoji": "scrapbook_slug" 2 | 3 | 4 | export default { 5 | "lioncityhacks999999": "nlcs-singapore", 6 | "quest": "quests" 7 | } -------------------------------------------------------------------------------- /src/lib/emojiKeywords.js: -------------------------------------------------------------------------------- 1 | export default { 2 | yay: "yay", 3 | OSF: "osf", 4 | hooray: "tada", 5 | arrived: "package", 6 | bin: "rac_yap", 7 | raccoon: "rac_yap", 8 | draw: "pencil2", 9 | art: "art", 10 | paint: "art", 11 | wrote: "lower_left_fountain_pen", 12 | slack: "slack", 13 | pcb: "pcb", 14 | onboard: "pcb", 15 | circuit: "pcb", 16 | kicad: "pcb", 17 | easyeda: "pcb", 18 | figma: "figma", 19 | "3d print": "3d-printer", 20 | "3d printing": "3d-printer", 21 | "3d printer": "3d-printer", 22 | covid: "coronavirus", 23 | singapore: "singaporeparrot", 24 | canada: "canadaparrot", 25 | india: "indiaparrot", 26 | space: "rocket", 27 | sleep: "zzz", 28 | hardware: "hardware", 29 | roshan: "roshan", 30 | sampoder: "sam-1", 31 | "vs code": "vsc", 32 | vscode: "vsc", 33 | "woo hoo": "tada", 34 | celebrate: "tada", 35 | cooking: "pan_with_egg", 36 | cooked: "pan_with_egg", 37 | cook: "pan_with_egg", 38 | birthday: "birthday", 39 | bday: "birthday", 40 | pumpkin: "jack_o_lantern", 41 | fall: "fallen_leaf", 42 | thanksgiving: "turkey", 43 | christmas: "christmas_tree", 44 | santa: "santa", 45 | snow: "snowflake", 46 | snowing: "snowflake", 47 | snowman: "snowman", 48 | vercel: "vercel", 49 | sunrise: "sunrise_over_mountains", 50 | sunset: "city_sunset", 51 | google: "google", 52 | soccer: "soccer", 53 | football: "soccer", 54 | car: "car", 55 | driving: "car", 56 | bank: "bank-hackclub", 57 | "shopping list": "hardware", 58 | github: "github", 59 | twitter: "twitter", 60 | bot: "robot_face", 61 | robot: "robot_face", 62 | robotics: "robot_face", 63 | minecraft: "minecraft", 64 | game: "video_game", 65 | npm: "npm", 66 | solder: "hardware", 67 | soldering: "hardware", 68 | arduino: "hardware", 69 | instagram: "instagram", 70 | observable: "observable", 71 | js: "js", 72 | javascript: "js", 73 | reactjs: "react", 74 | python: "python", 75 | swift: "swift", 76 | xcode: "swift", 77 | "x code": "swift", 78 | swiftui: "swift", 79 | "swift ui": "swift", 80 | golang: "gopher", 81 | rust: "rustlang", 82 | deno: "deno", 83 | blender: "blender", 84 | salad: "green_salad", 85 | adobe: "adobe", 86 | photoshop: "photoshop", 87 | inktober: "lower_left_fountain_pen", 88 | storm: "thunder_cloud_and_rain", 89 | rain: "rain_cloud", 90 | dino: "sauropod", 91 | school: "school_satchel", 92 | backpack: "school_satchel", 93 | linux: "linux", 94 | hacktober: "hacktoberfest", 95 | hacktoberfest: "hacktoberfest", 96 | exams: "books", 97 | exam: "books", 98 | studying: "books", 99 | studied: "books", 100 | study: "books", 101 | react: "react", 102 | apple: "appleinc", 103 | cat: "cat", 104 | dog: "dog", 105 | code: "goose-honk-technologist", 106 | hack: "goose-honk-technologist", 107 | autumn: "hackautumn", 108 | "Happy Birthday Zach": "zachday-2020", 109 | debate: "hackdebate", 110 | "next.js": "nextjs", 111 | nextjs: "nextjs", 112 | movie: "film_projector", 113 | halloween: "jack_o_lantern", 114 | pizza: "pizza", 115 | scrappy: "scrappy", 116 | cycle: "bike", 117 | bike: "bike", 118 | "Big Sur": "bs", 119 | zoom: "zoom", 120 | ship: "ship", 121 | macbook: "macbook-air-space-gray-screen", 122 | guitar: "guitar", 123 | complain: "old-man-yells-at-cloud", 124 | fight: "old-man-yells-at-cloud", 125 | cricket: "cricket_bat_and_ball", 126 | vim: "vim", 127 | docker: "docker", 128 | cake: "cake", 129 | notion: "notion", 130 | fedora: "fedoralinux", 131 | replit: "replit", 132 | mask: "mask", 133 | leap: "leap", 134 | discord: "discord", 135 | "/z": "zoom", 136 | postgres: "postgres", 137 | gatsby: "gatsby", 138 | prisma: "prisma", 139 | graphql: "graphql", 140 | "product hunt": "producthunt", 141 | java: "java_duke", 142 | repl: "replit", 143 | "repl.it": "replit", 144 | replit: "replit", 145 | "rick roll": "smolrick", 146 | BrainDUMP: "braindump", 147 | firefox: "firefoxlogo", 148 | vivaldi: "vivaldi", 149 | "ABCO-1": "abcout", 150 | "nix": "nix", 151 | "nixos": "nix", 152 | "nixpkgs": "nix", 153 | "typescript": "typescript", 154 | "ts": "typescript", 155 | "zephyr": "train", 156 | "summer": "sunny", 157 | "plane": "airplane", 158 | "train": "train", 159 | "bus": "bus", 160 | "bug": "bug", 161 | "debug": "dino-debugging", 162 | "debugging": "dino-debugging", 163 | "awesome": "awesome", 164 | "graph": "chart_with_upwards_trend", 165 | "chart": "chart_with_upwards_trend", 166 | "boba": "boba-parrot", 167 | "bubble tea": "boba-parrot", 168 | spotify: "spotify", 169 | repair: "hammer_and_wrench", 170 | cow: "cow", 171 | doge: "doge", 172 | shibe: "doge", 173 | dogecoin: "dogecoin", 174 | blockchain: "chains", 175 | ticket: "admission_tickets", 176 | homework: "memo", 177 | hw: "memo", 178 | piano: "musical_keyboard", 179 | orpheus: "orpheus", 180 | chess: "chess_pawn", 181 | pr: "pr", 182 | "pull request": "pr", 183 | bread: "bank-hackclub", 184 | nft: "nft", 185 | hns: "hns", 186 | wahoo: "wahoo-fish", 187 | aoc: "aoc", 188 | advent: "aoc", 189 | svelte: "svelte", 190 | cold: "snowflake", 191 | tailwind: "tailwind", 192 | tailwindcss: "tailwind", 193 | c: "c", 194 | squaresupply: "squaresupply", 195 | gamelab: "gamelab", 196 | "annoying site": "annoyingsite", 197 | redwood: "redwoodjs", 198 | redwoodjs: "redwoodjs", 199 | homebrew: "homebrew-mac", 200 | stickers: "stickers", 201 | club: "hackclub", 202 | think: "thinking", 203 | thinking: "thinking", 204 | cool: "cooll-dino", 205 | science: "scientist", 206 | research: "microscope", 207 | biology: "microbe", 208 | brain: "brain", 209 | "science fiction": "flying_saucer", 210 | "sci-fi": "alien", 211 | mexico: "mexicoparrot", 212 | food: "shallow_pan_of_food", 213 | sad: "sadge", 214 | galaxy: "milky_way", 215 | plant: "potted_plant", 216 | plants: "potted_plant", 217 | picture: "camera", 218 | pictures: "camera", 219 | photography: "camera_with_flash", 220 | assemble: "assemble", 221 | sprig: "sprig-dino", 222 | laser: "monkey-laser", 223 | music: "music", 224 | "<#C045S4393CY>": "10daysinpublic", 225 | "10daysinpublic": "10daysinpublic", 226 | "hardware party": "winter-hardware-wonderland", 227 | "hardware wonderland": "winter-hardware-wonderland", 228 | "hardware-party": "winter-hardware-wonderland", 229 | "days of making": "winter-hardware-wonderland", 230 | "winter hardware": "winter-hardware-wonderland", 231 | winter: "winter-hardware-wonderland", 232 | wonderland: "winter-hardware-wonderland", 233 | whw: "winter-hardware-wonderland", 234 | ipfs: "ipfs", 235 | "the orpheus show": "tos-icon", 236 | "orpheus show": "tos-icon", 237 | "the orpheus podcast": "tos-icon", 238 | "orpheus podcast": "tos-icon", 239 | podcast: "studio_microphone", 240 | quest: "quests", 241 | puzzmo: "puzzmo", 242 | "purple bubble": "purplebubble", 243 | purplebubble: "purplebubble", 244 | summit: "leaders-summit", 245 | "summit vision": "summit-vision", 246 | "apple vision": "summit-vision", 247 | nest: "nest" 248 | }; 249 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | }; -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/lib/streaks.js: -------------------------------------------------------------------------------- 1 | import prisma from "./prisma.js"; 2 | import { getUserRecord } from "./users.js"; 3 | import { getDayFromISOString, getNow } from "./utils.js"; 4 | import { getRandomWebringPost } from "./webring.js"; 5 | import { postEphemeral, react, reply } from "./slack.js"; 6 | import { t } from "./transcript.js"; 7 | import { SEASON_EMOJI } from "./seasons.js"; 8 | import channelKeywords from "./channelKeywords.js"; 9 | import { reactBasedOnKeywords } from "./reactions.js"; 10 | import { setStatus } from "./profiles.js"; 11 | import metrics from "../metrics.js"; 12 | 13 | export const shouldUpdateStreak = async (userId, increment) => { 14 | const userRecord = await getUserRecord(userId); 15 | const latestUpdates = await prisma.updates.findMany({ 16 | orderBy: { 17 | postTime: "desc", 18 | }, 19 | where: { 20 | accountsSlackID: userRecord.slackID, 21 | }, 22 | }); 23 | // increment = true | usually when they added a new update in slack 24 | // increment = false | usually when an update is deleted from slack 25 | /* 26 | We increment the user's streak if their last post was not made on the same day as today. 27 | 28 | The post we consider to be their last to increment their streak is the the most recent post they made before today. 29 | The time difference between their last and today should tell us whether or not to increment their streak. 30 | 31 | We are going to decrement their streak if the post the post they made today had incremented their streak. 32 | */ 33 | const createdTime = increment 34 | ? latestUpdates[1]?.postTime 35 | : latestUpdates[0]?.postTime; 36 | const today = getDayFromISOString(getNow(userRecord.timezone)); 37 | const createdDay = getDayFromISOString(createdTime); 38 | return ( 39 | today != createdDay || (increment ? !latestUpdates[1] : !latestUpdates[0]) 40 | ); 41 | }; 42 | 43 | export const streaksToggledOff = async (user) => { 44 | const userRecord = await getUserRecord(user); 45 | return userRecord.streaksToggledOff; 46 | }; 47 | 48 | export const incrementStreakCount = (userId, channel, message, ts) => 49 | new Promise(async (resolve) => { 50 | const userRecord = await getUserRecord(userId); 51 | const shouldUpdate = await shouldUpdateStreak(userId, true); 52 | const randomWebringPost = await getRandomWebringPost(userId); 53 | let updatedMaxStreakCount; 54 | const updatedStreakCount = userRecord.streakCount + 1; 55 | const scrapbookLink = 56 | "https://scrapbook.hackclub.com/" + userRecord.username; 57 | await react("remove", channel, ts, "beachball"); // remove beachball react 58 | await react("add", channel, ts, SEASON_EMOJI); 59 | if (typeof channelKeywords[channel] !== "undefined") 60 | await react("add", channel, ts, channelKeywords[channel]); 61 | await reactBasedOnKeywords(channel, message, ts); 62 | if (shouldUpdate) { 63 | if (userRecord.newMember && updatedStreakCount > 1) { 64 | await prisma.accounts.update({ 65 | where: { 66 | slackID: userRecord.slackID, 67 | }, 68 | data: { 69 | newMember: false, 70 | }, 71 | }); 72 | } 73 | if ( 74 | userRecord.maxStreaks < updatedStreakCount || 75 | !userRecord.maxStreaks 76 | ) { 77 | updatedMaxStreakCount = updatedStreakCount; 78 | } else { 79 | updatedMaxStreakCount = userRecord.maxStreaks; 80 | } 81 | await prisma.accounts.update({ 82 | where: { 83 | slackID: userRecord.slackID, 84 | }, 85 | data: { 86 | maxStreaks: updatedMaxStreakCount, 87 | streakCount: updatedStreakCount, 88 | }, 89 | }); 90 | metrics.increment("streak", 1); 91 | await displayStreaks(userId, updatedStreakCount); 92 | if (userRecord.newMember && updatedStreakCount === 1) { 93 | postEphemeral(channel, t("messages.streak.newstreak"), userId); 94 | } 95 | } 96 | const replyMessage = await getReplyMessage( 97 | userId, 98 | userRecord.username, 99 | updatedStreakCount 100 | ); 101 | 102 | await reply( 103 | channel, 104 | ts, 105 | shouldUpdate 106 | ? replyMessage 107 | : t("messages.streak.nostreak", { scrapbookLink }) 108 | ); 109 | if (randomWebringPost.post) { 110 | await reply( 111 | channel, 112 | ts, 113 | t("messages.webring.random", { 114 | randomWebringPost: randomWebringPost.post, 115 | }), 116 | true 117 | ); 118 | } else if (!randomWebringPost.post && randomWebringPost.nonexistence) { 119 | await reply( 120 | channel, 121 | ts, 122 | t("messages.webring.nonexistence", { 123 | scrapbookUrl: randomWebringPost.scrapbookUrl, 124 | }) 125 | ); 126 | } 127 | resolve(); 128 | }); 129 | 130 | export const getReplyMessage = async (user, username, day) => { 131 | const toggledOff = await streaksToggledOff(user); 132 | const scrapbookLink = `https://scrapbook.hackclub.com/${username}`; 133 | let streakNumber = day <= 7 ? day : "7+"; 134 | if (toggledOff) streakNumber = "7+"; 135 | if (day <= 3 && !toggledOff) { 136 | return t("messages.streak.oldmember." + streakNumber, { 137 | scrapbookLink, 138 | user, 139 | }); 140 | } 141 | return t("messages.streak." + streakNumber, { scrapbookLink, user }); 142 | }; 143 | 144 | export const displayStreaks = async (userId, streakCount) => { 145 | const userRecord = await getUserRecord(userId); 146 | if (!userRecord.streaksToggledOff) { 147 | if (streakCount == 0 || !userRecord.displayStreak) { 148 | setStatus(userId, "", ""); 149 | } else { 150 | const statusText = "day streak in #scrapbook"; 151 | const statusEmoji = `:som-${streakCount > 7 ? "7+" : streakCount}:`; 152 | setStatus(userId, statusText, statusEmoji); 153 | } 154 | } 155 | }; 156 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/lib/transcript.yml: -------------------------------------------------------------------------------- 1 | ping: pong 2 | error: | 3 | ERROR! ERROR! :skull: 4 | ``` 5 | <@${this.e}> 6 | ``` 7 | messages: 8 | help: | 9 | Hello!!!!!!!!!!!!!! I'm a bot that helps you build your . Here are all the commands you can run: 10 | 11 | - \`/scrappy-togglestreaks\`: toggles your streak count on/off in your status 12 | - \`/scrappy-togglestreaks all\`: opts out of streaks completely 13 | - \`/scrappy-setcss\`: adds a custom CSS file to your scrapbook profile. 14 | - \`/scrappy-setdomain\`: links a custom domain to your scrapbook profile, e.g. https://zachlatta.com 15 | - \`/scrappy-setusername\`: change your profile username 16 | - \`/scrappy-setaudio\`: links an audio file to your Scrapbook. 17 | - \`/scrappy-setwebhook\`: create a Scrappy Webhook we will make a blank fetch request to this URL every time you post 18 | - \`/scrappy-webring\`: adds or removes someone to your webring 19 | 20 | BTW if you want to delete or update a post you can do so by simply editing or deleting your Slack message. 21 | 22 | Learn all about Scrapbook at https://scrapbook.hackclub.com/about 23 | PS: for scrappy_dev, prepend the commands with \`test-\` e.g \`/test-scrappy\` 24 | forget: | 25 | Forgetting about all recorded scraps from.... 26 | uhhhh.... 27 | who are you? 28 | join: 29 | scrapbook: | 30 | hey heeEEEey there <@${this.user}>, I hope you brought your gluestix and silly-scissors cuz this is the world of *SCRAPBOOKS*! To begin a magical journey, 31 | > :star: *POST A PHOTO TO THIS CHANNEL* :camera_with_flash: > :slack: > :mindblown: 32 | (It could be anything. A project you're starting. An art project you made. Something you're reading. ) 33 | css: | 34 | WooOOAAah hey! Welcome to <#C015M6U6JKU>! This is the place where people go to make their profiles look cool!!! 35 | 36 | To add a custom style to your scrapbook, find a link to a CSS file and post it in this channel. Feel free to use any of the others posted in the channel, or you can create your own at or . 37 | streak: 38 | 1: | 39 | Groovy! Congratulations <@${this.user}> on your first post in <#${process.env.CHANNEL}>. Your scrapbook is live at <${this.scrapbookLink}?welcome=true|${this.scrapbookLink}>! 40 | 41 | *Next step:* Type \`/scrappy displaystreaks\` to display your streak in your status! 42 | 2: | 43 | Day two already! It's felt like 100! Keep it up! I'll add that to your scrapbook at ${this.scrapbookLink}. 44 | 45 | Did you know you can add custom CSS to your scrapbook? Join <#C015M6U6JKU> to learn how! 46 | 3: | 47 | I'll scarf that down and add it to your scrapbook for day 3. You're moving up the ranks! ${this.scrapbookLink} 48 | 49 | Did you know you can add a custom domain to your scrapbook? Check out <@USNPNJXNX>\'s: https://scrapbook.sampoder.com 50 | Add your own domain by typing \`/scrappy setdomain\`. 51 | 4: Hello, hello? Uh, I wanted to let you know that that's day 4 completed!!! ${this.scrapbookLink} 52 | 5: 5 whole days of streaks! That's like one more than yesterday!!! ${this.scrapbookLink} 53 | 6: 6 days! You've almost made it to a week!!! Don't stop now!!!! A week is 7 days by the way. ${this.scrapbookLink} 54 | 7: | 55 | A WHOLE WEEK OF STREAKS?!?! https://www.youtube.com/watch?v=i0dmSIAzWeQ 56 | 57 | ${this.scrapbookLink} 58 | "7+": 59 | - Yahoo! ${this.scrapbookLink} 60 | - ":yay: ${this.scrapbookLink}" 61 | - ":dance::dance1::dance2: ${this.scrapbookLink}" 62 | - ":parrotwave1::parrotwave2::parrotwave3::parrotwave4::parrotwave5::parrotwave6::parrotwave7: ${this.scrapbookLink}" 63 | - | 64 | Thanks for the memory on ${this.scrapbookLink}! 65 | —Your future self 66 | - | 67 | :congaparrot::congaparrot::congaparrot::congaparrot: 68 | ${this.scrapbookLink} 69 | :congaparrot::congaparrot::congaparrot::congaparrot: 70 | nostreak: | 71 | :yay::yay::yay: Your update is live on your scrapbook! :yay::yay::yay: ${this.scrapbookLink} 72 | 73 | (PS: you've already posted today, so I didn't increment your streak. But great job!) 74 | newstreak: ':yay: You just started a streak! Keep posting every day to continue your streak. In the meantime, type \`/scrappy help\` to see what else you can do!' 75 | oldmember: 76 | 1: | 77 | Far out! You've started a new streak! Welcome back to Scrapbook :yay: 78 | 79 | ${this.scrapbookLink} 80 | 2: Right on for day two! I'll add that to your scrapbook on ${this.scrapbookLink}. 81 | 3: I'll scarf that down and add it to your scrapbook for day 3. You're getting up there! ${this.scrapbookLink} 82 | toggle: 83 | status: 84 | visible: Your streak count is now visible. 85 | invisible: Your streak count is now invisible. 86 | all: 87 | optin: Nice! I\'ll will start counting your streaks. from now on, they\'ll be in your status and on your scrapbook. 88 | optout: Cool beans, I\'ll stop counting your streaks. 89 | delete: | 90 | WoooOOOAAAh there! You can only share images, audio files, videos or links with previews in <#${process.env.CHANNEL}>! I've deleted your message: 91 | 92 | ${this.text} 93 | 94 | But feel free to repost it with an image or video and I'll let it be :eye::lips::eye: 95 | webring: 96 | add: <@${this.webringUser}> has been added to your webring! Now their latest post will occasionally appear under your scrapbook updates. See them live on <${this.scrapbookLink}|your scrapbook>! 97 | remove: <@${this.webringUser}> has been removed from <${this.scrapbookLink}|your webring>. 98 | noargs: Webrings are a way to link to your friends' scrapbooks in your scrapbook, and to receive scrapbook updates from your friends when you post. To add or remove someone to your webring, type \`/scrappy webring @username\` 99 | toolong: Wowwwwzaaa! I have stage fright and that is a few too many people, you can only add 8 people to your webring.... I'm being serious... 100 | yourself: | 101 | Hey! :watchingyou: Add other people to your webring, not yourself!!!! 102 | invaliduser: | 103 | I couldn't find that user :thunj: Make sure you're tagging the user, and make sure they're a Scrapbook user. 104 | nonexistence: | 105 | ALSO BTW i tried to get the latest post from someone in your webring, but they've never posted to Scrapbook so there's no post to get!!!! you may wanna consider removing them from your webring. this is the someone in question: ${this.scrapbookUrl} 106 | random: 107 | - Put on your clout goggles for a new post from your webring! ${this.randomWebringPost} 108 | - Bounce on over to this new post from your webring! ${this.randomWebringPost} 109 | - "Quick!!!!!!! Go check out this new post from your webring before it blows away: ${this.randomWebringPost}" 110 | - "Here, have a new post from your webring to munch on: ${this.randomWebringPost}" 111 | - "Check it out!!! Go CHOMP DOWN on this new post from your webring: ${this.randomWebringPost}" 112 | css: 113 | noargs: To link a custom CSS file, type \`/scrappy-setcss\` followed by a link to a CSS file online. Try this one, which sets your background to hot pink! \`/scrappy setcss https://gist.github.com/MatthewStanciu/a0c10a8d4264b737fcc3c1724591c232\` 114 | removed: Your CSS file has been removed from your profile. If you would like to re-add it, type \`/scrappy-setcss \`. 115 | set: '<${this.url}|Your CSS file> has been linked to your profile! Check it out: \`https://scrapbook.hackclub.com/${this.username}\`' 116 | nocss: You linked a Gist, but there isn’t a .css file on your Gist. Try again with the raw URL to the CSS. 117 | broadcast: Anyone can now click the button below to add this CSS style to their scrapbook! <${this.scrapbookLink}|See it live on this profile>. 118 | audio: 119 | noargs: To link a custom sound file to your scrapbook, type \`scrappy-setaudio\` followed by a sound file url. 120 | removed: I've wiped your previous audio file. You can re-add it with "/scrappy-setaudio ${this.previous}" 121 | set: Woohoo! Check out your new tunes at ${this.url}. 122 | notaccepted: Your file doesn't contain an accepted audio filetype. Try reuploading a file that ends in mp3, aiff, wav, or m4a. 123 | emoji: 124 | accepted: 125 | - musical_note 126 | - musical_score 127 | - guitar 128 | - musical_keyboard 129 | username: 130 | noargs: To change your scrapbook username, type \`/scrappy-setusername\` followed by your desired username. 131 | set: Woohoo! Check out your new username at ${this.url}. 132 | time: Sorry, but you can only update your username every 24 hours. 133 | short: Seriously?! -_- Your username needs to be AT LEAST 3 characters. 134 | exists: Looks some one already has this username, what are you trying to pull? 135 | webhook: 136 | noargs: To add a Scrappy Webhook, type \`/scrappy-setwebhook\` followed by your webhook URL. 137 | set: | 138 | Ping pong! We're on the road to automation, now when ever you feed me a post I'll ping that URL :sparkles: 139 | domain: 140 | noargs: To link a custom domain to your scrapbook, type \`/scrappy-setdomain\` followed by the domain or subdomain you want to link. e.g. \`/scrappy-setdomain zachlatta.com\`. Then, create a CNAME record in your DNS provider for your domain and point it to \`cname.vercel-dns.com\`. 141 | overlimit: Couldn't set your domain. Only 50 custom domains can be added to a project, and 50 people have already added their custom domains. . 142 | domainerror: Couldn't set your domain \`${this.text}\`. Here's the error - \`${this.error}\` 143 | domainverifyerror: Couldn't verify your domain \`${this.text}\`. Here's the error - \`${this.error}\` 144 | domaindoesnotexist: | 145 | Couldn't verify your domain \`${this.text}\`. Here's the error - \`${this.error}\` 146 | 147 | Try running \`/scrappy-setdomain ${this.text}\` first. 148 | domainverify: Couldn't set your domain \`${this.text}\`. You can't add a domain if it's already set to another Vercel project. Try again with a different domain. 149 | domainset: | 150 | Custom domain \`${this.text}\` set! 151 | 152 | *Your next steps*: create a CNAME record in your DNS provider for your domain and point it to \`cname.vercel-dns.com\`. 153 | domainverified: Your custom domain \`${this.text}\` has been verified and set! 154 | debug: Error - \`${this.error}\` 155 | open: 156 | invaliduser: Hmmmmmmmm THAT'S NOT A REAL USER!!!! What are you trying to pull here???? 157 | userArg: <@${this.userArg}>'s scrapbook is ${this.scrapbookLink} 158 | self: Your scrapbook profile is ${this.scrapbookLink} 159 | steal: 160 | invaliduser: Hmmmmmmmm THAT'S NOT A REAL USER!!!! What are you trying to pull here???? I don't like it :( 161 | done: <@${this.victimUser}>'s CSS is now live on <${this.scrapbookLink}|your profile>! 162 | noargs: This command sets someone's CSS file to your Scrapbook profile. To use it, type \`/scrappy-stealcss @user\` 163 | empty: <@${this.victimUser}>'s custom CSS is empty, try another person. 164 | profileUpdate: Your profile details have been updated! ${this.scrapbookLink} 165 | assetReady: <@${this.user}> Your video has been processed and is live on your scrapbook! 166 | errors: 167 | filetype: WHAT THE HECK this filetype isn't supported. Make sure you\'re uploading a photo or video and try again. 168 | heic: WHAT THE HEIC!!!! the HEIC filetype isn't supported :(((((((( try and reuploading. 169 | bigvideo: | 170 | Woweee!!! What's that??? That file is so big...I don't have enough memory for it... 171 | 172 | Since I can't download this file, I'm going to make it "public" via Slack's API and upload it to your scrapbook that way. The update will be live on your scrapbook in 30 seconds, but it'll take anywhere between 30 seconds and a few minutes for it to be viewable. I'll notify you when it's ready. 173 | 174 | (PS: You may receive a notification from Slackbot—go ahead and ignore it.) 175 | imagefail: | 176 | Hmmmmm :thonkspin: I couldn't fetch your image for some reason. I'm going to try to upload your image another way. You may receive a DM from Slackbot—go ahead and ignore it. If you don't see your image live on your Scrapbook, please let <@U4QAK9SRW> know. 177 | zach: Zaaaaaaaaaaaaaaaaaaaach!!!!!!!!!!!!!!! I just tried to update your streak, but <@U4QAK9SRW> reinstalled me with his Slack tokens at some point, so it didn\'t work. When you get a chance, reinstall me with your tokens and update the env variables in the Heroku deployment. Then toggle \`/scrappy displaystreaks\` off and on again. 178 | promise: | 179 | NOOOOOOOOOOOOOOOOO something went wrong!!!! <@U4QAK9SRW> fix it please 180 | ``` 181 | ${this.err} 182 | ``` 183 | anywhere: 184 | op: | 185 | Sorry, but only the original message poster can react with :${this.reaction}: to upload to Scrapbook. 186 | files: | 187 | Hmm... :thonk: I don't detect any files in this message. Please attach a video or an image file. 188 | mentionKeyword: 189 | - <@${this.user}> no u 190 | - <@${this.user}> :() 191 | - <@${this.user}> aosidjghlkj;LKasASDLKGJ AASDFLKJ LAKJSHDF ;OIHA GKLJRWHG AJSDGH 192 | - <@${this.user}> no u ${'!'.repeat(Math.floor(Math.random() * 7))} 193 | - <@${this.user}> you are SO MEAN OMG. i am going to delete your account 194 | - take it back <@${this.user}>, or i will ping staff and ask them to ban you from the slack 195 | - oh yeah <@${this.user}>?? actually i think the same abotu you're mom. pwned 196 | - <@${this.user}> BULLLYYY :(((( 197 | - <@${this.user}> https://cloud-r8zkxq6k0.vercel.app/image.png 198 | - <@${this.user}> 01110011 01100011 01110010 01100101 01110111 00100000 01111001 01101111 01110101 199 | - here <@${this.user}>, eat some dog food and rethink your actions 200 | - <@${this.user}> i don't think thats very nice!!!!!!!!!!!!!!! 201 | - <@${this.user}> OMG SHUT YOUR MOUTH I DO SO MUCH FOR YOU AND THIS HOW YOU TREAT MEJKMMEMEEEEE?????!!!?!? 202 | - <@${this.user}> OMG Lemme tell zach to ban you. 203 | - <@${this.user}> GET LOST OR I WILL RESET YOUR STREAK 204 | - <@${this.user}> Why did you do this to me! You traitor :( 205 | - <@${this.user}> Gimme your address, i wanna ship you some trash 206 | - <@${this.user}> imma tell my creator, they're an adminnnnn 207 | - <@${this.user}> haha todays opposite day so no u MWHAHAHAHAHAH 208 | - <@${this.user}> u wanna fight? im gonna fight hyou. stinky 209 | - <@${this.user}> help.\n<@${this.user}> i'm stuck.\n<@${this.user}> let me out.\n<@${this.user}> i'm alive. 210 | - Salutations <@${this.user}>, banker just transferred all of your GP to me! I'll be enjoying my new Hack Club sticker box, thank you very much. 211 | - You think you can hurt me, <@${this.user}>? You can't <3. 212 | mention: 213 | - this message can't stop me because i can't read! 214 | - WEEEEEEEEEEEEEEEEEEE 215 | - (gobbling down trash) what???????????????? i am busy 216 | - asdfl kshfd kjs aighsdu AIU SYDGIUAYSD Y76R42 DS 723R DSFJKS 287RI7SD 217 | - cGxlYXNlIGhlbHAgbWU= 218 | - you mentioned me but i dropped out of elementary school because i wanted to eat trash so i can't read your message 219 | - hiiiiiiiiiii hELLLOOOOOOOOOOOO 220 | - I love you 3< 221 | - but like why though 222 | - huh? 223 | - not funny. didn't laugh 224 | - You are the best 225 | - no. 3> 226 | - 01100111 01101111 00100000 01100001 01110111 01100001 01111001 227 | - leave me alone I'm trying to finish eating the rest of my trash. 228 | - i'm buzzy trying to keep track of all these scrapbooksasdjlasaaousao 229 | - 3 looks like a sideways butt haha 230 | - garbador i chooooseeee youuuuuuuuuu 231 | - cAn yOu lEt mE eAt mY tRaSh iN pEaCe?!÷™¡™¡˙ø∆b 232 | - ļ̴̹̪̳͍̦̹̣̖̺̺͔͖̽̒̄̏̀e̴̢̯̗̱̳͇̦̩̤͚̟̎́̇͋̉̔͆͌͐̒̅̀̌̏ẗ̴̛̩̎͂̾̒̓̏͂̾̒ ̷̧̛͎̞̮͍̫͛̍̐̒̾̈́̿ṃ̸̓̋̐̍̍͠ȩ̴̙̼͓̺̌͐̔͊̓̆͆̀͝͝ ̶͙̟͎͛̀͑͂̕̚͘͜ỡ̵̗͚͍̞͇̲̟̭̬͉͉̌̒̆̊͑̋͛̕̕͜ͅṷ̴̧̡͎̬̈́̆͗̑̀͒̑͐͒̎̐̈́͝͝ṭ̷̢̙̮̭͎̘̥̩̳̟̖̞͆̈̇͑̇̂͛͛̆́͜͜ 233 | - So guys, in a way, people are like trash cans. When you stomp on them, their mouth opens up. *stomp* 234 | - Hiya, I hope you're having a great day so far, <@${this.user}>! I need to go back to eating trash, but here's a virtual hugQ34978UFAIHSAI;DFGHSLHAS ;LKSADFG ;LOKAOISGPHOIUASG IAYUWEYAFSZVAG;EOUROIU;AHSAUGPI;. 235 | - System.out.println("Go away"); 236 | - Ok fine I'll give you my Bitcoin wallet.\n\nThe public key is \`1ATX8RXjVigphrYUxLJRziLstSdCan3Nig\`\n\nThe private key is \`L3wPf8yeQmrMSoVk1fY7w1qa5K79uaAxgGcB1keYyTYsn7TwiVi4\` 237 | - no 238 | - yea 239 | - ya 240 | - yes 241 | startup: 242 | trashType: 243 | - type ${1 + Math.floor(Math.random() * 7)} recyclables 244 | - polyethylene terephthalate 245 | - high-density polyethylene 246 | - polyvinyl chloride 247 | - poop 248 | - low-density polyethylene 249 | - polypropylene 250 | - polystyrene 251 | - the scraps left on your plate 252 | - non fungible tokens 253 | eats: 254 | - eats 255 | - consumes 256 | - devours 257 | - wolfs down 258 | - scarfs down 259 | - swallows 260 | - slurps 261 | - gobbles 262 | - gulps down 263 | - ingests 264 | - guzzles 265 | improvements: 266 | - Bug fixes and improvements 267 | - Bug fixes + improvements 268 | - More bug fixes and improvements 269 | - More bugs & improvements 270 | - More bugs & less fixes 271 | - Bug fixes and imps 272 | - Bugs and imps 273 | randomChange: 274 | - ${1 + Math.ceil(Math.random() * 5)}x as many beatles 275 | - ${1 + Math.ceil(Math.random() * 5)}x as many gears 276 | - ${1 + Math.ceil(Math.random() * 5)}x as many levers 277 | - ${1 + Math.ceil(Math.random() * 5)}x more flavors available 278 | - Increased scrappy's self esteem by ${1 + Math.ceil(Math.random() * 5)}x 279 | - Decreased scrappy's self esteem by ${1 + Math.ceil(Math.random() * 5)}x 280 | - converted all bits -> bytes 281 | - converted all bytes -> bits 282 | - converted all 1s -> 0s 283 | - A startup message! 284 | scrappyChange: 285 | - now scrappy ${this.t('startup.eats')} trash 286 | - scrappy can now digest ${this.t('startup.trashType')} 287 | message: | 288 | We continuously update our app to improve your experience. This update includes: 289 | - ${this.t('startup.improvements')} 290 | - ${this.t('startup.randomChange')} 291 | - ${this.t('startup.scrappyChange')} 292 | - ${this.latestCommitMsg} 293 | -------------------------------------------------------------------------------- /src/lib/updates.js: -------------------------------------------------------------------------------- 1 | import prisma from "./prisma.js"; 2 | import { react, reply, postEphemeral } from "./slack.js"; 3 | import { getPublicFileUrl } from "./files.js"; 4 | import { t } from "./transcript.js"; 5 | import { getUserRecord } from "./users.js"; 6 | import { formatText, extractOgUrl, getAndUploadOgImage, getUrls, getPageContent } from "./utils.js"; 7 | import { incrementStreakCount } from "./streaks.js"; 8 | import { app } from "../app.js"; 9 | import metrics from "../metrics.js"; 10 | import { config } from "dotenv"; 11 | import Airtable from "airtable"; 12 | 13 | // load environment variables 14 | config() 15 | 16 | // initialize the airtable base 17 | const base = new Airtable({ 18 | apiKey: process.env.PLUGINS_AIRTABLE_API_KEY, 19 | }).base(process.env.PLUGINS_AIRTABLE_BASE_ID); 20 | 21 | 22 | 23 | export const createUpdate = async (files = [], channel, ts, user, text) => { 24 | let attachments = []; 25 | let videos = []; 26 | let videoPlaybackIds = []; 27 | 28 | const channelInfo = await app.client.conversations.info({ 29 | token: process.env.SLACK_BOT_TOKEN, 30 | channel 31 | }); 32 | 33 | if (channelInfo.ok) { 34 | let channelName = channelInfo.channel.name_normalized; 35 | metrics.increment(`referrer.channel.${channelName}`, 1); 36 | } else { 37 | metrics.increment("errors.get_channel_name", 1); 38 | console.error(channelInfo.error); 39 | } 40 | 41 | const upload = await Promise.all([ 42 | react("add", channel, ts, "beachball"), 43 | ...files.map(async (file) => { 44 | const publicUrl = await getPublicFileUrl(file.url_private, channel, user); 45 | if (!publicUrl) { 46 | await postEphemeral(channel, t("messages.errors.filetype"), user, ts); 47 | return; 48 | } 49 | attachments.push(publicUrl.url); 50 | if (publicUrl.muxId) { 51 | videos.push(publicUrl.muxId); 52 | videoPlaybackIds.push(publicUrl.muxPlaybackId); 53 | } 54 | }), 55 | ]) 56 | 57 | // if there are no attachments, attempt to get from the first link having an og image 58 | if (!text) return; 59 | const urls = getUrls(text); 60 | if (urls && urls.length > 0) { 61 | for (const url of urls) { 62 | try { 63 | const pageContent = await getPageContent(url); 64 | const ogUrls = extractOgUrl(pageContent); 65 | 66 | if (!ogUrls) continue; 67 | 68 | let imageUri = await getAndUploadOgImage(ogUrls); 69 | if (imageUri) { 70 | attachments.push(imageUri); 71 | break; 72 | } 73 | } catch (error) { 74 | console.error(`Error processing URL ${url}:`, error); 75 | continue; 76 | } 77 | } 78 | } 79 | 80 | if ((attachments.length + videos.length) === 0) { 81 | await Promise.all([ 82 | react("remove", channel, ts, "beachball"), 83 | react("add", channel, ts, "x"), 84 | // delete message if no media files 85 | app.client.chat.delete({ 86 | token: process.env.SLACK_USER_TOKEN, 87 | channel, 88 | ts 89 | }), 90 | // notify user they need to include an image, video or link with preview 91 | postEphemeral(channel, t("messages.delete", { text }), user) 92 | ]); 93 | metrics.increment("errors.file_upload", 1); 94 | return "error"; 95 | } 96 | 97 | let userRecord = await getUserRecord(user); 98 | 99 | const date = new Date().toLocaleString("en-US", { 100 | timeZone: userRecord.timezone, 101 | }); 102 | 103 | const convertedDate = new Date(date).toISOString(); 104 | const messageText = await formatText(text); 105 | 106 | const updateInfo = { 107 | messageText, 108 | postTime: ts, 109 | attachments, 110 | user: { 111 | slackID: userRecord.slackID, 112 | name: userRecord.slack.profile.display_name 113 | }, 114 | channel 115 | }; 116 | 117 | const update = await prisma.updates.create({ 118 | data: { 119 | accountsID: userRecord.id, 120 | accountsSlackID: userRecord.slackID, 121 | postTime: convertedDate, 122 | messageTimestamp: parseFloat(ts), 123 | text: messageText, 124 | attachments: attachments, 125 | muxAssetIDs: videos, 126 | muxPlaybackIDs: videoPlaybackIds, 127 | isLargeVideo: attachments.some( 128 | (attachment) => attachment.url === "https://i.imgur.com/UkXMexG.mp4" 129 | ), 130 | channel: channel, 131 | }, 132 | }); 133 | 134 | metrics.increment("new_post", 1); 135 | await incrementStreakCount(user, channel, messageText, ts); 136 | 137 | // send a copy of the updates to the subcribers 138 | base("Update Listeners").select({ 139 | maxRecords: 100, 140 | view: "Grid view", 141 | filterByFormula: "NOT({Status} = 'Inactive')", 142 | }).eachPage((records, nextPage) => { 143 | records.forEach(async record => { 144 | const subcriber = { app: record.get("App"), endpoint: record.get("Endpoint"), status: record.get("Status") }; 145 | try { 146 | await fetch(subcriber.endpoint, { 147 | method: "POST", 148 | headers: { 149 | "Content-Type": "application/json" 150 | }, 151 | body: JSON.stringify(updateInfo) 152 | }); 153 | metrics.increment("success.send_post_update", 1); 154 | } catch { metrics.increment("errors.send_post_update", 1); } // silently fail to not crash app 155 | 156 | }); 157 | 158 | // load the next set of documents 159 | nextPage(); 160 | }, (error) => { 161 | if (error) metrics.increment("errors.airtable_get_post_listeners", 1); 162 | metrics.increment("success.airtable_get_post_listeners", 1); 163 | }); 164 | 165 | return update; 166 | }; 167 | 168 | export const updateExists = async (updateId) => 169 | prisma.emojiReactions 170 | .findMany({ 171 | where: { 172 | updateId: updateId, 173 | }, 174 | }) 175 | .then((r) => r.length > 0); 176 | 177 | export const updateExistsTS = async (TS) => { 178 | try { 179 | const r = await prisma.updates 180 | .findMany({ 181 | where: { 182 | messageTimestamp: parseFloat(TS), 183 | }, 184 | }) 185 | return r.length > 0; 186 | } catch { 187 | // THis is naughty i know 188 | return false; 189 | } 190 | } 191 | 192 | 193 | export const deleteUpdate = async (ts) => { 194 | return await prisma.updates.deleteMany({ 195 | where: { 196 | messageTimestamp: parseFloat(ts), 197 | }, 198 | }); 199 | }; 200 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/routes/streakResetter.js: -------------------------------------------------------------------------------- 1 | // This API route is pinged by a Zap every hour 2 | 3 | import { getNow, timeout } from '../lib/utils.js' 4 | import { setStatus } from '../lib/profiles.js' 5 | import prisma from '../lib/prisma.js' 6 | import fetch from 'node-fetch' 7 | import { app } from "../app.js"; 8 | import metrics from '../metrics.js'; 9 | 10 | export default async (req, res) => { 11 | res.status(200).end() 12 | const users = await prisma.accounts.findMany({ 13 | where: { 14 | streakCount: { 15 | gt: 0 16 | } 17 | } 18 | }) 19 | users.forEach(async (user) => { 20 | await timeout(500) 21 | const userId = user.slackID 22 | const timezone = user.timezone 23 | const username = user.username 24 | let now = new Date(getNow(timezone)) 25 | now.setHours(now.getHours() - 4) 26 | const latestUpdate = await prisma.updates.findFirst({ 27 | where: { 28 | accountsSlackID: userId 29 | }, 30 | orderBy: [ 31 | { 32 | postTime: 'desc' 33 | } 34 | ] 35 | }) 36 | const createdTime = latestUpdate?.postTime 37 | if (!createdTime) { 38 | // @msw: this fixes a bug where a user creates their first post then deletes it before streak resetter runs 39 | // this prevents trying to reset streaks based on a user without posts 40 | return 41 | } 42 | const createdDate = new Date(createdTime) 43 | const yesterday = new Date(getNow(timezone)) 44 | yesterday.setDate(now.getDate() - 1) 45 | yesterday.setHours(0) 46 | yesterday.setMinutes(0) 47 | if ((createdDate <= yesterday) && user.streakCount != 0) { 48 | console.log( 49 | `It's been more than a day since ${username} last posted. Resetting their streak...` 50 | ) 51 | try { 52 | await prisma.accounts.update({ 53 | where: { slackID: user.slackID }, 54 | data: { streakCount: 0 } 55 | }); 56 | metrics.increment("success.streak_reset", 1); 57 | } catch (err) { 58 | console.log("Error: Failed to update user streak") 59 | metrics.increment("errors.streak_reset", 1); 60 | } 61 | 62 | if (user.displayStreak) { 63 | try { 64 | const info = await app.client.users.info({ 65 | user: user.slackID 66 | }); 67 | if(!info.is_admin) await setStatus(userId, '', '') 68 | } 69 | catch(e) { 70 | app.client.chat.postMessage({ 71 | channel: "USNPNJXNX", 72 | text: t("messages.errors.zach"), 73 | }); 74 | } 75 | await fetch('https://slack.com/api/chat.postMessage', { 76 | method: 'POST', 77 | headers: { 78 | 'Content-Type': 'application/json', 79 | Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}` 80 | }, 81 | body: JSON.stringify({ 82 | channel: userId, //userId 83 | text: `<@${userId}> It's been more than 24 hours since you last posted a Scrapbook update, so I've reset your streak. No worries, though—post something else to start another streak! And the rest of your updates are still available at https://scrapbook.hackclub.com/${username} :)` 84 | }) 85 | }) 86 | } 87 | } 88 | }) 89 | // Calculate streaks to fix any errors 90 | // @Josias - Not sure what errors this fixes so I'm commenting this out 91 | // let twoDaysAhead = new Date() 92 | // twoDaysAhead.setDate(twoDaysAhead.getDate() + 2) 93 | // let threeDaysBehind = new Date() 94 | // threeDaysBehind.setDate(threeDaysBehind.getDate() - 3) 95 | // const usersToCalculate = await prisma.accounts.findMany({ 96 | // include: { 97 | // updates: { 98 | // orderBy: { 99 | // postTime: 'desc', 100 | // }, 101 | // } 102 | // }, 103 | // where: { 104 | // updates: { 105 | // some: { 106 | // postTime: { 107 | // lte: twoDaysAhead, 108 | // gte: threeDaysBehind 109 | // }, 110 | // } 111 | // } 112 | // }, 113 | // }) 114 | // usersToCalculate.forEach(async (user) => { 115 | // await timeout(500) 116 | // const userId = user.slackID 117 | // const timezone = user.timezone 118 | // const username = user.username 119 | // const latestUpdate = user.updates[0] 120 | // const createdTime = latestUpdate?.postTime 121 | // if (!createdTime) { 122 | // // @msw: this fixes a bug where a user creates their first post then deletes it before streak resetter runs 123 | // // this prevents trying to reset streaks based on a user without posts 124 | // return 125 | // } 126 | // let createdDate = new Date(createdTime) 127 | // let streak = 0 128 | // let k = 0 129 | // const now = new Date(getNow(timezone)); 130 | // const yesterday = new Date(getNow(timezone)) 131 | // yesterday.setDate(now.getDate() - 1) 132 | // yesterday.setHours(0) 133 | // yesterday.setMinutes(0) 134 | // while(createdDate >= yesterday) { 135 | // streak++ 136 | // k++ 137 | // yesterday.setDate(yesterday.getDate() - 1) 138 | // let newCreatedDate = new Date(user.updates[k]?.postTime) 139 | // while(createdDate.toDateString() == newCreatedDate.toDateString()){ 140 | // k++ 141 | // newCreatedDate = new Date(user.updates[k]?.postTime) 142 | // } 143 | // createdDate = newCreatedDate 144 | // } 145 | // if(streak > 0 && streak > user.streakCount){ 146 | // await prisma.accounts.update({ 147 | // where: { slackID: user.slackID }, 148 | // data: { streakCount: streak } 149 | // }) 150 | // } 151 | // }) 152 | } --------------------------------------------------------------------------------