├── .env.example ├── .gitignore ├── .rgignore ├── Caddyfile ├── Procfile ├── README.md ├── index.esm.mjs ├── manifest.yml ├── package.json ├── prisma ├── migrations │ └── 20220208035819_baseline_migration │ │ └── migration.sql └── schema.prisma ├── public ├── 404.html ├── activity-chart.js ├── channel-list.js ├── emoji.js ├── helpers.js ├── index.html └── style.css ├── scripts ├── MovingAverage.cleanup.zsh ├── pg_stat_statements.sql ├── sql │ ├── DATABASE.clean.sql │ ├── MovingAverage.DELETE_550k.sql │ └── MovingAverage.SELECT.created_ASC_LIM_2.sql └── statement_timeout.sql.zsh ├── src ├── api.ts ├── commands.ts ├── config.ts ├── convos.ts ├── home.ts ├── index.ts ├── ma.ts ├── main.ts ├── messages.ts ├── reactions.ts ├── scripts │ └── queries.ts ├── server.ts ├── slashcmd.ts └── util.ts ├── systemd └── system │ ├── bunyan-cleanup.service │ ├── bunyan-cleanup.timer │ └── bunyan.service ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV='development' 2 | APP_ID='' 3 | CLIENT_ID='' 4 | CLIENT_SECRET='' 5 | SIGNING_SECRET='' 6 | SLACK_VERIFICATION_TOKEN='' 7 | BOT_TOKEN='' 8 | APP_TOKEN='' 9 | STATE_SECRET='' 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | .envrc 4 | latest.dump 5 | 6 | # sb1 is the og streambot for reference 7 | /sb1 8 | .env 9 | .*.env 10 | /.db 11 | /tmp 12 | -------------------------------------------------------------------------------- /.rgignore: -------------------------------------------------------------------------------- 1 | yarn.lock 2 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | http_port {$PORT:8080} 3 | https_port 8443 4 | auto_https off 5 | } 6 | 7 | 8 | :{$PORT:8080} { 9 | root / ./public 10 | file_server 11 | 12 | reverse_proxy /api/* :{$PORT_APP:3002} 13 | reverse_proxy /slack/* :{$PORT_APP:3002} 14 | 15 | @websockets { 16 | header Connection *Upgrade* 17 | header Upgrade websocket 18 | } 19 | 20 | reverse_proxy @websockets /socket.io/* :{$PORT_WS:3003} { 21 | header_up Host {host} 22 | header_up X-Real-IP {remote_host} 23 | header_up X-Forwarded-For {remote_host} 24 | header_up X-Forwarded-Proto {scheme} 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start 2 | 3 | release: npx prisma migrate deploy 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # @Paul Bunyan [DEPRECATED] 4 | 5 | ⚠️ This bot is no longer being maintained or updated by Hack Club staff. 6 | 7 | _Work inspired by [@streambot](https://github.com/hackclub/streambot)._ 8 | 9 | @Paul Bunyan (pictured to the right) logs everything happening in the Hack Club 10 | Slack. Posts, reactions, etc. all get tracked so he can help you understand 11 | where the activity in the Slack is. 12 | 13 | ## How to use 14 | 15 | Ask Paul Bunyan what's happening in the Slack by typing `/sup`– he'll respond with a list of 5 recently active channels in the last 2 hours: 16 | 17 | ![](https://cloud-k25eaalth-hack-club-bot.vercel.app/1screen_shot_2021-04-30_at_17.36.13.png) 18 | 19 | --- 20 | 21 | Want to query a specific channel, user, or emoji? 22 | 23 | You can run any of these: 24 | 25 | - `/supwit @orpheus` _Where has @orpheus been active recently?_ 26 | - `/supwit 30 @orpheus` _Where has @orpheus been active in the last 30 minutes?_ 27 | - `/supwit :yay:` _Where has the :yay: reaction been used recently?_ 28 | - `/supwit 45 :yay:` _Where has the :yay: reaction been used in the last 45 min?_ 29 | - `/supwit #lounge` _What's happening in #lounge?_ 30 | - `/supwit 60 #lounge` _What's been happening in #lounge over the past 60 min_ 31 | 32 | ## What can @Paul Bunyan see? 33 | 34 | If you want to opt-in or opt-out of logging (for yourself or a channel), type 35 | `@Paul Bunyan help`– he's an all-around great guy and will do his best to 36 | accomodate whatever you're trying to do. 37 | 38 | ## Future work 39 | 40 | ### Stats API 41 | 42 | We could build some pretty great graphs & analytics tools if we could query the data we're storing. You can find a work-in-progress example of this at http://streamboot-bot.herokuapp.com. 43 | 44 | ### Websockets 45 | 46 | In the future we might add websocket support to post out to other applications when a new reaction or message is posted in the Slack. You can see an example of how we'd use that info on our [Slack Join Page](https://hackclub.com/slack) 47 | 48 | ![](https://cloud-1f3dc5q2u-hack-club-bot.vercel.app/0screen_shot_2021-04-30_at_18.11.01.png) 49 | -------------------------------------------------------------------------------- /index.esm.mjs: -------------------------------------------------------------------------------- 1 | //require('./dist') 2 | export * from './dist/index.js' 3 | import $ from './dist/index.js' 4 | export default $ 5 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | display_information: 2 | name: Paul Bunyan 3 | description: Paul logs everything so he always knows what 'sup. Run /sup to try it out! 4 | background_color: "#db2f23" 5 | features: 6 | bot_user: 7 | display_name: Paul Bunyan 8 | always_online: true 9 | slash_commands: 10 | - command: /sup 11 | url: https://bunyan.fogg.dev/slack/events 12 | description: "'Sup? Most active channels by $mins." 13 | usage_hint: "[mins=120]" 14 | should_escape: false 15 | - command: /supwit 16 | url: https://bunyan.fogg.dev/slack/events 17 | description: "'Sup wit' it? Ask me about any emoji, user, or channel." 18 | usage_hint: "[mins=1440] [:sparkles:, @scrappy, #scrappy]" 19 | should_escape: true 20 | oauth_config: 21 | scopes: 22 | bot: 23 | - app_mentions:read 24 | - channels:history 25 | - channels:join 26 | - channels:manage 27 | - channels:read 28 | - chat:write 29 | - commands 30 | - incoming-webhook 31 | - reactions:read 32 | - reactions:write 33 | - users:read 34 | - users:read.email 35 | settings: 36 | event_subscriptions: 37 | request_url: https://bunyan.fogg.dev/slack/events 38 | bot_events: 39 | - app_home_opened 40 | - app_mention 41 | - channel_archive 42 | - channel_created 43 | - channel_deleted 44 | - channel_left 45 | - channel_rename 46 | - channel_unarchive 47 | - message.channels 48 | - reaction_added 49 | - reaction_removed 50 | interactivity: 51 | is_enabled: true 52 | request_url: https://streamboot-bot.herokuapp.com/slack/events 53 | org_deploy_enabled: false 54 | socket_mode_enabled: false 55 | token_rotation_enabled: false 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sb2", 3 | "version": "1.0.0", 4 | "description": "Stream Bot: 2nd gen", 5 | "private": true, 6 | "repository": "git@github.com:hackclub/sb2.git", 7 | "author": "Zachary Fogg ", 8 | "license": "MIT", 9 | "types": "dist/index.d.ts", 10 | "main": "dist/index.js", 11 | "type": "commonjs", 12 | "module": "dist", 13 | "exports": { 14 | "import": "./index.esm.mjs", 15 | "require": "./dist/index.js" 16 | }, 17 | "files": [ 18 | "index.esm.mjs", 19 | "dist" 20 | ], 21 | "scripts": { 22 | "build": "npx tsc >/dev/null || true", 23 | "start": "node dist/index.js", 24 | "dev": "nodemon --exec 'ts-node' src/index.ts", 25 | "systemd:execstart:dev": "yarn run dev", 26 | "systemd:execstart": "yarn run build && yarn run start", 27 | "systemd:readme": "echo 'do this!\nsudo ln -sfv /opt/bunyan/systemd/system/bunyan*.* /etc/systemd/system/'" 28 | }, 29 | "dependencies": { 30 | "@prisma/client": "^3.9.1", 31 | "@slack/bolt": "^3.2.0", 32 | "@types/express": "^4.17.11", 33 | "@types/sha1": "^1.1.2", 34 | "@types/socket.io": "^2.1.13", 35 | "cors": "^2.8.5", 36 | "date-fns": "^2.21.1", 37 | "dotenv": "^8.2.0", 38 | "moving-average": "^1.0.1", 39 | "nodemon": "^2.0.15", 40 | "prisma": "^3.9.1", 41 | "sha1": "^1.1.1", 42 | "socket.io": "^4.0.1", 43 | "tedis": "^0.1.12", 44 | "ts-node": "^10.4.0", 45 | "tslib": "^2.1.0", 46 | "typescript": "^4.1.5" 47 | }, 48 | "devDependencies": { 49 | "@types/cors": "^2.8.10", 50 | "@types/node": "^16.11.11", 51 | "@typescript-eslint/parser": "^4.31.2", 52 | "eslint": "^7.32.0", 53 | "ts-node-dev": "^1.1.6" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /prisma/migrations/20220208035819_baseline_migration/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "SlackResource" ( 3 | "id" TEXT NOT NULL, 4 | "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "watching" BOOLEAN NOT NULL DEFAULT true, 7 | 8 | CONSTRAINT "SlackResource_pkey" PRIMARY KEY ("id") 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "MovingAverage" ( 13 | "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 | "average" DECIMAL(65,30) NOT NULL DEFAULT 0, 15 | "variance" DECIMAL(65,30) NOT NULL DEFAULT 0, 16 | "deviation" DECIMAL(65,30) NOT NULL DEFAULT 0, 17 | "forecast" DECIMAL(65,30) NOT NULL DEFAULT 0, 18 | "slack_id" TEXT NOT NULL, 19 | "id" SERIAL NOT NULL, 20 | "messages" INTEGER NOT NULL DEFAULT 0, 21 | 22 | CONSTRAINT "MovingAverage_pkey" PRIMARY KEY ("id") 23 | ); 24 | 25 | -- CreateTable 26 | CREATE TABLE "User" ( 27 | "id" TEXT NOT NULL, 28 | "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 29 | "updated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 30 | "watching" BOOLEAN NOT NULL DEFAULT true, 31 | 32 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 33 | ); 34 | 35 | -- CreateTable 36 | CREATE TABLE "Channel" ( 37 | "id" TEXT NOT NULL, 38 | "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 39 | "updated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 40 | "watching" BOOLEAN NOT NULL DEFAULT true, 41 | 42 | CONSTRAINT "Channel_pkey" PRIMARY KEY ("id") 43 | ); 44 | 45 | -- CreateTable 46 | CREATE TABLE "Emoji" ( 47 | "id" TEXT NOT NULL, 48 | "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 49 | "updated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 50 | "watching" BOOLEAN NOT NULL DEFAULT true, 51 | 52 | CONSTRAINT "Emoji_pkey" PRIMARY KEY ("id") 53 | ); 54 | 55 | -- CreateTable 56 | CREATE TABLE "Reaction" ( 57 | "id" SERIAL NOT NULL, 58 | "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 59 | "emoji_id" TEXT NOT NULL, 60 | "user_id" TEXT NOT NULL, 61 | "channel_id" TEXT NOT NULL, 62 | "ts" DECIMAL(65,30) NOT NULL DEFAULT 0, 63 | "event_ts" DECIMAL(65,30) NOT NULL DEFAULT 0, 64 | 65 | CONSTRAINT "Reaction_pkey" PRIMARY KEY ("id") 66 | ); 67 | 68 | -- CreateTable 69 | CREATE TABLE "Message" ( 70 | "id" SERIAL NOT NULL, 71 | "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 72 | "user_id" TEXT NOT NULL, 73 | "channel_id" TEXT NOT NULL, 74 | "content_hash" TEXT NOT NULL, 75 | "content_length" INTEGER NOT NULL, 76 | "thread_ts" DECIMAL(65,30) NOT NULL DEFAULT 0, 77 | "ts" DECIMAL(65,30) NOT NULL DEFAULT 0, 78 | 79 | CONSTRAINT "Message_pkey" PRIMARY KEY ("id") 80 | ); 81 | 82 | -- CreateIndex 83 | CREATE INDEX "MovingAverage.created_index" ON "MovingAverage"("created"); 84 | 85 | -- CreateIndex 86 | CREATE INDEX "MovingAverage.slack_id_messages_index" ON "MovingAverage"("slack_id", "messages"); 87 | 88 | -- CreateIndex 89 | CREATE INDEX "MovingAverage.slack_id_index" ON "MovingAverage"("slack_id"); 90 | 91 | -- CreateIndex 92 | CREATE INDEX "Reaction.channel_id_index" ON "Reaction"("channel_id"); 93 | 94 | -- CreateIndex 95 | CREATE INDEX "Reaction.emoji_id_channel_id_index" ON "Reaction"("emoji_id", "channel_id"); 96 | 97 | -- CreateIndex 98 | CREATE INDEX "Reaction.user_id_channel_id_index" ON "Reaction"("user_id", "channel_id"); 99 | 100 | -- CreateIndex 101 | CREATE INDEX "Message.user_id_index" ON "Message"("user_id"); 102 | 103 | -- CreateIndex 104 | CREATE INDEX "Message.channel_id_user_id_index" ON "Message"("channel_id", "user_id"); 105 | 106 | -- AddForeignKey 107 | ALTER TABLE "MovingAverage" ADD CONSTRAINT "MovingAverage_slack_id_fkey" FOREIGN KEY ("slack_id") REFERENCES "SlackResource"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 108 | 109 | -- AddForeignKey 110 | ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 111 | 112 | -- AddForeignKey 113 | ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_channel_id_fkey" FOREIGN KEY ("channel_id") REFERENCES "Channel"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 114 | 115 | -- AddForeignKey 116 | ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_emoji_id_fkey" FOREIGN KEY ("emoji_id") REFERENCES "Emoji"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 117 | 118 | -- AddForeignKey 119 | ALTER TABLE "Message" ADD CONSTRAINT "Message_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 120 | 121 | -- AddForeignKey 122 | ALTER TABLE "Message" ADD CONSTRAINT "Message_channel_id_fkey" FOREIGN KEY ("channel_id") REFERENCES "Channel"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 123 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | previewFeatures = [] 4 | } 5 | 6 | datasource db { 7 | provider = "postgresql" 8 | url = env("DATABASE_URL_PRISMA") 9 | } 10 | 11 | model SlackResource { 12 | id String @id 13 | created DateTime @default(now()) 14 | updated DateTime @default(now()) @updatedAt 15 | watching Boolean @default(true) 16 | averages MovingAverage[] 17 | } 18 | 19 | model MovingAverage { 20 | created DateTime @default(now()) 21 | average Decimal @default(0) 22 | variance Decimal @default(0) 23 | deviation Decimal @default(0) 24 | forecast Decimal @default(0) 25 | slack_id String 26 | id Int @id @default(autoincrement()) 27 | messages Int @default(0) 28 | slack_resource SlackResource @relation(fields: [slack_id], references: [id]) 29 | 30 | @@index([created], map: "MovingAverage.created_index") 31 | @@index([slack_id], map: "MovingAverage.slack_id_index") 32 | @@index([slack_id, messages], map: "MovingAverage.slack_id_messages_index") 33 | } 34 | 35 | model User { 36 | id String @id 37 | created DateTime @default(now()) 38 | updated DateTime @default(now()) @updatedAt 39 | watching Boolean @default(true) 40 | messages Message[] 41 | reactions Reaction[] 42 | } 43 | 44 | model Channel { 45 | id String @id 46 | created DateTime @default(now()) 47 | updated DateTime @default(now()) @updatedAt 48 | watching Boolean @default(true) 49 | messages Message[] 50 | reactions Reaction[] 51 | } 52 | 53 | model Emoji { 54 | id String @id 55 | created DateTime @default(now()) 56 | updated DateTime @default(now()) @updatedAt 57 | watching Boolean @default(true) 58 | reactions Reaction[] 59 | } 60 | 61 | model Reaction { 62 | id Int @id @default(autoincrement()) 63 | created DateTime @default(now()) 64 | emoji_id String 65 | user_id String 66 | channel_id String 67 | ts Decimal @default(0) 68 | event_ts Decimal @default(0) 69 | channel Channel @relation(fields: [channel_id], references: [id]) 70 | emoji Emoji @relation(fields: [emoji_id], references: [id]) 71 | user User @relation(fields: [user_id], references: [id]) 72 | 73 | @@index([channel_id], map: "Reaction.channel_id_index") 74 | @@index([emoji_id, channel_id], map: "Reaction.emoji_id_channel_id_index") 75 | @@index([user_id, channel_id], map: "Reaction.user_id_channel_id_index") 76 | } 77 | 78 | model Message { 79 | id Int @id @default(autoincrement()) 80 | created DateTime @default(now()) 81 | user_id String 82 | channel_id String 83 | content_hash String 84 | content_length Int 85 | thread_ts Decimal @default(0) 86 | ts Decimal @default(0) 87 | channel Channel @relation(fields: [channel_id], references: [id]) 88 | user User @relation(fields: [user_id], references: [id]) 89 | 90 | @@index([user_id], map: "Message.user_id_index") 91 | @@index([channel_id, user_id], map: "Message.channel_id_user_id_index") 92 | } 93 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 37 | 38 | 39 | 40 | 41 |
42 |
43 |

Error 404

44 |
45 |
46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /public/activity-chart.js: -------------------------------------------------------------------------------- 1 | 2 | var data = { 3 | movingAverages: [], 4 | updatedAt: null 5 | } 6 | 7 | var chart 8 | var chartOptions = {} 9 | 10 | const lavalampColors = [ 11 | { r: 236, g: 9, b: 48 }, 12 | { r: 205, g: 84, b: 147 }, 13 | { r: 205, g: 147, b: 84 }, 14 | { r: 96, g: 20, b: 162 }, 15 | { r: 96, g: 20, b: 162 }, 16 | { r: 96, g: 20, b: 162 }, 17 | { r: 96, g: 20, b: 162 }, 18 | { r: 96, g: 20, b: 162 }, 19 | { r: 96, g: 20, b: 162 }, 20 | ] 21 | function rgba(c, a) { 22 | return `rgba(${[c.r, c.g, c.b, a].join(',')})` 23 | } 24 | function colorFromString(string) { 25 | return Please.make_color({ seed: string, format: 'rgb' })[0] 26 | } 27 | 28 | function run() { 29 | let success = false 30 | fetch(endpoint('demo')) 31 | .then(r => 32 | r.json() 33 | ).then(movingAverages => { 34 | if (movingAverages) { 35 | data.updatedAt = Date.now() 36 | data.rawData = movingAverages 37 | data.channelData = {} 38 | movingAverages 39 | .filter(c => c.slack_id[0] === 'C') 40 | .forEach(ma => { 41 | if (!data.channelData[ma['slack_id']]) { 42 | data.channelData[ma['slack_id']] = { 43 | score: 0, 44 | points: [] 45 | } 46 | } 47 | data.channelData[ma.slack_id].score += Math.min(Math.exp(ma.average), 50) 48 | data.channelData[ma.slack_id].points.push({ 49 | y: Math.min(Math.exp(ma.average), 50), 50 | x: ma.ten_min_timestamp 51 | }) 52 | }) 53 | success = true 54 | } 55 | }).finally(() => { 56 | // render all the data (success or not) 57 | const updateEl = document.querySelector('.updated') 58 | if (data.updatedAt) { 59 | let date = new Date(data.updatedAt) 60 | updateEl.innerHTML = `Latest data pulled at ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}` 61 | } else { 62 | updateEl.innerHTML = `(Failed to load latest data, will retry shortly)` 63 | } 64 | 65 | const resultEl = document.querySelector('.results') 66 | const dataEl = document.querySelector('.raw-data') 67 | if (data.channelData) { 68 | if (!document.querySelector('canvas')) { 69 | const el = document.createElement('canvas') 70 | el.width = 400 71 | el.height = 400 72 | resultEl.appendChild(el) 73 | chartOptions.type = 'line' 74 | chartOptions.options = { 75 | scales: { 76 | x: { 77 | type: 'time', 78 | time: { 79 | unit: 'minute' 80 | } 81 | }, 82 | y: { 83 | type: 'logarithmic' 84 | } 85 | }, 86 | animations: { 87 | tension: { 88 | duration: 3000, 89 | easing: 'linear', 90 | from: 1, 91 | to: 0.5, 92 | loop: true 93 | } 94 | }, 95 | plugins: { 96 | zoom: { 97 | pan: { 98 | enabled: true, 99 | mode: 'x' 100 | }, 101 | zoom: { 102 | enabled: true, 103 | mode: 'x', 104 | } 105 | } 106 | } 107 | } 108 | const ctx = el.getContext('2d') 109 | chart = new Chart(ctx, chartOptions) 110 | } 111 | 112 | // update the graph 113 | chartOptions.data.datasets = [] 114 | 115 | const channelPromises = Object.keys(data.channelData).map(async (channelID, index) => { 116 | if (index < 10) { 117 | chartOptions.data.datasets.push({ 118 | label: await getChannelName(channelID), 119 | data: data.channelData[channelID].points, 120 | fill: true, 121 | fillOpacity: 0.5, 122 | borderColor: rgba(colorFromString(channelID), 0.8), 123 | backgroundColor: rgba(colorFromString(channelID), 0.3), 124 | tension: 0.5, 125 | }) 126 | } 127 | }) 128 | Promise.all(channelPromises).then(() => chart.update('none')) 129 | 130 | dataEl.innerHTML = `// ${endpoint('demo')}\n${JSON.stringify(data.rawData, null, 2)}` 131 | } else { 132 | resultEl.innerHTML = `⚠️ something has gone wrong` 133 | dataEl.innerHTML = `⚠️ something has gone wrong` 134 | } 135 | }) 136 | } 137 | 138 | // run() 139 | 140 | // setInterval(run, 1000 * 15) // ever 15 seconds, update the list -------------------------------------------------------------------------------- /public/channel-list.js: -------------------------------------------------------------------------------- 1 | async function channelRun() { 2 | if (!results) { 3 | var results = {} 4 | } 5 | 6 | if (!results.channels) { 7 | results.channels = {} 8 | } 9 | let channelData = await fetch(endpoint('top/channels')).then(r => r.json()) 10 | channelData.forEach(cd => { 11 | results.channels[cd.channel_id] = { 12 | count: cd.count, 13 | name: getChannelName(cd.channel_id) 14 | } 15 | }) 16 | 17 | Object.keys(results.channels).slice(0,5).forEach(async (channelID, index) => { 18 | let c = results.channels[channelID] 19 | let el = document.querySelector(`.channel-top-${index + 1}`) 20 | el.innerHTML = `${await c.name} had ${c.count} messages in the last hour` 21 | el.classList.remove('loading') 22 | }) 23 | } 24 | 25 | channelRun() 26 | setInterval(channelRun, 15 * 1000) -------------------------------------------------------------------------------- /public/emoji.js: -------------------------------------------------------------------------------- 1 | var socket 2 | function connectSocket() { 3 | socket = new WebSocket(`ws://${window.location.host}`) 4 | socket.onopen = event => { 5 | console.log({event}) 6 | } 7 | socket.onclose = event => { 8 | console.log("Socket closed. Attempting reconnect in 1 second.", event.reason) 9 | } 10 | 11 | socket.onmessage = event => { 12 | const { emoji, url } = JSON.parse(event.data) 13 | triggerEmoji(emoji) 14 | } 15 | 16 | return socket 17 | } 18 | 19 | connectSocket() 20 | // setInterval(() => { 21 | // if (!socket || socket.readyState !== socket.OPEN) { 22 | // connectSocket() 23 | // } 24 | // }, 5000) 25 | 26 | // mock emoji triggering for the time being: 27 | function mockEmojiTrigger() { 28 | let emojis = [ 29 | '😄','😃','😀','😊','☺','😉','😍','😘','😚','😗','😙','😜','😝','😛','😳','😁','😔','😌','😒','😞','😣','😢','😂','😭','😪','😥','😰','😅','😓','😩','😫','😨','😱','😠','😡','😤','😖','😆','😋','😷','😎','😴','😵','😲','😟','😦','😧','😈','👿','😮','😬','😐','😕','😯','😶','😇','😏','😑','👲','👳','👮','👷','💂','👶','👦','👧','👨','👩','👴','👵','👱','👼','👸','😺','😸','😻','😽','😼','🙀','😿','😹','😾','👹','👺','🙈','🙉','🙊','💀','👽','💩','🔥','✨','🌟','💫','💥','💢','💦','💧','💤','💨','👂','👀','👃','👅','👄','👍','👎','👌','👊','✊','✌','👋','✋','👐','👆','👇','👉','👈','🙌','🙏','☝','👏','💪','🚶','🏃','💃','👫','👪','👬','👭','💏','💑','👯','🙆','🙅','💁','🙋','💆','💇','💅','👰','🙎','🙍','🙇','🎩','👑','👒','👟','👞','👡','👠','👢','👕','👔','👚','👗','🎽','👖','👘','👙','💼','👜','👝','👛','👓','🎀','🌂','💄','💛','💙','💜','💚','❤','💔','💗','💓','💕','💖','💞','💘','💌','💋','💍','💎','👤','👥','💬','👣','💭','🐶','🐺','🐱','🐭','🐹','🐰','🐸','🐯','🐨','🐻','🐷','🐽','🐮','🐗','🐵','🐒','🐴','🐑','🐘','🐼','🐧','🐦','🐤','🐥','🐣','🐔','🐍','🐢','🐛','🐝','🐜','🐞','🐌','🐙','🐚','🐠','🐟','🐬','🐳','🐋','🐄','🐏','🐀','🐃','🐅','🐇','🐉','🐎','🐐','🐓','🐕','🐖','🐁','🐂','🐲','🐡','🐊','🐫','🐪','🐆','🐈','🐩','🐾','💐','🌸','🌷','🍀','🌹','🌻','🌺','🍁','🍃','🍂','🌿','🌾','🍄','🌵','🌴','🌲','🌳','🌰','🌱','🌼','🌐','🌞','🌝','🌚','🌑','🌒','🌓','🌔','🌕','🌖','🌗','🌘','🌜','🌛','🌙','🌍','🌎','🌏','🌋','🌌','🌠','⭐','☀','⛅','☁','⚡','☔','❄','⛄','🌀','🌁','🌈','🌊','🎍','💝','🎎','🎒','🎓','🎏','🎆','🎇','🎐','🎑','🎃','👻','🎅','🎄','🎁','🎋','🎉','🎊','🎈','🎌','🔮','🎥','📷','📹','📼','💿','📀','💽','💾','💻','📱','☎','📞','📟','📠','📡','📺','📻','🔊','🔉','🔈','🔇','🔔','🔕','📢','📣','⏳','⌛','⏰','⌚','🔓','🔒','🔏','🔐','🔑','🔎','💡','🔦','🔆','🔅','🔌','🔋','🔍','🛁','🛀','🚿','🚽','🔧','🔩','🔨','🚪','🚬','💣','🔫','🔪','💊','💉','💰','💴','💵','💷','💶','💳','💸','📲','📧','📥','📤','✉','📩','📨','📯','📫','📪','📬','📭','📮','📦','📝','📄','📃','📑','📊','📈','📉','📜','📋','📅','📆','📇','📁','📂','✂','📌','📎','✒','✏','📏','📐','📕','📗','📘','📙','📓','📔','📒','📚','📖','🔖','📛','🔬','🔭','📰','🎨','🎬','🎤','🎧','🎼','🎵','🎶','🎹','🎻','🎺','🎷','🎸','👾','🎮','🃏','🎴','🀄','🎲','🎯','🏈','🏀','⚽','⚾','🎾','🎱','🏉','🎳','⛳','🚵','🚴','🏁','🏇','🏆','🎿','🏂','🏊','🏄','🎣','☕','🍵','🍶','🍼','🍺','🍻','🍸','🍹','🍷','🍴','🍕','🍔','🍟','🍗','🍖','🍝','🍛','🍤','🍱','🍣','🍥','🍙','🍘','🍚','🍜','🍲','🍢','🍡','🍳','🍞','🍩','🍮','🍦','🍨','🍧','🎂','🍰','🍪','🍫','🍬','🍭','🍯','🍎','🍏','🍊','🍋','🍒','🍇','🍉','🍓','🍑','🍈','🍌','🍐','🍍','🍠','🍆','🍅','🌽','🏠','🏡','🏫','🏢','🏣','🏥','🏦','🏪','🏩','🏨','💒','⛪','🏬','🏤','🌇','🌆','🏯','🏰','⛺','🏭','🗼','🗾','🗻','🌄','🌅','🌃','🗽','🌉','🎠','🎡','⛲','🎢','🚢','⛵','🚤','🚣','⚓','🚀','✈','💺','🚁','🚂','🚊','🚉','🚞','🚆','🚄','🚅','🚈','🚇','🚝','🚋','🚃','🚎','🚌','🚍','🚙','🚘','🚗','🚕','🚖','🚛','🚚','🚨','🚓','🚔','🚒','🚑','🚐','🚲','🚡','🚟','🚠','🚜','💈','🚏','🎫','🚦','🚥','⚠','🚧','🔰','⛽','🏮','🎰','♨','🗿','🎪','🎭','📍','🚩','⬆','⬇','⬅','➡','🔠','🔡','🔤','↗','↖','↘','↙','↔','↕','🔄','◀','▶','🔼','🔽','↩','↪','ℹ','⏪','⏩','⏫','⏬','⤵','⤴','🆗','🔀','🔁','🔂','🆕','🆙','🆒','🆓','🆖','📶','🎦','🈁','🈯','🈳','🈵','🈴','🈲','🉐','🈹','🈺','🈶','🈚','🚻','🚹','🚺','🚼','🚾','🚰','🚮','🅿','♿','🚭','🈷','🈸','🈂','Ⓜ','🛂','🛄','🛅','🛃','🉑','㊙','㊗','🆑','🆘','🆔','🚫','🔞','📵','🚯','🚱','🚳','🚷','🚸','⛔','✳','❇','❎','✅','✴','💟','🆚','📳','📴','🅰','🅱','🆎','🅾','💠','➿','♻','♈','♉','♊','♋','♌','♍','♎','♏','♐','♑','♒','♓','⛎','🔯','🏧','💹','💲','💱','©','®','™','〽','〰','🔝','🔚','🔙','🔛','🔜','❌','⭕','❗','❓','❕','❔','🔃','🕛','🕧','🕐','🕜','🕑','🕝','🕒','🕞','🕓','🕟','🕔','🕠','🕕','🕖','🕗','🕘','🕙','🕚','🕡','🕢','🕣','🕤','🕥','🕦','✖','➕','➖','➗','♠','♥','♣','♦','💮','💯','✔','☑','🔘','🔗','➰','🔱','🔲','🔳','◼','◻','◾','◽','▪','▫','🔺','⬜','⬛','⚫','⚪','🔴','🔵','🔻','🔶','🔷','🔸','🔹' 30 | ] 31 | triggerEmoji(emojis[Math.floor(Math.random() * emojis.length)]) 32 | setTimeout(mockEmojiTrigger, Math.random() * 5 * 1000) 33 | } 34 | mockEmojiTrigger() 35 | 36 | function triggerEmoji(emoji) { 37 | const container = document.querySelector('.emoji-container') 38 | // make explosion for the emoji to erupt from 39 | let explosion = document.createElement('div') 40 | explosion.classList.add('explosion') // duh 41 | explosion.innerText = '💥' 42 | container.appendChild(explosion) 43 | anime({ 44 | targets: explosion, 45 | duration: 500, 46 | scale: 10, 47 | rotate: anime.random(-30, 30), 48 | opacity: 0 49 | }) 50 | setTimeout(() => { 51 | explosion.remove() 52 | }, 500) 53 | 54 | 55 | // then make an emoji 56 | console.log('triggering', emoji) 57 | let el = document.createElement('div') 58 | el.classList.add('emoji') 59 | el.dataset.emoji = emoji 60 | el.classList.add(emoji) 61 | container.appendChild(el) 62 | let duration = anime.random(2500, 5000) 63 | anime({ 64 | targets: el, 65 | easing: 'easeOutQuad', 66 | translateX: anime.random(-150, 150), 67 | translateY: anime.random(150, 500), 68 | rotate: anime.random(360 * 2, 360 * 5), 69 | scale: anime.random(0.5, 2), 70 | opacity: 0, 71 | duration: duration, 72 | }) 73 | setTimeout(() => { 74 | el.remove() 75 | }, duration) 76 | } -------------------------------------------------------------------------------- /public/helpers.js: -------------------------------------------------------------------------------- 1 | function endpoint(path) { 2 | return window.location.origin + '/api/' + path 3 | } 4 | 5 | var slackLookupCache = {} 6 | 7 | async function getChannelName(channelID) { 8 | if (!slackLookupCache.channels) { 9 | slackLookupCache.channels = {} 10 | } 11 | 12 | if (!slackLookupCache.channels[channelID]) { 13 | slackLookupCache.channels[channelID] = fetch(endpoint(`demo-channel-lookup/${channelID}`)).then(r => r.json()).then(r => `#${r.name}`).catch(err => 0) 14 | } 15 | 16 | return await slackLookupCache.channels[channelID] 17 | } 18 | 19 | async function getSlackEmoji(emoji) { 20 | if (!slackLookupCache.emoji) { 21 | slackLookupCache.emoji = {} 22 | } 23 | 24 | if (slackLookupCache.emoji[emoji]) { 25 | slackLookupCache.channels[channelID] = fetch(endpoint(`demo-emoji-lookup/${emoji}`)).then(r => r.json()).then(j => j.src).catch(err => 0) 26 | } 27 | 28 | return await slackLookupCache.emoji[emoji] 29 | } 30 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |
31 |

What's poppin'

32 |
33 | 40 |

Loading...

41 |
42 |

This was created with Streamboot

43 |
44 | See the raw data 45 |

46 |         Loading...
47 |       
48 |
49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Lobster&display=swap'); 2 | 3 | h1 { 4 | font-family: 'Lobster', cursive; 5 | position: relative; 6 | font-size: 5em; 7 | } 8 | 9 | canvas { 10 | max-height: 60vh; 11 | } 12 | 13 | a { 14 | color: white; 15 | } 16 | 17 | .header-container { 18 | margin: 0 auto; 19 | } 20 | 21 | .header { 22 | margin-bottom: 24px; 23 | position: relative; 24 | display: inline-block; 25 | } 26 | 27 | .header::before { 28 | content: 'Find out'; 29 | font-size: 36px; 30 | position: absolute; 31 | font-weight: lighter; 32 | transform: rotate(-6deg); 33 | top: -20px; 34 | color: red; 35 | } 36 | 37 | .header::after { 38 | content: 'on the hack club slack'; 39 | font-family: sans; 40 | position: absolute; 41 | font-size: 36px; 42 | bottom: -20px; 43 | right: 0; 44 | } 45 | .loading::after { 46 | content: 'loading...'; 47 | font-style: italic; 48 | } 49 | .explosion { 50 | position: absolute; 51 | top: 50%; 52 | left: 50%; 53 | height: 1px; 54 | width: 1px; 55 | } 56 | .emoji-container { 57 | position: relative; 58 | } 59 | .emoji { 60 | position: absolute; 61 | top: 50%; 62 | left: 50%; 63 | } 64 | .emoji::after { 65 | content: attr(data-emoji) 66 | } 67 | 68 | body { 69 | background-image: url('https://cloud-oth8cndgs-hack-club-bot.vercel.app/0grid.svg'); 70 | color: whitesmoke; 71 | text-align: center; 72 | align-content: center; 73 | justify-content: center; 74 | margin: 0; 75 | padding: 0; 76 | background: #2e2e2e; 77 | } 78 | 79 | pre { 80 | padding: 1em; 81 | display: inline-block; 82 | text-align: left; 83 | margin: 0 auto !important; 84 | background: rgba(138, 109, 109, 0.3); 85 | border-radius: 10px; 86 | } -------------------------------------------------------------------------------- /scripts/MovingAverage.cleanup.zsh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | 3 | local sdir="${0:a:h}" 4 | 5 | #source "${sdir}/../.env" 6 | 7 | local timeAgo="`date --date='45 days ago' -u +'%Y-%m-%d'`" 8 | 9 | local scriptSelect="$sdir"/sql/MovingAverage.SELECT.created_ASC_LIM_2.sql 10 | local scriptDelete="$sdir"/sql/MovingAverage.DELETE_550k.sql 11 | local scriptClean="$sdir"/sql/DATABASE.clean.sql 12 | 13 | local psqlArgs=( 14 | "--quiet" 15 | "--tuples-only" 16 | ) 17 | 18 | for i in $(seq 0 256); do 19 | local select1=`psql "${psqlArgs[@]}" -f "$scriptSelect" | head -n1` 20 | local latestDate=`echo "$select1" | cut -f2 -d'|' | grep -oe '[0-9-]\+' | head -n1` 21 | if [[ $latestDate > $timeAgo ]]; then 22 | echo "DONE :)! $latestDate > $timeAgo" 23 | psql --echo-all "${psqlArgs[@]}" -f "$scriptClean" 24 | exit 0 25 | else 26 | echo "KEEP GOING D=! $latestDate <= $timeAgo" 27 | fi 28 | for j in $(seq 0 64); do 29 | echo $i - $j 30 | psql "${psqlArgs[@]}" -f "$scriptDelete" 31 | done 32 | done 33 | -------------------------------------------------------------------------------- /scripts/pg_stat_statements.sql: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | 3 | local sdir="${0:a:h}" 4 | 5 | read -r -d '' cmd <<'EOF' 6 | psql --quiet --tuples-only --echo-queries -c \ 7 | "CREATE extension pg_stat_statements;" 8 | EOF 9 | 10 | sudo su postgres -c "$cmd" 11 | -------------------------------------------------------------------------------- /scripts/sql/DATABASE.clean.sql: -------------------------------------------------------------------------------- 1 | VACUUM FULL VERBOSE ANALYZE "MovingAverage"; 2 | VACUUM FULL VERBOSE ANALYZE "Message"; 3 | VACUUM FULL VERBOSE ANALYZE "Reaction"; 4 | 5 | --REINDEX TABLE "MovingAverage"; 6 | --REINDEX TABLE "Message"; 7 | --REINDEX TABLE "Reaction"; 8 | 9 | REINDEX DATABASE d39ddcnoeocgek; 10 | -------------------------------------------------------------------------------- /scripts/sql/MovingAverage.DELETE_550k.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM "MovingAverage" WHERE id IN (SELECT id FROM "MovingAverage" ORDER BY id ASC LIMIT 550000); 2 | -------------------------------------------------------------------------------- /scripts/sql/MovingAverage.SELECT.created_ASC_LIM_2.sql: -------------------------------------------------------------------------------- 1 | SELECT id,created FROM "MovingAverage" ORDER BY created ASC LIMIT 2; -------------------------------------------------------------------------------- /scripts/statement_timeout.sql.zsh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | 3 | local sdir="${0:a:h}" 4 | 5 | psql --quiet --tuples-only --echo-queries -c \ 6 | "ALTER ROLE ${PGUSER:-${1:-$USER}} SET statement_timeout TO '${2:-90}s'"; 7 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import app, { prisma, receiver } from './server' 2 | import express, { Request, Response, NextFunction } from 'express' 3 | import { maPool, masStats, maStats } from './convos' 4 | import cors from 'cors' 5 | 6 | receiver.router.use(express.static('public')) 7 | 8 | receiver.router.get(`/api/demo`, async (req: Request, res: Response, next: NextFunction) => { 9 | res.json({}) 10 | }) 11 | 12 | receiver.router.get(`/api/demo-channel-lookup/:id`, async (req: Request, res: Response, next: NextFunction) => { 13 | res.json({}) 14 | }) 15 | 16 | 17 | receiver.router.get(`/api/convos`, cors(), (req: Request, res: Response, next: NextFunction) => { 18 | res.json(masStats(maPool)) 19 | }) 20 | 21 | 22 | receiver.router.get(`/api/convo/:id`, cors(), (req: Request, res: Response, next: NextFunction) => { 23 | res.json({}) 24 | }) 25 | 26 | 27 | const ARGS = { 28 | convos: { 29 | top: { 30 | key: { 31 | valid: [ 'average', 'variance', 'deviation', 'forecast', 'messages', ], 32 | default: 'average', 33 | }, 34 | take: { 35 | valid: {min: 0, max: 256}, 36 | default: 10, 37 | }, 38 | }, 39 | }, 40 | top: { 41 | emoji: { 42 | valid: {} 43 | }, 44 | channels: {}, 45 | users: {}, 46 | }, 47 | } 48 | 49 | 50 | receiver.router.get(`/api/convos/top`, cors(), async (req: Request, res: Response, next: NextFunction) => { 51 | res.json({}) 52 | }) 53 | 54 | 55 | // "the most popular emoji (in the past hour) (by reaction frequency, descending) [in b-flat]" 56 | receiver.router.get(`/api/top/emoji`, cors(), async (req: Request, res: Response, next: NextFunction) => { 57 | res.json({}) 58 | }) 59 | 60 | 61 | // "the most active users (in the past hour) (by message frequency, descending) {zfogg remix}" 62 | receiver.router.get(`/api/top/users`, cors(), async (req: Request, res: Response, next: NextFunction) => { 63 | res.json({}) 64 | }) 65 | 66 | 67 | // "the most active channels (in the past hour) (by message frequency, descending) " 68 | receiver.router.get(`/api/top/channels`, cors(), async (req: Request, res: Response, next: NextFunction) => { 69 | res.json({}) 70 | }) 71 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import { GenericMessageEvent, SayArguments } from '@slack/bolt' 2 | import app, { prisma } from './server' 3 | import { maPool, masStats, maStats, getMa } from './convos' 4 | import { channelMaBlocks, userMaBlocks } from './home' 5 | 6 | 7 | const BOT_NAME = `paulb` 8 | 9 | export const BOT_ID = process.env.BOT_ID || `U01S7UUCB89` 10 | 11 | const BOT_CHID = process.env.BOT_CHID || `CGSEAP135` 12 | 13 | const USER_IDS = { 14 | zrl: 'U0266FRGP', 15 | zfogg: 'U01DV5F30CF', 16 | } 17 | 18 | //* if you run \`/sup\`, i'll tell you what _'sup_ in slack. you can run \`/sup 600\` to see what 'sup over the past 10 hours. 19 | //* if you run \`/supwit [:emoji:, #channel, @user]\` i'll tell you what _'sup wit_ that thing. i'll tell you what 'sup wit other stuff in slack if you don't give me anything specific (\`/supwit\`). i'll tell you what 'sup wit stuff over the past 1440 minutes by default, but you can pass in a different number of minutes like \`/supwit 120\` or \`/supwit 120 #lounge\` \`/supwit 120 <@${USER_IDS.zfogg}>\` or \`/supwit 120 :upvote:\` for two hours. 20 | const MSGS = { 21 | joinChannel: `:wave: hi! :wave: i'm a bot by \`<@${USER_IDS.zfogg}>\` that streams and logs slack activity so people can easily discover new channels. :canadaparrot: 22 | 23 | don't want your channel (or your account) to be part of this? that's ok! just type \`<@${BOT_ID}> disable me\` to have me ignore all of your messages or \`<@${BOT_ID}> disable channel\` to have me ignore this whole channel. 24 | 25 | if you want to re-enable streaming, you can type \`<@${BOT_ID}> enable me\` or \`<@${BOT_ID}> enable channel\` and if you want to check whether i'm streaming, you can type \`<@${BOT_ID}> status me\` or \`<@${BOT_ID}> status channel\`. 26 | 27 | send the message \`<@${BOT_ID}> stats me\` or \`<@${BOT_ID}> stats channel\` to see information about yourself or the channel you're in. you can find channel info on this bot's home tab! (click my username 😎). 28 | 29 | i'll never stream private messages, group chats, or private channels. message \`<@${USER_IDS.zfogg}>\` if you have any questions. happy hacking! 30 | 31 | you can find me at \`https://github.com/hackclub/bunyan\`!`, 32 | 33 | help: `:axe: <@${BOT_ID}> is a Californian surfer and lumberjack whose best friend is a blue ox :ox:. 34 | this guy is super into logging, so he always knows what ’sup around slack. 35 | 36 | 1. \`/sup\` - bunyan will tell you what ’sup in slack (over the past 120mins). 37 | * \`/sup 1440\` What ’sup over the past day (1 day = 1440 minutes) 38 | * \`/sup 600\` What ’sup over the past 10 hours (10 hours = 600 minutes). 39 | 40 | 2. \`/supwit\` - run with an emoji, channel, or user, and bunyan will tell you what ’sup wit that thing! (\`/supwit :upvote:\` , \`/supwit #lounge\`, \`/supwit @scrappy\`) 41 | * \`/supwit\` What 'sup wit Hack Club tho? 42 | * \`/supwit 60\` What 'sup wit Hack Club in the past 60 minutes? 43 | * \`/supwit @orpheus\` Where has @orpheus been active recently? 44 | * \`/supwit 45 :yay:\` Where has the :yay: reaction been used in the last 45 min? 45 | * \`/supwit 120 #lounge\` What’s been happening in #lounge over the past 2 hours? 46 | 47 | <@${BOT_ID}> only logs interaction times, not their contents, not private channels or DMs, and you may opt out any time. 48 | * type \`<@${BOT_ID}> disable me\` to ignore all messages or \`<@${BOT_ID}> disable channel\` if don't want your channel (or account) to be part of this. 49 | * type \`<@${BOT_ID}> enable me\` or \`<@${BOT_ID}> enable channel\` to re-enable this. 50 | * type \`<@${BOT_ID}> status me\` or \`<@${BOT_ID}> status channel\` to see what 'sup. 51 | 52 | made by <@${USER_IDS.zfogg}> with <3 at Hack Club HQ`, 53 | 54 | disableMe: `i will now ignore your messages`, 55 | enableMe: `i will now stream your messages`, 56 | 57 | disableChannel: `i will now ignore this channel's messages`, 58 | enableChannel: `i will now stream this channel's messages`, 59 | 60 | statusMeIgnoring: `i am ignoring your messages`, 61 | statusMeListening: `i am streaming your messages`, 62 | 63 | statusChannelIgnoring: `i am ignoring this channel's messages`, 64 | statusChannelListening: `i am streaming this channel's messages`, 65 | 66 | status: `up an' loggin'` 67 | } 68 | 69 | 70 | async function setWatching(slack_type: 'user' | 'channel', id: string, onOff: boolean) { 71 | const ma = maPool[id] 72 | if (!(id in maPool) || typeof ma === 'undefined') { 73 | throw new Error(`resource with id '${id}' is unknown`) 74 | } 75 | try { // FIXME: this should happen in bulk 76 | if (slack_type === 'user') { 77 | await prisma.user.upsert({ 78 | where: { id, }, 79 | create: { id, watching: onOff }, 80 | update: { watching: onOff }, 81 | }) 82 | } else if (slack_type === 'channel') { 83 | await prisma.channel.upsert({ 84 | where: { id, }, 85 | create: { id, watching: onOff }, 86 | update: { watching: onOff }, 87 | }) 88 | } 89 | await prisma.slackResource.upsert({ 90 | where: { id, }, 91 | create: { id, watching: onOff }, 92 | update: { watching: onOff }, 93 | }) 94 | } catch (e) { 95 | console.error(e) 96 | } 97 | return ma.watching = onOff 98 | } 99 | 100 | async function statusWatching(id: string) { 101 | return maPool[id]?.watching 102 | } 103 | 104 | 105 | //app.message(RegExp(`^<@${BOT_ID}> help`, `i`), async ({ message, client, logger, context }) => { 106 | app.event('app_mention', async ({ event, say, client, context, logger }) => { 107 | if (!event.text.startsWith(`<@${BOT_ID}>`)) { 108 | return 109 | } 110 | 111 | if ('user' in event && event.user !== undefined) { 112 | // Not all messages have a user FIXME 113 | 114 | let arg 115 | let msg = '' 116 | 117 | const message = event 118 | const _message = (message as unknown) as GenericMessageEvent 119 | const thread_ts = _message.thread_ts || _message.ts 120 | 121 | if (arg = event.text.match(RegExp(`^<@${BOT_ID}> help`, `i`))) { 122 | msg = MSGS.help 123 | } else if (arg = event.text.match(RegExp(`^<@${BOT_ID}> r u good bruh`, 'i'))) { 124 | msg = MSGS.status 125 | } else if (arg = event.text.match(RegExp(`^<@${BOT_ID}> (enable|disable) (me|channel)`, `i`))) { 126 | try { 127 | const meOrChan = arg[2] 128 | const onOrOff = arg[1] 129 | switch (`${meOrChan}.${onOrOff}`) { 130 | case `me.disable`: 131 | await setWatching('user', event.user, false) 132 | msg = MSGS.disableMe 133 | break 134 | case `me.enable`: 135 | await setWatching('user', event.user, true) 136 | msg = MSGS.enableMe 137 | break 138 | case `channel.disable`: 139 | await setWatching('channel', event.channel, false) 140 | msg = MSGS.disableChannel 141 | break 142 | case `channel.enable`: 143 | await setWatching('channel', event.channel, true) 144 | msg = MSGS.enableChannel 145 | break 146 | default: 147 | throw new Error('unknown arg matches') 148 | } 149 | } catch (e) { 150 | console.error(msg = 'could not enable/disable. check your command?', event, e) 151 | } 152 | 153 | } else if (arg = event.text.match(RegExp(`^<@${BOT_ID}> status (me|channel)`, `i`))) { 154 | switch (arg[1]) { 155 | case `me`: 156 | const meOnOff = await statusWatching(event.user) 157 | msg = meOnOff ? MSGS.statusMeListening : MSGS.statusMeIgnoring 158 | break 159 | case `channel`: 160 | const chOnOff = await statusWatching(event.channel) 161 | msg = chOnOff ? MSGS.statusChannelListening : MSGS.statusChannelIgnoring 162 | break 163 | default: 164 | console.error(msg = 'could not get status. check your command?', event) 165 | } 166 | 167 | } else if (arg = event.text.match(RegExp(`^<@${BOT_ID}> stats (me|channel)`, `i`))) { 168 | let blocks:any 169 | switch (arg[1]) { 170 | case `me`: 171 | const usMa = getMa(event.user) 172 | if (usMa === undefined || usMa.ma == undefined) { break } 173 | const usMaStats = maStats(event.user, usMa.ma, usMa.iMsgs) 174 | blocks = userMaBlocks(event.user, usMaStats, usMa.watching) 175 | await say({blocks: blocks as any, thread_ts} as any) 176 | break 177 | case `channel`: 178 | const chMa = getMa(event.channel) 179 | if (chMa === undefined || chMa.ma == undefined) { break } 180 | const chMaStats = maStats(event.channel, chMa.ma, chMa.iMsgs) 181 | blocks = channelMaBlocks(event.channel, chMaStats, chMa.watching) 182 | await say({blocks: blocks as any, thread_ts} as any) 183 | break 184 | default: 185 | logger.error(msg = 'could not get status. check your command?', event) 186 | await say({text: msg, thread_ts}) 187 | } 188 | return 189 | } 190 | 191 | if (msg.length === 0) { 192 | msg = `:axe: check your command :sad-yeehaw:\n(try \`<@${BOT_ID}> help\`)` // FIXME lol proper error handling 193 | } 194 | 195 | app.client.reactions.add({ 196 | token: context.botToken, 197 | name: 'axe', 198 | channel: event.channel, 199 | timestamp: event.ts, 200 | }) 201 | app.client.reactions.add({ 202 | token: context.botToken, 203 | name: 'thread', 204 | channel: event.channel, 205 | timestamp: event.ts, 206 | }) 207 | 208 | await client.chat.postMessage({ // .postEphemeral 209 | channel: event.channel, 210 | user: event.user, 211 | text: msg, 212 | thread_ts, 213 | }) 214 | 215 | } 216 | }) 217 | 218 | 219 | // FIXME: combine these into one function? 220 | app.event('channel_unarchive', async ({ event, say, client, context, logger }) => { 221 | logger.info('CHANNEL UNARCHIVED', event) 222 | try { 223 | await client.conversations.join({channel: event.channel}) 224 | await prisma.channel.create({data: {id: event.channel}, }) 225 | } catch (e) { 226 | logger.error(e) 227 | } 228 | }) 229 | app.event('channel_created', async ({ event, say, client, context, logger }) => { 230 | logger.info('CHANNEL CREATED', event.channel) 231 | try { 232 | await client.conversations.join({channel: event.channel.id}) 233 | await prisma.channel.create({data: {id: event.channel.id}, }) 234 | } catch (e) { 235 | logger.error(e) 236 | } 237 | }) 238 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { Tedis, TedisPool } from "tedis"; 2 | 3 | 4 | function newConfig(redisHost: string, redisPort: number) { 5 | try { 6 | const tedis = new Tedis({ 7 | host: redisHost, 8 | port: redisPort, 9 | }); 10 | return tedis 11 | } catch (e) { 12 | console.error(e) 13 | throw e 14 | } 15 | } 16 | 17 | 18 | async function userActive(t: Tedis, id: string) { 19 | try { 20 | const _userActive = await t.get(id) 21 | return typeof _userActive === "string" || typeof _userActive === "number" 22 | } catch (e) { 23 | console.error(e) 24 | return false 25 | } 26 | } 27 | 28 | 29 | async function enableUser(t: Tedis, id: string) { 30 | try { 31 | await t.del(id) 32 | } catch (e) { 33 | throw e; console.error(e) 34 | } 35 | } 36 | 37 | 38 | async function disableUser(t: Tedis, id: string) { 39 | try { 40 | await t.set(id, JSON.stringify(true)) 41 | } catch (e) { 42 | throw e; console.error(e) 43 | } 44 | } 45 | 46 | 47 | async function channelActive(t: Tedis, id: string) { 48 | try { 49 | const _channelActive = await t.get(id) 50 | return typeof _channelActive === "string" || typeof _channelActive === "number" 51 | } catch (e) { 52 | console.error(e) 53 | return false 54 | } 55 | } 56 | 57 | 58 | async function enableChannel(t: Tedis, id: string) { 59 | try { 60 | await t.del(id) 61 | } catch (e) { 62 | console.error(e); throw e 63 | } 64 | } 65 | 66 | 67 | async function disableChannel(t: Tedis, id: string) { 68 | try { 69 | await t.set(id, JSON.stringify(true)) 70 | } catch (e) { 71 | console.error(e); throw e 72 | } 73 | } 74 | 75 | 76 | async function storeIPInfo(t: Tedis, info: any) { 77 | try { 78 | const encoded = JSON.stringify(info) 79 | await t.set(`ip/${info.IP}`, encoded) 80 | } catch (e) { 81 | console.error(e); throw e 82 | } 83 | } 84 | 85 | 86 | async function getIPInfo(t: Tedis, ip: string) { 87 | try { 88 | const ipinfoStr = await t.get(`ip/${ip}`) 89 | if (ipinfoStr === null || typeof ipinfoStr === "number") { 90 | return [{IP: ""}, false, null] 91 | } else { 92 | const info = JSON.parse(ipinfoStr) 93 | return [info, true, null] 94 | } 95 | } catch (e) { 96 | console.error(e); throw e 97 | } 98 | } 99 | 100 | 101 | async function storeUserIP(t: Tedis, userId: string, ip: string) { 102 | try { 103 | await t.set(`userip/${userId}`, ip) 104 | } catch (e) { 105 | console.error(e); throw e 106 | } 107 | } 108 | 109 | async function getUserIp(t: Tedis, userId: string) { 110 | try { 111 | const ip = await t.get(`userip/${userId}`) 112 | if (ip !== null && typeof ip !== "number") { 113 | return [ip, false, null] 114 | } else { 115 | return ["", true, null] 116 | } 117 | } catch (e) { 118 | console.error(e); throw e 119 | } 120 | } 121 | 122 | 123 | async function getUserIPInfo(t: Tedis, userId: string) { 124 | try { 125 | const ip = await getUserIp(t, userId) 126 | if (ip === null || typeof ip === "number") { 127 | return [{IP: ip}, false, null] 128 | } else { 129 | //const info = JSON.parse(ip) 130 | return [{IP: ""}, true, null] 131 | } 132 | } catch (e) { 133 | console.error(e); throw e 134 | } 135 | } 136 | 137 | 138 | async function registerActiveUserInChannel(t: Tedis, channelId: string, userId: string) { 139 | await t.sadd(`active_channel_members/${channelId}`, userId) 140 | } 141 | 142 | 143 | async function getActiveUsersInChannel(t: Tedis, channelId: string) { 144 | return await t.smembers(`active_channel_members/${channelId}`) 145 | } 146 | -------------------------------------------------------------------------------- /src/convos.ts: -------------------------------------------------------------------------------- 1 | import { GenericMessageEvent } from '@slack/bolt' 2 | import MA, { MovingAverage } from './ma' 3 | import app, { prisma } from './server' 4 | 5 | 6 | export const MA_INTERVAL = process.env.MA_INTERVAL 7 | ? parseInt(process.env.MA_INTERVAL, 10) 8 | //: 1000 * 10 * 1 // 10 seconds 9 | //: 1000 * 30 * 1 // 30 seconds 10 | //: 1000 * 60 * 1 // one minute 11 | //: 1000 * 90 * 1 // 90 seconds 12 | : 1000 * 60 * 5 // five minutes 13 | //: 1000 * 60 * 30 // thirty minutes 14 | 15 | 16 | export type MaPool = { 17 | [key: string]: { // a slack resource id 18 | ma: any // a moving average instance 19 | iMsgs: number // this interval's (pending) message count 20 | oMsgs: number // old (previously processed) message count 21 | watching: boolean // are we tracking this resource? 22 | } 23 | } 24 | 25 | 26 | export const maPool: MaPool = {} 27 | 28 | export function getMa(chId: string) { 29 | if (typeof chId !== 'string') { throw new Error(`invalid slack id '${chId}'`) } 30 | if (!maPool[chId]) { 31 | prisma.slackResource.upsert({ 32 | where: { id: chId }, 33 | create: { id: chId }, 34 | update: { }, 35 | }) 36 | maPool[chId] = { 37 | ma: MA(MA_INTERVAL), 38 | iMsgs: 0, 39 | oMsgs: 0, 40 | watching: true, 41 | } 42 | } 43 | return maPool[chId] 44 | } 45 | 46 | 47 | export async function pushMas(mas: MaPool, now: Date | number) { 48 | console.log('try pushing mas') 49 | //if (initialPull === true) { return } 50 | const upsertData = [] 51 | for (const [chId, chMa] of Object.entries(mas)) { 52 | if (chMa.watching === false) { console.log('not watching', chId); continue } 53 | const stats = maStats(chId, chMa.ma, chMa.iMsgs) 54 | stats.messages = chMa.iMsgs 55 | chMa.ma.push(now, chMa.iMsgs) 56 | if (chMa.iMsgs > 0) { 57 | console.log(chMa.ma, stats) 58 | } 59 | chMa.oMsgs += chMa.iMsgs 60 | chMa.iMsgs = 0 61 | const upsertRow = { ...stats } 62 | upsertData.push(upsertRow) 63 | } 64 | try { 65 | await Promise.all( 66 | upsertData.map(row => { 67 | return prisma.slackResource.upsert({ 68 | where: { id: row.slack_id }, 69 | create: { id: row.slack_id }, 70 | update: { }, 71 | }).then((row: any) => { 72 | prisma.movingAverage.create({data: row,}) 73 | }).catch((e) => { 74 | console.error(e, JSON.stringify(row, null, 2)) 75 | }) 76 | }) 77 | ).then((rows) => { 78 | console.info('done pushing mas!', rows.length) 79 | }) 80 | } catch (e) { 81 | console.error(e) 82 | } 83 | } 84 | 85 | export async function pullMas() { 86 | 87 | const maResources = await prisma.movingAverage.groupBy({ 88 | by: ['slack_id'], 89 | where: { messages: { gt: 0 }, }, 90 | }) 91 | console.log('mas to pull: length', maResources.length) 92 | 93 | const slackResources = await prisma.slackResource.findMany({ 94 | select: { id: true, }, 95 | where: { 96 | watching: { equals: true, }, 97 | id: { in: maResources.map(({slack_id}) => slack_id) }, 98 | }, 99 | }) 100 | 101 | const pas = slackResources.map((_sa) => { 102 | return prisma.movingAverage.findFirst({ 103 | where: { slack_id: { equals: _sa.id, }, }, 104 | orderBy: { created: 'desc', }, 105 | }).then((_ma) => { 106 | if (_ma === null) { 107 | throw new Error(`broken movingAverage record for '${_sa.id}'`) 108 | } 109 | console.info(`pulled ${_ma.slack_id}`) 110 | maPool[_ma.slack_id] = { 111 | iMsgs: 0, 112 | oMsgs: 0, 113 | watching: true, 114 | ma: MA(MA_INTERVAL).create( 115 | _ma.average.toNumber(), 116 | _ma.variance.toNumber(), 117 | _ma.deviation.toNumber(), 118 | _ma.forecast.toNumber(), 119 | new Date(_ma.created).getTime(), 120 | ), 121 | } 122 | }).catch((e) => { 123 | console.error(`error with ${_sa.id}`, e) 124 | }) 125 | }) 126 | 127 | try { 128 | console.log('pulling mas!', slackResources.length) 129 | await Promise.all(pas) 130 | console.log(`done pulling mas!`) 131 | } catch (e) { 132 | console.error('error pulling mas!', e) 133 | } 134 | 135 | //getMa(_sa.id) 136 | } 137 | 138 | export type MaStat = { 139 | slack_id: string 140 | average: string 141 | variance: string 142 | deviation: string 143 | forecast: string 144 | messages: number 145 | } 146 | 147 | export function maStats(slack_id: string, ma: MovingAverage, messages: number | undefined) { 148 | const stats: MaStat = { 149 | slack_id, 150 | average: (ma.average() || 0).toString(), 151 | variance: (ma.variance() || 0).toString(), 152 | deviation: (ma.deviation() || 0).toString(), 153 | forecast: (ma.forecast() || 0).toString(), 154 | messages: (messages || 0) 155 | } 156 | return stats 157 | } 158 | 159 | export function masStats(mas: MaPool) { 160 | const _masStats: MaStat[] = [] 161 | for (const [chId, chMa] of Object.entries(maPool)) { 162 | const stats = maStats(chId, chMa.ma, chMa.iMsgs) 163 | _masStats.push(stats) 164 | } 165 | return _masStats 166 | } 167 | 168 | 169 | app.message(/./, async ({ message, say, logger }) => { 170 | if ('user' in message && message.user !== undefined) { // Not all messages have a user FIXME 171 | const _message = message as GenericMessageEvent 172 | const thread_ts = _message.thread_ts || _message.ts 173 | //logger.info('❗ user message ❗', message) 174 | 175 | const chId = message.channel 176 | const chMa = getMa(chId) // a moving average for a user 177 | const usMa = getMa(message.user) // a moving average for a channel 178 | if (typeof chMa === 'undefined') { throw new Error(`undefined maPool '${chId}'`) } 179 | if (typeof usMa === 'undefined') { throw new Error(`undefined maPool '${message.user}'`) } 180 | chMa.iMsgs += 1 181 | usMa.iMsgs += 1 182 | } 183 | }) 184 | 185 | -------------------------------------------------------------------------------- /src/home.ts: -------------------------------------------------------------------------------- 1 | import app from './server' 2 | import { maPool, pushMas, pullMas, maStats, MaStat } from './convos' 3 | import { sortedMas, nonzeroMas } from './util' 4 | 5 | 6 | app.event('app_home_opened', async ({ event, client, context }) => { 7 | const emaBlocks = [] 8 | for (const [chId, chMa] of sortedMas(maPool)) { 9 | if (chId.startsWith('U') || chMa.ma.average() <= 0) { continue } 10 | const maStat = maStats(chId, chMa.ma, chMa.iMsgs) 11 | emaBlocks.push(...channelMaBlocks(chId, maStat, chMa.watching)) 12 | emaBlocks.push({"type": "divider"}) 13 | } 14 | emaBlocks.pop() // FIXME: this is to get rid of the final divider lmao 15 | 16 | try { 17 | /* view.publish is the method that your app uses to push a view to the Home tab */ 18 | const result = await client.views.publish({ 19 | /* the user that opened your app's app home */ 20 | user_id: event.user, 21 | /* the view object that appears in the app home*/ 22 | view: { 23 | type: 'home', 24 | callback_id: 'home_view', 25 | 26 | /* body of the view */ 27 | blocks: [ 28 | { 29 | "type": "section", 30 | "text": { 31 | "type": "mrkdwn", 32 | "text": "*Exponential moving averages (EMAs) by channel*\n\n_'Enabled' and 'Disable' do NOT work yet!_\n\n_'Watching' also does not work yet!_\n\nSend a PR or plz wait :)" 33 | } 34 | }, 35 | { 36 | "type": "divider" 37 | }, 38 | ] 39 | .concat(emaBlocks) // FIXME: this is ugly lol 40 | } 41 | }) 42 | } 43 | 44 | catch (error) { 45 | console.error(error) 46 | } 47 | }) 48 | 49 | 50 | export function userMaBlocks(slackId: string, maStat: MaStat, watching: boolean) { 51 | return [ 52 | { 53 | "type": "context", 54 | "elements": [ 55 | { 56 | "type": "mrkdwn", 57 | "text": `User tracked: ${watching ? '✔️' : '🚫'}` 58 | }, 59 | ] 60 | }, 61 | { 62 | "type": "section", 63 | "text": { 64 | "type": "mrkdwn", 65 | "text": `*<@${slackId}>*\nAverage: *${maStat.average}*\nVariance: *${maStat.variance}*\nDeviation: *${maStat.deviation}*\nForecast: *${maStat.forecast}*` 66 | }, 67 | }, 68 | ] 69 | } 70 | 71 | 72 | export function channelMaBlocks(slackId: string, maStat: MaStat, watching: boolean) { 73 | return [ 74 | { 75 | "type": "context", 76 | "elements": [ 77 | { 78 | "type": "mrkdwn", 79 | "text": `Channel tracked: ${watching ? '✔️' : '🚫'}` 80 | }, 81 | ] 82 | }, 83 | { 84 | "type": "section", 85 | "text": { 86 | "type": "mrkdwn", 87 | "text": `*<#${slackId}>*: Average: *${Math.exp(parseFloat(maStat.average)).toFixed(4)}* | Variance: *${Math.exp(parseFloat(maStat.variance)).toFixed(4)}* | Deviation: *${Math.exp(parseFloat(maStat.deviation)).toFixed(4)}* | Forecast: *${Math.exp(parseFloat(maStat.forecast)).toFixed(4)}*` 88 | }, 89 | }, 90 | 91 | //{ // TODO: implement these with the `.watching` property 92 | //"type": "actions", 93 | //"elements": [ 94 | //{ 95 | //"type": "button", 96 | //"text": { 97 | //"type": "plain_text", 98 | //"text": "Enable", 99 | //"emoji": true 100 | //}, 101 | //"style": "primary", 102 | //"value": "approve" 103 | //}, 104 | //{ 105 | //"type": "button", 106 | //"text": { 107 | //"type": "plain_text", 108 | //"text": "Disable", 109 | //"emoji": true 110 | //}, 111 | //"style": "danger", 112 | //"value": "decline" 113 | //}, 114 | //] 115 | //}, 116 | ] 117 | } 118 | 119 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | if (process.env.NODE_ENV !== 'production') { 3 | dotenv.config() 4 | } 5 | 6 | //import './config' 7 | import './server' 8 | import './api' 9 | import './home' 10 | import './slashcmd' 11 | import './reactions' 12 | import './messages' 13 | 14 | import main from './main' 15 | import { prisma } from './server' 16 | main() 17 | .catch(e => { 18 | throw e 19 | }) 20 | .finally(async () => { 21 | await prisma.$disconnect() 22 | }) 23 | process.on('SIGINT', async () => { 24 | console.log("Bye bye!") 25 | //await prisma.$disconnect() 26 | process.exit() 27 | }) 28 | 29 | export default {} 30 | -------------------------------------------------------------------------------- /src/ma.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // INFO: https://raw.githubusercontent.com/pgte/moving-average/master/index.js 4 | 5 | 6 | const exp = Math.exp 7 | 8 | 9 | export type MovingAverage = { 10 | create: (average: number, variance: number, deviation: number, forecase: number, previous: number) => MovingAverage 11 | push: (time: number, value: number) => void 12 | average: () => number 13 | variance: () => number 14 | deviation: () => number 15 | forecast: () => number 16 | } 17 | 18 | 19 | export default function MA(timespan: number) { 20 | if (typeof timespan !== 'number') { throw new Error('must provide a timespan to the moving average constructor') } 21 | 22 | if (timespan <= 0) { throw new Error('must provide a timespan > 0 to the moving average constructor') } 23 | 24 | let ma: number // moving average 25 | let v: number = 0 // variance 26 | let d: number = 0 // deviation 27 | let f: number = 0 // forecast 28 | 29 | let previousTime: number 30 | 31 | function alpha (t: number, pt: number) { 32 | return 1 - (exp(-(t - pt) / timespan)) 33 | } 34 | 35 | let ret: MovingAverage = { 36 | create: (average: number, variance: number, deviation: number, forecast: number, previous: number) => { 37 | ma = average 38 | v = variance 39 | d = deviation 40 | f = forecast 41 | previousTime = previous 42 | return ret 43 | }, 44 | 45 | push: (time, value) => { 46 | if (previousTime) { 47 | // calculate moving average 48 | const a = alpha(time, previousTime) 49 | const diff = value - ma 50 | const incr = a * diff 51 | ma = a * value + (1 - a) * ma 52 | // calculate variance & deviation 53 | v = (1 - a) * (v + diff * incr) 54 | d = Math.sqrt(v) 55 | // calculate forecast 56 | f = ma + a * diff 57 | } else { 58 | ma = value 59 | } 60 | previousTime = time 61 | }, 62 | 63 | // Exponential Moving Average 64 | average: () => { return ma }, 65 | 66 | variance: () => { return v }, 67 | 68 | deviation: () => { return d }, 69 | 70 | forecast: () => { return f }, 71 | } 72 | 73 | return ret 74 | } 75 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import app, { prisma, io, io_http } from './server' 2 | import {maPool, pushMas, pullMas, masStats, MA_INTERVAL} from './convos' 3 | import './commands' 4 | 5 | 6 | const { HOST, PORT, PORT_WS, NODE_ENV } = process.env 7 | 8 | export default async function main() { 9 | await app.start({ 10 | host: (HOST ? HOST : '0.0.0.0'), 11 | port: (PORT ? parseInt(PORT) : 3000), 12 | }) 13 | console.log(`🪓 Paul Bunyan is logging on http://${HOST}:${PORT}/ in mode='${NODE_ENV}'!`) 14 | 15 | await pullMas() 16 | //await loopPushMas() 17 | setInterval(loopPushMas, MA_INTERVAL) 18 | } 19 | 20 | 21 | async function loopPushMas() { 22 | try { 23 | //await pullMas(maPool) 24 | //for (const maStats of masStats(maPool)) { console.log(JSON.stringify(maStats, null, 2)) } 25 | await pushMas(maPool, Date.now()) 26 | } catch (e) { 27 | console.error(e) 28 | } 29 | 30 | const stats = [] 31 | for (const maStat of masStats(maPool)) { 32 | if (parseFloat(maStat.average) > 0) { 33 | stats.push(maStat) 34 | } 35 | } 36 | if (stats.length > 0 && NODE_ENV !== 'production') { 37 | //console.log(stats.length, 'stats') 38 | //console.table(stats) 39 | } 40 | 41 | //console.table(masStats(maPool)) 42 | } 43 | -------------------------------------------------------------------------------- /src/messages.ts: -------------------------------------------------------------------------------- 1 | import { GenericMessageEvent } from '@slack/bolt' 2 | import MA, { MovingAverage } from './ma' 3 | import { snooze } from './util' 4 | import app, { prisma, io } from './server' 5 | import sha1 from 'sha1' 6 | //import { channels } from '../data/streamboot-data.old.json' 7 | 8 | 9 | app.message(/^zft1.*$/i, async ({ message, say, client, logger }) => { 10 | if (process.env.NODE_ENV !== 'production') { return } 11 | let userIds = [] 12 | try { 13 | userIds = JSON.parse((message as any).text.slice(5)) 14 | } catch (e) { 15 | console.log(message) 16 | console.error(e) 17 | return 18 | } 19 | const emails = [] 20 | for (let userId of userIds) { 21 | console.log('userId', userId) 22 | try { 23 | // Call the users.info method using the WebClient 24 | const result:any = await client.users.info({ user: userId, }) 25 | console.log(result.user.profile.display_name) 26 | emails.push(result.user.profile.email) 27 | } catch (error) { 28 | console.error(error) 29 | } 30 | } 31 | console.log(JSON.stringify(emails, null, 2)) 32 | console.log('DONE!') 33 | //console.log('tryna join all...') 34 | //if ((message as GenericMessageEvent).user !== 'U01DV5F30CF') { return } 35 | //const getPage = async (cursor?: string) => { 36 | //const { channels, response_metadata } = await client.users.conversations({ 37 | //user: 'USYUBKF1A' [> the fuzz <], 38 | //limit: 128, 39 | //cursor, 40 | //}) 41 | //console.log((channels as any[]).length) //; return 42 | ////console.table(channels.map((x) => x.id)) 43 | //console.log('JOINING ALL CHANNELS') 44 | //console.log('channels.length', (channels as any[]).length) 45 | //for (const channel of (channels as any[])) { 46 | //const inDb = await prisma.channel.findFirst({where: {id: channel.id}, }) 47 | //console.log(`joining??? id=${channel.id} channel=${channel.name}`) 48 | //if (inDb === null) { 49 | //try { 50 | //const res = await client.conversations.join({channel: channel.id}) 51 | //await prisma.channel.create({data: {id: channel.id}, }) 52 | //console.log(`JOINED ${(res as any).channel.id} ${(res as any).channel.name}`) 53 | //} catch (e) { 54 | //console.log(`fail :/ ${channel.id}`) 55 | //logger.error(e) 56 | //} 57 | //} 58 | //await snooze(2000) 59 | //} 60 | //if (response_metadata !== undefined && response_metadata.next_cursor) { 61 | //await snooze(5000) 62 | //await getPage(response_metadata.next_cursor) 63 | //logger.info('PAGE'); logger.info('PAGE'); logger.info('PAGE'); logger.info('PAGE'); 64 | //} else { 65 | //logger.info('DONE'); logger.info('DONE'); logger.info('DONE'); logger.info('DONE'); 66 | //} 67 | //} 68 | //await getPage() 69 | }) 70 | 71 | app.message(/./, async ({ message, say, client, logger }) => { 72 | if ('user' in message && message.user !== undefined) { // Not all messages have a user FIXME 73 | try { 74 | const _message = message as GenericMessageEvent 75 | const thread_ts = parseFloat(_message.thread_ts || _message.ts) || 0 76 | const ts = _message.ts 77 | const m = message, _m = _message 78 | setTimeout(async () => { 79 | const { channel } = await client.conversations.info({channel: message.channel}) 80 | const { user } = await client.users.info({user: message.user || ''}) 81 | logger.info(`!! ${(user as any).profile.display_name || '_'}@${(channel as any).name} ${m.user}@${m.channel}/${thread_ts}/${_m.ts}`) 82 | }, 0) 83 | 84 | const text = message && message.text || '' 85 | const length = message && message.text && message.text.length || 0 86 | const message$ = await prisma.message.create({ 87 | data: { 88 | thread_ts, ts, 89 | content_hash: sha1(text), 90 | content_length: length, 91 | user: { 92 | connectOrCreate: { 93 | create: {id: message.user}, 94 | where: {id: message.user}, 95 | }, 96 | }, 97 | channel: { 98 | connectOrCreate: { 99 | create: {id: message.channel}, 100 | where: {id: message.channel}, 101 | }, 102 | }, 103 | }, 104 | }) 105 | 106 | io.emit("messages", { message: message$, }) 107 | 108 | } catch (e) { 109 | logger.error(e) 110 | } 111 | } 112 | }) 113 | -------------------------------------------------------------------------------- /src/reactions.ts: -------------------------------------------------------------------------------- 1 | import { ReactionMessageItem } from '@slack/bolt' 2 | import MA, { MovingAverage } from './ma' 3 | import app, { prisma, io } from './server' 4 | 5 | 6 | app.event('reaction_added', async ({ event, context, body, client, logger }) => { 7 | try { 8 | const e = event 9 | setTimeout(async () => { 10 | const { channel } = await client.conversations.info({channel: (e as any).item.channel}) 11 | const { user } = await client.users.info({user: e.user}) 12 | logger.info(`:${e.reaction}: ${(user as any).profile.display_name || '_'}@${(channel as any).name} ${e.user}@${(e as any).item.channel}/${(e as any).item.ts}/${e.event_ts}`) 13 | }, 0) 14 | const reaction = await prisma.reaction.create({ 15 | data: { 16 | ts: parseFloat((e as any).item.ts) || 0, 17 | event_ts: parseFloat(e.event_ts) || 0, 18 | emoji: { 19 | connectOrCreate: { 20 | create: {id: event.reaction}, 21 | where: {id: event.reaction}, 22 | }, 23 | }, 24 | user: { 25 | connectOrCreate: { 26 | create: {id: event.user}, 27 | where: {id: event.user}, 28 | }, 29 | }, 30 | channel: { 31 | connectOrCreate: { 32 | create: {id: (event.item as any).channel}, 33 | where: {id: (event.item as any).channel}, 34 | }, 35 | }, 36 | }, 37 | }) 38 | 39 | io.emit("reaction", { reaction, }) 40 | 41 | } catch (e) { 42 | logger.error(e) 43 | } 44 | }) 45 | -------------------------------------------------------------------------------- /src/scripts/queries.ts: -------------------------------------------------------------------------------- 1 | //import { RelationalScalarFieldEnum } from '@types/prisma' 2 | import { prisma } from '../server' 3 | 4 | 5 | async function main() { 6 | // we will run tests over this date range 7 | const TIMELEN_day = 1000 * 60 * 60 * 24 // ms s m h d 8 | // gt = further into the past <---<--- 9 | const gt = new Date(Date.now() - TIMELEN_day*1.00) 10 | // lt = closer to the present --->---> 11 | const lt = new Date(Date.now() - TIMELEN_day*0.01) 12 | 13 | const topUsersForChannel = await TopUsersForChannel(['C01RNH6K9JS'], [gt, lt]) 14 | console.info('\ngiven a channel_id and (start, end)::datetime, show the most frequent USERS for that channel') 15 | console.table(topUsersForChannel) 16 | 17 | const topEmojiForChannel = await TopEmojiForChannel(['C01HZ3J359B'], [gt, lt]) 18 | console.info('\ngiven a channel_id and (start, end)::datetime, show the most frequent EMOJI for that channel') 19 | console.table(topEmojiForChannel) 20 | 21 | const topEmojiForUser = await TopEmojiForUser(['U01DV5F30CF'], [gt, lt]) 22 | console.info('\ngiven a user_id and (start, end)::datetime, show the most frequent EMOJI for that user') 23 | console.table(topEmojiForUser) 24 | 25 | const topChannelsForUser = await TopChannelsForUser(['U01DV5F30CF'], [gt, lt]) 26 | console.info('\ngiven a user_id and (start, end)::datetime, show the most frequent CHANNELS for that user') 27 | console.table(topChannelsForUser) 28 | 29 | const topChannelsForEmoji = await TopChannelsForEmoji(['peefest'], [gt, lt]) 30 | console.info('\ngiven an emoji_id and (start, end)::datetime, show the most frequent CHANNELS for that emoji') 31 | console.table(topChannelsForEmoji) 32 | 33 | const topUsersForEmoji = await TopUsersForEmoji(['peefest'], [gt, lt]) 34 | console.info('\ngiven an emoji_id and (start, end)::datetime, show the most frequent USERS for that emoji') 35 | console.table(topUsersForEmoji) 36 | } 37 | 38 | 39 | // {{{ 40 | export async function TopEmoji(take: number, [gt, lt]: Date[]) { 41 | const topEmoji = await prisma.reaction.groupBy({ 42 | by: ['emoji_id'], 43 | _count: {_all: true}, 44 | orderBy: {_count: {id: 'desc'}}, 45 | where: { 46 | created: { gt, lt, }, 47 | user: { watching: true }, 48 | channel: { watching: true }, 49 | }, 50 | take, 51 | }) 52 | //console.table(topEmoji) 53 | return topEmoji 54 | } 55 | // }}} 56 | 57 | 58 | // {{{ 59 | export async function TopMessagesBy(by: string[], take: number, [gt, lt]: Date[]) { 60 | const topMessagesBy = await prisma.message.groupBy({ 61 | by: by as any[], // ['emoji_id', 'channel_id', 'user_id'], // probably choose one idk 62 | _count: {_all: true}, 63 | orderBy: {_count: {id: 'desc'}}, 64 | where: { 65 | created: { gt, lt, }, 66 | user: { watching: true }, 67 | channel: { watching: true }, 68 | }, 69 | take, 70 | }) 71 | //console.table(topMessagesBy) 72 | return topMessagesBy 73 | } 74 | 75 | export async function TopReactionsBy(by: string[], take: number, [gt, lt]: Date[]) { 76 | const topReactionsBy = await prisma.reaction.groupBy({ 77 | by: by as any[], // ['emoji_id', 'channel_id', 'user_id'], // probably choose one idk 78 | _count: {_all: true}, 79 | orderBy: {_count: {id: 'desc'}}, 80 | where: { 81 | created: { gt, lt, }, 82 | user: { watching: true }, 83 | channel: { watching: true }, 84 | }, 85 | take, 86 | }) 87 | //console.table(topReactionsBy) 88 | return topReactionsBy 89 | } 90 | 91 | type TopForTmp = {[key: string]: {[key: string]: number}} 92 | type TopForItem = { 93 | emoji_id?: string 94 | channel_id?: string 95 | user_id?: string 96 | _count: {[key: string]: number} 97 | } 98 | export async function MergeGroups(by: string, xs: TopForItem[], ys: TopForItem[], key: string) { 99 | const tmp = {} 100 | xs.reduceRight(((acc: TopForTmp, stat: any, i) => { 101 | if (!acc) { throw new Error(`undefined acc for ${by} ${key}`) } 102 | if (!(stat[by] in acc)) { (acc as any)[stat[by]] = {_count: {[key]: 0}} }; 103 | (acc as any)[stat[by]]._count[key] += stat._count[key]; return acc 104 | }), tmp) 105 | ys.reduceRight(((acc: TopForTmp, stat: any, i) => { 106 | if (!acc) { throw new Error(`undefined acc for ${by} ${key}`) } 107 | if (!(stat[by] in acc)) { (acc as any)[stat[by]] = {_count: {[key]: 0}} }; 108 | (acc as any)[stat[by]]._count[key] += stat._count[key]; return acc 109 | }), tmp) 110 | const mergedSortedGroups = Object.entries(tmp) 111 | .map(([x, xstat]) => ({[by]: x, _count: (xstat as any)._count})) 112 | .sort((x, y) => ((y as any)._count[key] - (x as any)._count[key])) 113 | //console.table(mergedSortedGroups) 114 | return mergedSortedGroups 115 | } 116 | // }}} 117 | 118 | 119 | // {{{ 120 | export async function TopUsers(take: number, [gt, lt]: Date[]) { 121 | const topUsersByMessage = await TopMessagesBy(['user_id'], take, [gt as Date, lt as Date]) 122 | const topUsersByReaction = await TopReactionsBy(['user_id'], take, [gt as Date, lt as Date]) 123 | const topUsers = MergeGroups('user_id', topUsersByMessage, topUsersByReaction, '_all') 124 | //console.table(topUsers) 125 | return topUsers 126 | } 127 | // }}} 128 | 129 | 130 | // {{{ 131 | export async function TopChannels(take: number, [gt, lt]: Date[]) { 132 | const topChannelsByMessage = await TopMessagesBy(['channel_id'], take, [gt as Date, lt as Date]) 133 | const topChannelsByReaction = await TopReactionsBy(['channel_id'], take, [gt as Date, lt as Date]) 134 | const topChannels = MergeGroups('channel_id', topChannelsByMessage, topChannelsByReaction, '_all') 135 | //console.table(topChannels) 136 | return topChannels 137 | } 138 | // }}} 139 | 140 | 141 | // {{{ 142 | export async function TopUsersForChannel(channel_ids: string[], [gt, lt]: Date[]) { 143 | const topUsersForChannel = await prisma.message.groupBy({ 144 | by: ['user_id'], 145 | _count: {_all: true}, 146 | orderBy: {_count: {id: 'desc'}}, 147 | where: { 148 | channel_id: {in: channel_ids}, 149 | created: { gt, lt, }, 150 | user: { watching: true }, 151 | channel: { watching: true }, 152 | }, 153 | }) 154 | //console.table(topUsersForChannel) 155 | return topUsersForChannel 156 | } 157 | // }}} 158 | 159 | 160 | // {{{ 161 | export async function TopEmojiForChannel(channel_ids: string[], [gt, lt]: Date[]) { 162 | const topEmojiForChannel = await prisma.reaction.groupBy({ 163 | by: ['emoji_id'], 164 | _count: {_all: true}, 165 | orderBy: {_count: {id: 'desc'}}, 166 | where: { 167 | channel_id: {in: channel_ids}, 168 | created: { gt, lt, }, 169 | user: { watching: true }, 170 | channel: { watching: true }, 171 | }, 172 | }) 173 | //console.table(topEmojiForChannel) 174 | return topEmojiForChannel 175 | } 176 | // }}} 177 | 178 | 179 | // {{{ 180 | export async function TopEmojiForUser(user_ids: string[], [gt, lt]: Date[]) { 181 | const topEmojiForUser = await prisma.reaction.groupBy({ 182 | by: ['emoji_id'], 183 | _count: {_all: true}, 184 | orderBy: {_count: {id: 'desc'}}, 185 | where: { 186 | user_id: {in: user_ids}, 187 | created: { gt, lt, }, 188 | user: { watching: true }, 189 | channel: { watching: true }, 190 | }, 191 | }) 192 | //console.table(topEmojiForUser) 193 | return topEmojiForUser 194 | } 195 | // }}} 196 | 197 | 198 | // {{{ 199 | export async function TopChannelsForUser(user_ids: string[], [gt, lt]: Date[]) { 200 | // INFO: a user can do TWO things in a channel (message, react) 201 | // we gotta handle that.. 202 | // ... this is a lot of code but i'm just 203 | // 1. turning the two arrays into objects{[.slack_id]: {.count}}, 204 | // 2. and merging them by adding the `.count`s when channel_id appears in both 205 | // 3. turning the object back into single list of the same format 206 | // 4. sorting the list by `.count` 207 | 208 | const topChannelsForUser_messages = await prisma.message.groupBy({ 209 | by: ['channel_id'], 210 | _count: {_all: true}, 211 | orderBy: {_count: {id: 'desc'}}, 212 | where: { 213 | user_id: {in: user_ids}, 214 | created: { gt, lt, }, 215 | user: { watching: true }, 216 | channel: { watching: true }, 217 | }, 218 | }) //console.table(topChannelsForUser_messages) 219 | 220 | const topChannelsForUser_reactions = await prisma.reaction.groupBy({ 221 | by: ['channel_id'], 222 | _count: {_all: true}, 223 | orderBy: {_count: {id: 'desc'}}, 224 | where: { 225 | user_id: {in: user_ids}, 226 | created: { gt, lt, }, 227 | user: { watching: true }, 228 | channel: { watching: true }, 229 | }, 230 | }) //console.table(topChannelsForUser_reactions) 231 | 232 | // INFO: combine message and reaction count and sort by the result 233 | const _topChannelsForUser: {[key: string]: number} = {} 234 | topChannelsForUser_messages.reduceRight(((acc, {channel_id, _count}, i) => { 235 | if (!(channel_id in acc)) { acc[channel_id] = 0 }; acc[channel_id] += _count._all; return acc 236 | }), _topChannelsForUser) 237 | topChannelsForUser_reactions.reduceRight(((acc, {channel_id, _count}, i) => { 238 | if (!(channel_id in acc)) { acc[channel_id] = 0 }; acc[channel_id] += _count._all; return acc 239 | }), _topChannelsForUser) 240 | const topChannelsForUser: {channel_id: string, _count: number}[] = Object.entries(_topChannelsForUser) 241 | .map(([channel_id, _count]) => ({channel_id, _count})) 242 | .sort((x, y) => (y._count - x._count)) 243 | 244 | //console.table(topChannelsForUser) 245 | return topChannelsForUser 246 | } 247 | // }}} 248 | 249 | 250 | // {{{ 251 | export async function TopChannelsForEmoji(emoji_ids: string[], [gt, lt]: Date[]) { 252 | const topChannelsForEmoji = await prisma.reaction.groupBy({ 253 | by: ['channel_id'], 254 | _count: {_all: true}, 255 | orderBy: {_count: {id: 'desc'}}, 256 | where: { 257 | emoji_id: {in: emoji_ids}, 258 | created: { gt, lt, }, 259 | user: { watching: true }, 260 | channel: { watching: true }, 261 | }, 262 | }) 263 | //console.table(topChannelsForEmoji) 264 | return topChannelsForEmoji 265 | } 266 | // }}} 267 | 268 | 269 | // {{{ 270 | export async function TopUsersForEmoji(emoji_ids: string[], [gt, lt]: Date[]) { 271 | const topUsersForEmoji = await prisma.reaction.groupBy({ 272 | by: ['user_id'], 273 | _count: {_all: true}, 274 | orderBy: {_count: {id: 'desc'}}, 275 | where: { 276 | emoji_id: {in: emoji_ids}, 277 | created: { gt, lt, }, 278 | user: { watching: true }, 279 | channel: { watching: true }, 280 | }, 281 | }) 282 | //console.table(topUsersForEmoji) 283 | return topUsersForEmoji 284 | } 285 | // }}} 286 | 287 | 288 | //main() 289 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { App, LogLevel, SocketModeReceiver, ExpressReceiver } from '@slack/bolt' 2 | import fs from 'fs' 3 | import util from 'util' 4 | import path from 'path' 5 | 6 | import { PrismaClient } from '@prisma/client' 7 | export const prisma = new PrismaClient() 8 | 9 | import { createServer } from 'http' 10 | import { Server } from 'socket.io' 11 | 12 | 13 | export const io_http = createServer() 14 | export const io = new Server(io_http, { }) 15 | 16 | io.on('connection', async (socket) => { 17 | console.log('connect from client') 18 | socket.emit('hi') 19 | }) 20 | 21 | 22 | const writeFile = util.promisify(fs.writeFile) 23 | 24 | export const ENV = process.env 25 | 26 | const appOptions: any = { 27 | signingSecret: ENV.SIGNING_SECRET, 28 | //appId: ENV.APP_ID, 29 | //clientId: ENV.CLIENT_ID, 30 | //clientSecret: ENV.CLIENT_SECRET, 31 | //stateSecret: ENV.STATE_SECRET, 32 | } 33 | if (ENV.NODE_ENV === 'development') { 34 | // FIXME: make oauth work for dev 35 | 36 | const clientOptions = { 37 | //slackApiUrl: 'https://dev.slack.com/api/', 38 | } 39 | 40 | //appOptions.clientOptions = clientOptions 41 | appOptions.socketMode = false 42 | appOptions.appToken = ENV.APP_TOKEN 43 | appOptions.token = ENV.BOT_TOKEN 44 | 45 | } else { 46 | appOptions.token = ENV.BOT_TOKEN 47 | } 48 | 49 | appOptions.receiver = new ExpressReceiver({ signingSecret: ENV.SIGNING_SECRET ?? '' }) 50 | 51 | const app = new App(appOptions) 52 | 53 | export const receiver = appOptions.receiver 54 | 55 | //export const webClient = async (key: string, _args: any) => { 56 | //return app.client[key]({token: appOptions.token, ..._args }) 57 | //} 58 | 59 | export default app 60 | -------------------------------------------------------------------------------- /src/slashcmd.ts: -------------------------------------------------------------------------------- 1 | import { GenericMessageEvent, SayArguments } from '@slack/bolt' 2 | import app, { prisma, ENV } from './server' 3 | import { formatRelative, subMinutes } from 'date-fns' 4 | import { BOT_ID } from './commands' 5 | import { 6 | TopEmoji, TopChannels, TopUsers, 7 | TopUsersForChannel, TopEmojiForChannel, 8 | TopEmojiForUser, TopChannelsForUser, 9 | TopChannelsForEmoji, TopUsersForEmoji, 10 | } from './scripts/queries' 11 | 12 | 13 | const CMD = { 14 | sup: (ENV.NODE_ENV === 'development') ? '/dev-asdf' : '/sup', 15 | supwit: (ENV.NODE_ENV === 'development') ? '/dev-asdflol' : '/supwit', 16 | } 17 | 18 | app.command(CMD.sup, async ({ command, ack, client, body, respond, logger }) => { 19 | logger.info(command) 20 | await ack() 21 | 22 | await respond({ 23 | response_type: 'ephemeral', 24 | replace_original: true, 25 | blocks: [{ 26 | type: "section", 27 | text: { 28 | type: "mrkdwn", 29 | text: [ 30 | `\`${command.command} ${command.text}\` :robot_face:`, 31 | `:axe: Ayy, hold up.. Grabbing some logs rq... :wood:`, 32 | ].join('\n') 33 | } 34 | },] 35 | }) 36 | 37 | // idk why .filter doesn't work lol 38 | //const channelsOnly = [] 39 | //for (const [chId, chMa] of sortedMas(maPool)) { 40 | //if (chId.startsWith('C') && chMa.ma.average() > 0) { 41 | //channelsOnly.push([chId, chMa]) 42 | //} 43 | //} 44 | 45 | const commandMatches = (new RegExp(`^(\\d+)$`)).exec(command.text) 46 | let argTime: number = 0 47 | if (commandMatches && commandMatches[1]) { 48 | argTime = parseInt(commandMatches[1], 10) 49 | } 50 | if (argTime <= 0) { argTime = 420 } // default 51 | const sampleTime = new Date(Date.now() - 1000 * 60 * argTime) 52 | 53 | const chSamples = await prisma.movingAverage.groupBy({ 54 | by: ['slack_id'], 55 | _sum: { average: true, messages: true }, 56 | _avg: { average: true, }, 57 | _max: { average: true, }, 58 | _count: { _all: true, }, 59 | where: { // channels within the past `time` 60 | created: { gt: sampleTime, }, 61 | slack_id: { startsWith: 'C', }, 62 | slack_resource: { watching: true, }, 63 | }, 64 | //orderBy: { _max: {average: 'desc', }, }, 65 | orderBy: { _avg: {average: 'desc', }, }, 66 | having: { average: { _avg: {gt: 0}, }, }, 67 | // TODO: make .take a /channels arg 68 | take: 5, 69 | }) 70 | 71 | if (chSamples.length === 0) { 72 | // We failed here if this happens. Give a nice error message! 73 | await respond({ 74 | response_type: 'ephemeral', 75 | replace_original: true, 76 | blocks: [{ 77 | type: "section", 78 | text: { 79 | type: "mrkdwn", 80 | text: [ 81 | `:sweat_smile: OKAY nvm the surf was _NOT_ up lol! I had some trouble with the logs.. :sweat:`, 82 | `:ox: Seems like ol' Babe isn't pulling her weight, eh! :man-shrugging:`, 83 | `:happy_ping_sock: (ping @zfogg maybe?..) :expanding_brain_4:`, 84 | ].join('\n') 85 | } 86 | },] 87 | }) 88 | return 89 | } 90 | 91 | 92 | const channelSections = chSamples 93 | .map(async (chSamp: any, i) => { 94 | const { channel } = await client.conversations.info({channel: chSamp.slack_id}) 95 | //const score = (Math.exp(chMa.ma.average()) - 1).toFixed(4) 96 | const score_sum = (( chSamp._sum.average)).toFixed(2) 97 | const score_avg = (Math.exp(chSamp._avg.average)).toFixed(2) 98 | const score_max = (( chSamp._max.average)).toFixed(2) 99 | const desc = (channel as any).topic.value 100 | const desc_lines = desc.split('\n').length // max line limit smh @anirudhb 101 | return [ 102 | { 103 | type: "section", 104 | text: { 105 | type: "mrkdwn", 106 | text: [ 107 | `*#${i+1}:* <#${chSamp.slack_id || 'NULL'}> (past *${argTime}* mins: *${chSamp._sum.messages || 0}* messages)`, 108 | `*Activity score:* average = *${score_avg || -1}* | maximum = *${score_max || -1}*`, 109 | `${desc.substring(0, 512) || ''}${(desc.length > 512 || desc_lines > 3) ? ' [...]' : ''}` // 512 char limit 110 | .split('\n').slice(0, 3).join('\n'), 111 | ].join('\n'), 112 | }, 113 | }, 114 | { type: "divider" }, 115 | ] 116 | }) 117 | 118 | try { 119 | const sections = [].concat.apply([], await Promise.all(channelSections as any)); 120 | 121 | await respond({ 122 | response_type: 'ephemeral', 123 | replace_original: true, 124 | blocks: [{ 125 | type: "section", 126 | text: { 127 | type: "mrkdwn", 128 | text: [ 129 | `:axe: Surfs' up! Specifically in these channels :sparkles:`, 130 | `_(since ${formatRelative(subMinutes(new Date(), argTime), new Date())})_`, 131 | ].join('\n') 132 | } 133 | }, 134 | { 135 | type: "divider" 136 | }, 137 | ...sections, 138 | ] 139 | }) 140 | 141 | 142 | } catch (error) { 143 | console.error(error); 144 | } 145 | 146 | }) 147 | 148 | 149 | app.command(CMD.supwit, async ({ command, ack, client, body, respond, logger }) => { 150 | logger.info(command) 151 | await ack() 152 | 153 | //console.log(command) 154 | 155 | //const argTime_matches = (new RegExp(`^(\\d+)$`)).exec(command.text) 156 | const arg1Matches = /^(\d+)/.exec(command.text) 157 | //console.log({arg1Matches}) 158 | let argTime: number = 0 159 | if (arg1Matches && arg1Matches[1]) { 160 | argTime = parseInt(arg1Matches[1], 10) 161 | } 162 | if (argTime <= 0) { argTime = 1440 } // default 163 | const gt = new Date(Date.now() - 1000 * 60 * argTime) // gt = further into the past <---<--- 164 | const lt = new Date(Date.now()) // lt = closer to the present --->---> 165 | 166 | let arg2_text = arg1Matches ? command.text.split(' ').slice(1).join('') : command.text 167 | let argUserMatch = /^<(\@)(\w+)(\|.+)?>/.exec(arg2_text) 168 | let argChannelMatch = /^<(\#)(\w+)(\|.+)?>/.exec(arg2_text) 169 | let argEmojiMatch = /^(:)(.+):/.exec(arg2_text) 170 | let arg2Match: any = null 171 | const arg2Matches = [argUserMatch, argChannelMatch, argEmojiMatch] 172 | .filter((x) => x !== null) 173 | .sort((x, y) => (x && y) ? x.index - y.index : Infinity) 174 | if (arg2Matches.length) { arg2Match = arg2Matches[0] } 175 | 176 | let argSlackId: string | null = null 177 | let argSlackType: string | null = null 178 | let argSlackTypeName: string | null = null 179 | let argSlackTypeRender: string | null = null 180 | if (arg2Match && arg2Match[1] && arg2Match[2]) { 181 | argSlackId = arg2Match[2] 182 | argSlackType = arg2Match[1] 183 | if (argSlackType === '#') { argSlackTypeName = 'channel'; argSlackTypeRender = `<#${argSlackId}>` } 184 | else if (argSlackType === '@') { argSlackTypeName = 'user'; argSlackTypeRender = `<@${argSlackId}>` } 185 | else { argSlackTypeName = 'emoji'; argSlackTypeRender = `:${argSlackId}:` } 186 | } 187 | 188 | let responseBlocks: any[] = [] 189 | 190 | if (argSlackId && argSlackTypeName === 'channel') { 191 | let topUsersForChannel = await TopUsersForChannel([argSlackId], [gt, lt]) 192 | if (topUsersForChannel.length) { 193 | responseBlocks.push({ type: "divider" }) 194 | responseBlocks.push({ 195 | type: "section", 196 | text: { 197 | type: "mrkdwn", 198 | text: [ 199 | `Top _@users_ for ${argSlackTypeRender}:`, 200 | topUsersForChannel.map(x => `<@${x.user_id}> (${x._count._all})`).join(', ') 201 | ].join('\n'), 202 | }, 203 | }) 204 | } 205 | 206 | let topEmojiForChannel = await TopEmojiForChannel([argSlackId], [gt, lt]) 207 | if (topEmojiForChannel.length) { 208 | responseBlocks.push({ type: "divider" }) 209 | responseBlocks.push({ 210 | type: "section", 211 | text: { 212 | type: "mrkdwn", 213 | text: [ 214 | `Top _:emoji:_ for ${argSlackTypeRender}:`, 215 | topEmojiForChannel.map(x => `:${x.emoji_id}: (${x._count._all})`).join(', ') 216 | ].join('\n'), 217 | }, 218 | }) 219 | } 220 | 221 | } else if (argSlackId && argSlackTypeName === 'user') { 222 | let topEmojiForUser = await TopEmojiForUser([argSlackId], [gt, lt]) 223 | if (topEmojiForUser.length) { 224 | responseBlocks.push({ type: "divider" }) 225 | responseBlocks.push({ 226 | type: "section", 227 | text: { 228 | type: "mrkdwn", 229 | text: [ 230 | `Top _:emoji:_ for ${argSlackTypeRender}:`, 231 | topEmojiForUser.map(x => `:${x.emoji_id}: (${x._count._all})`).join(', ') 232 | ].join('\n'), 233 | }, 234 | }) 235 | } 236 | 237 | let topChannelsForUser = await TopChannelsForUser([argSlackId], [gt, lt]) 238 | if (topChannelsForUser.length) { 239 | responseBlocks.push({ type: "divider" }) 240 | responseBlocks.push({ 241 | type: "section", 242 | text: { 243 | type: "mrkdwn", 244 | text: [ 245 | `Top _#channels_ for ${argSlackTypeRender}:`, 246 | topChannelsForUser.map(x => `<#${x.channel_id}> (${x._count})`).join(', ') 247 | ].join('\n'), 248 | }, 249 | }) 250 | } 251 | 252 | } else if (argSlackId && argSlackTypeName === 'emoji') { 253 | let topChannelsForEmoji = await TopChannelsForEmoji([argSlackId], [gt, lt]) 254 | if (topChannelsForEmoji.length) { 255 | responseBlocks.push({ type: "divider" }) 256 | responseBlocks.push({ 257 | type: "section", 258 | text: { 259 | type: "mrkdwn", 260 | text: [ 261 | `Top _#channels_ for ${argSlackTypeRender}:`, 262 | topChannelsForEmoji.map(x => `<#${x.channel_id}> (${x._count._all})`).join(', ') 263 | ].join('\n'), 264 | }, 265 | }) 266 | } 267 | 268 | let topUsersForEmoji = await TopUsersForEmoji([argSlackId], [gt, lt]) 269 | if (topUsersForEmoji.length) { 270 | responseBlocks.push({ type: "divider" }) 271 | responseBlocks.push({ 272 | type: "section", 273 | text: { 274 | type: "mrkdwn", 275 | text: [ 276 | `Top _@users_ for ${argSlackTypeRender}:`, 277 | topUsersForEmoji.map(x => `<@${x.user_id}> (${x._count._all})`).join(', ') 278 | ].join('\n'), 279 | }, 280 | }) 281 | } 282 | 283 | } else { 284 | let topEmoji = await TopEmoji(25, [gt, lt]) 285 | if (topEmoji.length) { 286 | responseBlocks.push({ type: "divider" }) 287 | responseBlocks.push({ 288 | type: "section", 289 | text: { 290 | type: "mrkdwn", 291 | text: [ 292 | `Top *:emoji:* _(hint: try \`${CMD.supwit} :scrappy:\`)_`, 293 | //topEmoji.map(x => `:${x.emoji_id}: - \`:${x.emoji_id}:\` (${x._count._all})`).join(', ') 294 | topEmoji.map(x => `:${x.emoji_id}: (${x._count._all})`).join(', ') 295 | ].join('\n'), 296 | }, 297 | }) 298 | } 299 | 300 | let topChannels = await TopChannels(25, [gt, lt]) 301 | if (topChannels.length) { 302 | responseBlocks.push({ type: "divider" }) 303 | responseBlocks.push({ 304 | type: "section", 305 | text: { 306 | type: "mrkdwn", 307 | text: [ 308 | `Top *#channels* _(hint: try \`${CMD.supwit} #scrapbook\`)_`, 309 | topChannels.map(x => `<#${x.channel_id}> (${(x as any)._count._all})`).join(', ') 310 | ].join('\n'), 311 | }, 312 | }) 313 | } 314 | 315 | let topUsers = await TopUsers(25, [gt, lt]) 316 | if (topEmoji.length) { 317 | responseBlocks.push({ type: "divider" }) 318 | responseBlocks.push({ 319 | type: "section", 320 | text: { 321 | type: "mrkdwn", 322 | text: [ 323 | `Top *@users* _(hint: try \`${CMD.supwit} @scrappy\`)_`, 324 | topUsers.map(x => `<@${x.user_id}> (${(x as any)._count._all})`).join(', ') 325 | ].join('\n'), 326 | }, 327 | }) 328 | } 329 | 330 | } 331 | 332 | //console.log(responseBlocks) 333 | 334 | const queryMsg = argSlackType ? `\`${argSlackTypeName}\` - ${argSlackTypeRender}` : 'ALL OF SLACK' 335 | const headerMsg = responseBlocks.length > 0 336 | ? [ 337 | `\`${command.command} ${command.text}\` :robot_face:`, 338 | `:wave: Phew! Logs are heavy. Here's ur info.. :sparkles:`, 339 | ].join('\n') 340 | : `:sad-yeehaw: Well shucks, I don't have anything in my log pile that matches that. I reckon if you run this in the future though I can find something for you.` 341 | 342 | //console.log({argSlackType, argSlackTypeName, argSlackTypeRender, argSlackId}) 343 | 344 | const bodyMsg = responseBlocks.length > 0 345 | ? `'sup wit: ${queryMsg} _(since ${formatRelative(subMinutes(new Date(), argTime), new Date())})_` 346 | : `_(hint: try \`<@${BOT_ID}> status (channel|me)\` or \`<@${BOT_ID}> help\`)_` 347 | 348 | await respond({ 349 | response_type: 'ephemeral', 350 | replace_original: true, 351 | blocks: [{ 352 | type: "section", 353 | text: { 354 | type: "mrkdwn", 355 | text: [ 356 | headerMsg, 357 | bodyMsg, 358 | ].join('\n') 359 | } 360 | }, 361 | ...responseBlocks, 362 | ] 363 | }) 364 | 365 | }) 366 | 367 | 368 | //export async function modalBlocks(modalMas) { } 369 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { MaPool } from './convos' 2 | 3 | 4 | export function sortedMas(maPool: MaPool) { 5 | return Object.entries(maPool) 6 | .sort((a, b) => { 7 | if (a[1].ma.average() < b[1].ma.average()) { 8 | return 1 9 | } else if (a[1].ma.average() > b[1].ma.average()) { 10 | return -1 11 | } else { 12 | return 0 13 | } 14 | }) 15 | } 16 | 17 | 18 | export function nonzeroMas(maPool: MaPool) { 19 | return Object.values(maPool).filter((x) => { 20 | return x.ma.average() > 0 21 | }) 22 | } 23 | 24 | 25 | export const snooze = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) 26 | -------------------------------------------------------------------------------- /systemd/system/bunyan-cleanup.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Bunyan - cleanup the database 3 | StartLimitInterval=200 4 | StartLimitBurst=5 5 | 6 | [Service] 7 | User=zfogg 8 | Type=oneshot 9 | WorkingDirectory=/opt/bunyan 10 | EnvironmentFile=/opt/bunyan/.env 11 | ExecStart=/bin/zsh /opt/bunyan/scripts/MovingAverage.cleanup.zsh 12 | RestartSec=30 13 | -------------------------------------------------------------------------------- /systemd/system/bunyan-cleanup.timer: -------------------------------------------------------------------------------- 1 | [Install] 2 | WantedBy=default.target 3 | WantedBy=timers.target 4 | 5 | [Unit] 6 | Description=Bunyan - clean up the database 7 | 8 | [Timer] 9 | OnCalendar=*-*-1,15 4:00:00 10 | Unit=bunyan-cleanup.service 11 | Persistent=true 12 | -------------------------------------------------------------------------------- /systemd/system/bunyan.service: -------------------------------------------------------------------------------- 1 | [Install] 2 | WantedBy=default.target 3 | 4 | [Unit] 5 | Description=Bunyan - run the nodejs slack bot app 6 | StartLimitInterval=200 7 | StartLimitBurst=5 8 | After=network-online.target 9 | 10 | [Service] 11 | User=zfogg 12 | Type=simple 13 | WorkingDirectory=/opt/bunyan 14 | EnvironmentFile=/opt/bunyan/.env 15 | ExecStart=/usr/bin/yarn run systemd:execstart:dev 16 | RestartSec=30 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["types"], 3 | "include": ["src"], 4 | "compilerOptions": { 5 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 6 | 7 | /* Basic Options */ 8 | "incremental": true, /* Enable incremental compilation */ 9 | "target": "ESNEXT" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 10 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 11 | "lib": ["es2020", "dom", "esnext"], /* Specify library files to be included in the compilation. */ 12 | // "allowJs": true, /* Allow javascript files to be compiled. */ 13 | // "checkJs": true, /* Report errors in .js files. */ 14 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 15 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 16 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 17 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 18 | // "outFile": "./", /* Concatenate and emit output to single file. */ 19 | "outDir": "dist", /* Redirect output structure to the directory. */ 20 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 21 | // "composite": true, /* Enable project compilation */ 22 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 23 | // "removeComments": true, /* Do not emit comments to output. */ 24 | // "noEmit": true, /* Do not emit outputs. */ 25 | "importHelpers": true, /* Import emit helpers from 'tslib'. */ 26 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 27 | //"isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 28 | 29 | /* Strict Type-Checking Options */ 30 | "strict": false /* Enable all strict type-checking options. */, 31 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 32 | "strictNullChecks": true, /* Enable strict null checks. */ 33 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 34 | "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 35 | "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 36 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 37 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 38 | 39 | /* Additional Checks */ 40 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 41 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 42 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 43 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 44 | "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 45 | 46 | /* Module Resolution Options */ 47 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 48 | "resolveJsonModule": true, 49 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 50 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 51 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 52 | "typeRoots": ["./types", "node_modules/@types"], /* List of folders to include type definitions from. */ 53 | // "types": [], /* Type declaration files to be included in compilation. */ 54 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 55 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 56 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 57 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 58 | 59 | /* Source Map Options */ 60 | //"sourceRoot": "dist", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 61 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 62 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 63 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 64 | 65 | /* Experimental Options */ 66 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 67 | "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 68 | 69 | /* Advanced Options */ 70 | "skipLibCheck": true /* Skip type checking of declaration files. */, 71 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 72 | } 73 | } 74 | --------------------------------------------------------------------------------