",
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 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
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 |
--------------------------------------------------------------------------------